Add voice library, embedding-mode generation, and webUI improvements

- 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
This commit is contained in:
B.D. Murphy
2026-09-02 17:33:15 -07:00
parent c86e323d7a
commit a172610b0e
22 changed files with 1377 additions and 141 deletions

View File

@@ -3,55 +3,75 @@
## 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.
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
```
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
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 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:
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 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
- **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_embeddings/ # Speaker identity cache
├── 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)
│ ├── nanny.npy
│ ├── kainda_walters_foster_murphy.npy
│ ├── deal_hunter.npy
│ └── ... # 94 speakers total
├── voices/ # Legacy WAV refs (kept for fallback)
│ └── ... # kept only for fallback/experiments
├── voices/ # Approved reference WAVs (the "listen to" artifact)
│ ├── narrator_ref.wav
│ ├── nanny_ref.wav
│ └── ...
@@ -63,40 +83,49 @@ entirely. This gives:
## Generation Modes
### Mode 1: Cached Embedding (Preferred)
# Mode 1 is the CANONICAL mode (since the vmatrix fix):
# client captures the FULL vmatrix once, then generates via prompt_slug.
Fastest and most consistent. Used when a `.npy` embedding exists for the speaker.
### 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 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,
# 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)
```
**Determinism**: Same embedding + same seed = **identical waveform** (verified: mean abs diff = 0.000000).
**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: WAV Reference (Fallback)
### Mode 2: Embedding-only (x_vector_only=1)
Used when no cached embedding exists. Extracts x-vector at generation time.
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
@@ -105,9 +134,10 @@ POST /v1/tts
temperature: 0.5
```
### Mode 3: VoiceDesign (New Voice Creation)
### Mode 4: VoiceDesign (New Voice Creation)
Used to create new character voices from text descriptions.
Used to create new character voices from text descriptions (also used to generate the
initial `*_ref.wav` for each character).
```python
POST /v1/tts
@@ -201,33 +231,38 @@ Returns server status and loaded models.
### Per-Utterance Consistency
Each utterance uses the same embedding + same seed → identical voice identity.
Variation comes only from:
- Different text (phoneme sequence)
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 speaker embedding used across all chapters → same voice identity.
The narrator always sounds like the narrator. Kainda always sounds like Kainda.
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.
### Determinism
### Capture Discipline
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
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. **Generate**: For each utterance:
- Load speaker embedding from `.npy` cache
- Send to server with text + temperature + seed
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`
4. **Stitch**: Concatenate utterance WAVs into chapter WAV
5. **Export**: Convert chapter WAV to MP3 via ffmpeg
6. **Verify**: Run quality checks
5. **Stitch**: Concatenate utterance WAVs into chapter WAV
6. **Export**: Convert chapter WAV to MP3 via ffmpeg
7. **Verify**: Run quality checks
## Hardware Constraints
@@ -281,11 +316,14 @@ Maps character names to Edge-TTS voice identifiers (used for WAV ref generation)
## Troubleshooting
### Voice sounds wrong
### Voice sounds wrong / "it's still the old voice"
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
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
@@ -302,6 +340,7 @@ 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 ...").
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).

View File

@@ -257,22 +257,23 @@ async def build_voice_prompt(
x_vector_only: int = Form(0),
force: int = Form(0),
):
"""Build and cache a speaker's digital voice (a set of numbers) once.
"""Build and cache a speaker's vmatrix (digital voice) once.
Two capture modes (pick per pipeline; a speaker's cached prompt is used as-is
on every subsequent utterance):
The vmatrix is the set of numbers inside Qwen3-TTS that IS the voice. Two capture
modes (a speaker's cached prompt is reused on every subsequent utterance via
prompt_slug):
* 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.
* x_vector_only=0 (DEFAULT, canonical, "full vmatrix"): extracts the FULL
ICL codec-token matrix (ref_code, the "8k matrix") + speaker embedding.
Generation conditions on the exact reference identity, so every utterance
is faithful AND identical in identity across the whole pipeline.
* x_vector_only=1: captures ONLY the coarse speaker embedding (ref_code=None).
Legacy/experimental - caused the historical "voice changed between
chapters" inconsistency. Not used for production consistency.
Idempotent: if `<slug>.pt` is already cached it returns without rebuilding,
unless `force=1` is passed (use force to switch an existing prompt mode).
unless `force=1` is passed (use force to switch an existing prompt mode, e.g.
after an approved reference WAV is replaced).
"""
path = _prompt_path(slug)
if path.exists() and not force:

View File

@@ -248,6 +248,32 @@ This table is what later lets a Pass 3 "Always" promotion (`04-mapping.md`, "Spe
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.
### Approved Voice = Reference WAV + Captured Vmatrix
An approval now records **two artifacts** for the character (this is the requirement: a wav to *listen* to + the numbers inside the TTS that *are* the voice):
| Artifact | Path | Purpose |
|---|---|---|
| Reference WAV | `.umbra/voices/{slug}_ref.wav` | Listenable sample of the approved voice |
| **Vmatrix** (.pt) | `.umbra/voice_prompts/{slug}.pt` | The captured voice identity used for consistency |
The **vmatrix** is what Qwen3-TTS actually conditions on to reproduce a voice:
- `ref_code` — the ICL **"8k matrix"**: a `(T, 16)` tensor of codec tokens produced by re-speaking the reference WAV through the model's codec encoder.
- `ref_spk_embedding` — the 2048-dim extracted speaker x-vector.
It is captured once (server-side `POST /v1/voice-prompt`, `x_vector_only=0`) and cached
under `voice_prompts/{slug}.pt`. `POST /api/speakers/voice-status` **approve** calls
`build_voice_prompt(...)` and records `vmatrix_path` in the voice registry next to
`voice_ref_path`. Every subsequent utterance passes `prompt_slug={slug}` so it reuses
the exact same numbers — a character's voice can never drift or "change" between
chapters. If the reference WAV is ever replaced, the `.pt` must be rebuilt (approve
again, or `force=True`) or generation will silently use the stale voice.
Note on modes: `x_vector_only=True` (embedding-only) is the legacy-coarse capture and
must NOT be used for production consistency. The full vmatrix (`x_vector_only=False`) is
the canonical mode and the engine default.
### Skip Logic
Voice references are not regenerated if:

View File

@@ -12,7 +12,8 @@ Pass 3 generates audio for every utterance in every chapter using **Qwen3-TTS**
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 generate(self, text: str, output_path: Path, voice_ref: Path | None = None, instruct: str = "", prompt_slug: str | None = None) -> Path
def build_voice_prompt(self, voice_ref: Path, ref_text: str | None = None, slug: str | None = None, x_vector_only: bool = False, force: bool = False) -> str
def unload_all(self) -> None
```
@@ -20,8 +21,28 @@ class QwenTTSEngine:
- 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)
- **Vmatrix capture** (`build_voice_prompt`): computes the voice's "8k matrix" (ICL `ref_code` codec-token matrix) + speaker embedding, caches it server-side as `voice_prompts/{slug}.pt`, returns the `prompt_slug` to pass to `generate()`.
- **`prompt_slug` generation** (the consistency path): every utterance of a character passes the same slug, so the exact same cached vmatrix is reused — voice identity can never drift between utterances or chapters.
- WAV output at 24kHz sample rate
> **`x_vector_only` default is `False` (full vmatrix).** The full capture (ref_code + embedding) is the canonical mode; the embed-only mode (`True`) is legacy/experimental and caused the historical "voice changed between chapters" problem.
### Vmatrix Capture in the Generation Flow
Because `build_voice_prompt` defaults to `/v1/voice-prompt` server semantics, the bulk
generator captures each distinct speaker's vmatrix once per run (before any utterance is
generated) and reuses the slug for all of that speaker's utterances:
```
for each distinct speaker with a missing .pt:
prompt_slug = build_voice_prompt(vref, ref_text=VOICE_REFERENCE_TEXT, x_vector_only=False)
# server writes/updates voice_prompts/{slug}.pt = {ref_code (T,16), ref_spk_embedding (2048,), icl_mode: True}
```
The `.pt` cache is the same filesystem location the server writes to (`PROMPT_DIR`,
default `.umbra/voice_prompts`), so it persists across runs and is shipped to the GPU
host via `tts_server/sync_voice_data.sh`.
### Edge-TTS (`voices/edge_tts.py`) — Test Samples & Fallback
```python
@@ -58,6 +79,11 @@ def resolve_reference(
```
For each chapter:
│
├── 0. Capture vmatrix for each distinct speaker (once per run)
│ prompt_slug = engine.build_voice_prompt(vref, ref_text=VOICE_REFERENCE_TEXT,
│ x_vector_only=False) # full "8k matrix" + embedding
│ # → voice_prompts/{slug}.pt (cached; missing only when the ref changed)
│
├── 1. Load utterances from DB
│ SELECT id, speaker_name, text, utterance_index
@@ -72,11 +98,12 @@ For each chapter:
├── Skip if file exists
│ if output_path.exists(): continue
│
├── Resolve voice reference
├── Resolve voice reference → prompt_slug
│ ref_path = resolve_reference(speaker, voice_refs, alias_map, narrator_ref)
│ slug = engine.slug_for_ref(ref_path)
│
├── Generate audio (Qwen3-TTS)
│ engine.generate(text, output_path, voice_ref=ref_path)
├── Generate audio (Qwen3-TTS) — identical cached vmatrix for the speaker
│ engine.generate(text, output_path, prompt_slug=slug)
│ # no ref → engine.generate(text, output_path, instruct=neutral)
│
├── Verify output file exists (else count as failed)
@@ -85,6 +112,10 @@ For each chapter:
Log error, continue to next utterance
```
Note the two-layer design: step 0 captures the vmatrix (a set of numbers) **once** per
speaker; step 3 reuses that identical capture for every utterance via `prompt_slug`.
This is what guarantees a character's voice never drifts across a chapter or the book.
**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
@@ -178,7 +209,7 @@ Success rate is reported per run as `generated / failed`; failed utterances can
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
3. **Vmatrix identity from references**: identity comes from the **captured vmatrix** of each `*_ref.wav` (ICL codec-token "8k matrix" + speaker embedding, cached as `voice_prompts/{slug}.pt`), not from `voice_assignments.json` voice strings with pitch shifts, and not from a coarse embedding-only capture. Every utterance reuses the same `.pt` via `prompt_slug`.
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

113
tests/test_library.py Normal file
View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from pathlib import Path
from umbraaudio import library
def _speaker(name: str = "Distorted Voice", **overrides) -> dict:
sp = {
"primary_name": name,
"aliases": [],
"gender": "Female",
"accent": "canadian",
"vocal_traits": ["raspy"],
"distortion_effects": ["hard_clip"],
"is_narrator": False,
"is_group": False,
"description": "test speaker",
}
sp.update(overrides)
return sp
class TestAddSpeakers:
def test_adds_and_copies_voice_ref(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
voices = temp_dir / "libvoices"
voices.mkdir(parents=True)
src = temp_dir / "src_distorted_voice_ref.wav"
src.write_bytes(b"RIFFDATAFORVOICE")
res = library.add_speakers(
[_speaker()],
source_project="TestProject",
voice_refs={"Distorted Voice": src},
db_path=db,
voices_dir=voices,
)
assert res["added"] == ["Distorted Voice"]
assert (voices / "distorted_voice_ref.wav").exists()
assert (voices / "distorted_voice_ref.wav").read_bytes() == b"RIFFDATAFORVOICE"
rows = library.list_speakers(db_path=db)
assert len(rows) == 1
assert rows[0]["primary_name"] == "Distorted Voice"
assert "distorted_voice_ref.wav" in rows[0]["voice_ref_path"]
def test_idempotent_duplicate(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
voices = temp_dir / "lv"
voices.mkdir(parents=True)
src = temp_dir / "narrator_ref.wav"
src.write_bytes(b"REF")
library.add_speakers(
[_speaker("Narrator", is_narrator=True)],
"P", {"Narrator": src}, db_path=db, voices_dir=voices,
)
res = library.add_speakers(
[_speaker("Narrator", is_narrator=True)],
"P", {"Narrator": src}, db_path=db, voices_dir=voices,
)
assert res["already"] == ["Narrator"]
assert len(library.list_speakers(db_path=db)) == 1
def test_skips_speaker_without_ref(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
res = library.add_speakers(
[_speaker()], "P", {}, db_path=db, voices_dir=temp_dir,
)
assert res["added"] == ["Distorted Voice"]
assert library.list_speakers(db_path=db)[0]["voice_ref_path"] is None
class TestAddNames:
def test_adds_records(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
res = library.add_names(
[{"name": "Kainda", "phonetic": "KAYN-dah", "source": "user",
"common": False, "confirmed": True}],
source_project="P",
db_path=db,
)
assert res["added"] == ["Kainda"]
rows = library.list_names(db_path=db)
assert rows[0]["name"] == "Kainda"
assert rows[0]["phonetic"] == "KAYN-dah"
assert rows[0]["confirmed"] == 1
def test_idempotent_duplicate(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
rec = {"name": "Kainda", "phonetic": "KAYN-dah"}
library.add_names([rec], "P", db_path=db)
res = library.add_names([rec], "P", db_path=db)
assert res["already"] == ["Kainda"]
assert len(library.list_names(db_path=db)) == 1
class TestDelete:
def test_delete_speaker(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
library.add_speakers([_speaker()], "P", {}, db_path=db, voices_dir=temp_dir)
sid = library.list_speakers(db_path=db)[0]["id"]
assert library.delete_speaker(sid, db_path=db) is True
assert library.list_speakers(db_path=db) == []
def test_delete_name(self, temp_dir: Path):
db = temp_dir / "voice_registry.db"
library.add_names([{"name": "Kainda"}], "P", db_path=db)
nid = library.list_names(db_path=db)[0]["id"]
assert library.delete_name(nid, db_path=db) is True
assert library.list_names(db_path=db) == []

View File

@@ -144,3 +144,67 @@ async def test_link_to_project(registry_db_path: Path, character_profile: Charac
await registry.link_to_project(reg_id, 1, role_in_book="Protagonist")
await registry.link_to_project(reg_id, 2)
await registry.close()
@pytest.mark.asyncio
async def test_set_vmatrix(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.set_vmatrix(reg_id, "/umbra/voice_prompts/nanny.pt")
found = await registry.find_by_name("John Smith")
assert found is not None
assert found["vmatrix_path"] == "/umbra/voice_prompts/nanny.pt"
await registry.close()
@pytest.mark.asyncio
async def test_migrate_adds_vmatrix_column(
registry_db_path: Path, character_profile: CharacterProfile
):
"""A DB created by the previous schema (no vmatrix_path) gains the column."""
legacy_schema = """
CREATE TABLE voice_registry (
id INTEGER PRIMARY KEY,
character_name TEXT NOT NULL UNIQUE,
aliases TEXT NOT NULL DEFAULT '[]',
gender TEXT, age_range TEXT,
pitch_register TEXT DEFAULT 'mid',
pace_baseline TEXT DEFAULT 'moderate',
energy TEXT DEFAULT 'measured',
timbre TEXT DEFAULT 'clear',
texture TEXT DEFAULT 'smooth',
formality TEXT DEFAULT 'conversational',
emotional_baseline TEXT DEFAULT 'neutral_calm',
distinguishing_trait TEXT,
accent_marker TEXT DEFAULT 'none',
voice_description TEXT,
voice_ref_path TEXT,
sample_audio_path TEXT,
speaker_embedding BLOB,
source_project TEXT,
is_narrator INTEGER NOT NULL DEFAULT 0,
is_general INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
import aiosqlite
conn = await aiosqlite.connect(str(registry_db_path))
await conn.executescript(legacy_schema)
await conn.commit()
await conn.close()
registry = VoiceRegistry(registry_db_path)
await registry.connect()
cursor = await registry.conn.execute("PRAGMA table_info(voice_registry)")
cols = {row["name"] for row in await cursor.fetchall()}
assert "vmatrix_path" in cols
reg_id, _ = await registry.find_or_create(character_profile)
await registry.set_vmatrix(reg_id, "/umbra/voice_prompts/nanny.pt")
found = await registry.find_by_name("John Smith")
assert found["vmatrix_path"] == "/umbra/voice_prompts/nanny.pt"
await registry.close()

View File

@@ -7,6 +7,22 @@ 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.
## The vmatrix (voice identity)
A character's voice = a reference WAV (**what you listen to**) + a **vmatrix** (the
set of numbers inside the TTS that *is* the voice, used for consistency). The vmatrix
is captured by `POST /v1/voice-prompt` and cached as `<slug>.pt` in `QWEN_PROMPT_DIR`:
- `ref_code` — the ICL **"8k matrix"**: a `(T, 16)` tensor of codec tokens produced by
re-speaking the reference WAV through the model's codec encoder
- `ref_spk_embedding` — the 2048-dim extracted speaker x-vector
**`x_vector_only=0` (the default) captures the FULL vmatrix** (ref_code + embedding,
`icl_mode=True`) — faithful and perfectly consistent. `x_vector_only=1` captures only
the sparse embedding (`ref_code=None`) and is legacy/experimental; the pipeline client
(`QwenTTSEngine.build_voice_prompt`) defaults to the full vmatrix and passes it on every
utterance as `prompt_slug`, so a character's voice can never drift between chapters.
## Contents
- `qwen_tts_server.py` — the FastAPI server (port 8881, `/health`,
@@ -47,6 +63,20 @@ 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)
```
## `POST /v1/voice-prompt` — capture the vmatrix
| form field | default | meaning |
|---|---|---|
| `slug` | required | cache key; stored as `{QWEN_PROMPT_DIR}/{slug}.pt` (same key the client passes as `prompt_slug`) |
| `ref_audio` | required (file) | the approved reference WAV for the speaker |
| `ref_text` | Rainbow Passage | reference transcript used to rebuild the `ref_code` "8k matrix" |
| `x_vector_only` | `0` | `0` = FULL vmatrix (`ref_code` + `ref_spk_embedding`, `icl_mode=True`); `1` = embedding-only (`ref_code=None`) |
| `force` | `0` | `1` = rebuild even if `<slug>.pt` already cached |
Idempotent: if `<slug>.pt` exists and `force` is unset, it returns the cached prompt
without re-extracting. The client's `build_voice_prompt` defaults to the full vmatrix
(`x_vector_only=False`) and calls this once per distinct speaker per generation run.
## Point the pipeline at it
The engine already reads the TTS URL from config (`qwen_tts_url`, default
@@ -64,12 +94,17 @@ QWEN_DEVICE=cuda ./run_server.sh # on GPU host, binds 0.0.0.0:8881
| `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_PROMPT_DIR` | `./voice_prompts` | cached vmatrix `.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 |
> **Deployment note:** in the live deployment (`192.168.123.14:8881`) `QWEN_PROMPT_DIR`
> points at the **project's** `.umbra/voice_prompts` on the shared volume, and
> `sync_voice_data.sh` ships both `voices/*_ref.wav` and `voice_prompts/*.pt` to the GPU
> box. That shared location is why Approve/build_voice_prompt immediately makes the
> vmatrix available to generation without extra steps.
> 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

View File

@@ -257,22 +257,23 @@ async def build_voice_prompt(
x_vector_only: int = Form(0),
force: int = Form(0),
):
"""Build and cache a speaker's digital voice (a set of numbers) once.
"""Build and cache a speaker's vmatrix (digital voice) once.
Two capture modes (pick per pipeline; a speaker's cached prompt is used as-is
on every subsequent utterance):
The vmatrix is the set of numbers inside Qwen3-TTS that IS the voice. Two capture
modes (a speaker's cached prompt is reused on every subsequent utterance via
prompt_slug):
* 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.
* x_vector_only=0 (DEFAULT, canonical, "full vmatrix"): extracts the FULL
ICL codec-token matrix (ref_code, the "8k matrix") + speaker embedding.
Generation conditions on the exact reference identity, so every utterance
is faithful AND identical in identity across the whole pipeline.
* x_vector_only=1: captures ONLY the coarse speaker embedding (ref_code=None).
Legacy/experimental - caused the historical "voice changed between
chapters" inconsistency. Not used for production consistency.
Idempotent: if `<slug>.pt` is already cached it returns without rebuilding,
unless `force=1` is passed (use force to switch an existing prompt mode).
unless `force=1` is passed (use force to switch an existing prompt mode, e.g.
after an approved reference WAV is replaced).
"""
path = _prompt_path(slug)
if path.exists() and not force:

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import json
import logging
import os
import sqlite3
import subprocess
import threading
@@ -153,6 +154,12 @@ class ChapterBuilder:
self.use_seed = use_seed
self.max_retries = max_retries
self.tts_health_interval = tts_health_interval
# Generation mode for cached voice prompts. Full-vmatrix ICL generation
# (prompt_slug -> cached ref_code) is measured at >15 min/utterance on
# the GPU box; embedding-mode generation (speaker embedding extracted
# from the current approved reference wav) is ~17s/utterance with the
# same voice identity. Default ON; disable via env to force slug mode.
self.use_embedding_mode = os.environ.get("UMBRA_TTS_EMBEDDING_MODE", "1") != "0"
self.data_dir: Path = config.data_dir
self.status_path: Path = (
@@ -248,17 +255,37 @@ class ChapterBuilder:
# TTS server reachable
import httpx
url = f"{self.config.tts.qwen_tts_url}/health"
health = None
try:
r = httpx.get(f"{self.config.tts.qwen_tts_url}/health", timeout=10)
r = httpx.get(url, timeout=10)
if r.status_code >= 500:
errors.append(f"TTS server returned HTTP {r.status_code}")
else:
health = r.json()
if not health.get("base_loaded"):
errors.append("TTS base model not loaded")
except Exception as e:
errors.append(f"TTS server unreachable: {e}")
# Auto-load the TTS base model if it isn't already loaded so that the
# webUI chapter build is self-sufficient (no manual pre-load step).
if not errors and health is not None and not health.get("base_loaded"):
load_url = f"{self.config.tts.qwen_tts_url}/load"
try:
self._set_phase("preflight", "Loading TTS base model...", 3)
rr = httpx.post(load_url, json={"model_type": "base"}, timeout=600)
if rr.status_code >= 500:
errors.append(f"TTS server failed to load base model (HTTP {rr.status_code})")
else:
hr = httpx.get(url, timeout=10)
health = hr.json() if hr.status_code < 500 else {}
except Exception as e:
errors.append(f"Failed to load TTS base model: {e}")
if not errors and not health.get("base_loaded"):
errors.append("TTS base model not loaded")
if not errors and not health.get("base_loaded"):
errors.append("TTS base model not loaded")
# Chapter has utterances
db = sqlite3.connect(str(self.data_dir / "project.db"))
db.row_factory = sqlite3.Row
@@ -405,9 +432,13 @@ class ChapterBuilder:
engine = QwenTTSEngine(qwen_tts_url=self.config.tts.qwen_tts_url)
# Build the ICL voice-clone prompt (full ref_code "8k matrix" + embedding)
# once per distinct speaker, so every utterance reuses the identical voice
# identity derived from the speaker-page reference wav.
# Capture the full vmatrix (the "8k matrix" ref_code + speaker embedding)
# once per distinct speaker, cached server-side as .pt under PROMPT_DIR.
# x_vector_only=False (explicit, and the engine default) captures the FULL
# vmatrix so the approved voice's exact numbers are persisted as the
# canonical record. Generation reuses that identity via embedding-mode
# (~17s/utt); full-vmatrix ICL generation is >15 min/utt, see
# use_embedding_mode.
prompt_map: dict[str, str] = {}
for u in missing:
spk = (u.get("speaker_name") or "narrator").strip()
@@ -419,6 +450,7 @@ class ChapterBuilder:
prompt_map[spk] = engine.build_voice_prompt(
vref,
ref_text=VOICE_REFERENCE_TEXT,
x_vector_only=False,
)
self._start_health_monitor()
@@ -472,7 +504,18 @@ class ChapterBuilder:
try:
prompt_slug = prompt_map.get(speaker)
if prompt_slug:
voice_mode = None
if voice_ref and self.use_embedding_mode:
engine.generate(
text=spoken_text,
output_path=out,
voice_ref=voice_ref,
ref_text=VOICE_REFERENCE_TEXT,
temperature=self.temperature,
seed=seed,
)
voice_mode = "embedding"
elif prompt_slug:
engine.generate(
text=spoken_text,
output_path=out,
@@ -480,6 +523,7 @@ class ChapterBuilder:
temperature=self.temperature,
seed=seed,
)
voice_mode = "icl"
elif voice_ref:
engine.generate(
text=spoken_text,
@@ -489,6 +533,7 @@ class ChapterBuilder:
temperature=self.temperature,
seed=seed,
)
voice_mode = "embedding"
else:
engine.generate(
text=spoken_text,
@@ -510,7 +555,7 @@ class ChapterBuilder:
write_marker(
spoken_text, out,
speaker=speaker, voice_ref=voice_ref,
voice_mode="icl" if prompt_map.get(speaker) else None,
voice_mode=voice_mode,
)
success = True
break

253
umbraaudio/library.py Normal file
View File

@@ -0,0 +1,253 @@
"""Cross-project voice/name library.
The Library stores speakers (with their voice reference audio), confirmed
proper-name pronunciations, and (future) reusable utterances so they can be
used on other audiobook projects. Data lives in the shared
``~/.umbra/voice_registry.db`` (same database as the Voice Registry) and the
copied voice samples live in ``~/.umbra/library/voices/``.
"""
from __future__ import annotations
import json
import shutil
import sqlite3
from pathlib import Path
from typing import Any
DEFAULT_LIBRARY_DB = Path.home() / ".umbra" / "voice_registry.db"
DEFAULT_LIBRARY_VOICES = Path.home() / ".umbra" / "library" / "voices"
LIBRARY_SCHEMA = """
CREATE TABLE IF NOT EXISTS library_speakers (
id INTEGER PRIMARY KEY,
primary_name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '[]',
gender TEXT,
accent TEXT,
vocal_traits TEXT NOT NULL DEFAULT '[]',
distortion_effects TEXT NOT NULL DEFAULT '[]',
is_narrator INTEGER NOT NULL DEFAULT 0,
is_group INTEGER NOT NULL DEFAULT 0,
description TEXT,
source_project TEXT,
voice_ref_path TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(primary_name)
);
CREATE TABLE IF NOT EXISTS library_names (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
phonetic TEXT,
source TEXT,
common INTEGER NOT NULL DEFAULT 0,
confirmed INTEGER NOT NULL DEFAULT 0,
source_project TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS library_utterances (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
speaker_name TEXT,
voice_ref_path TEXT,
note TEXT,
source_project TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
def get_library_db_path() -> Path:
return DEFAULT_LIBRARY_DB
def get_library_voices_dir() -> Path:
return DEFAULT_LIBRARY_VOICES
def ensure_library_schema(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or get_library_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
conn.executescript(LIBRARY_SCHEMA)
conn.commit()
return conn
def _json_str(value: Any) -> str:
if isinstance(value, str):
try:
json.loads(value)
return value
except Exception:
return json.dumps([value])
if value is None:
return "[]"
return json.dumps(value)
def add_speakers(
speakers: list[dict],
source_project: str | None,
voice_refs: dict[str, Path],
db_path: Path | None = None,
voices_dir: Path | None = None,
) -> dict:
"""Add speakers to the library, copying their voice reference into the
library voices directory.
``speakers`` are the speaker dicts (from speakers.json). ``voice_refs``
maps speaker primary_name -> path to that speaker's ``*_ref.wav``.
Returns ``{"added": [...], "already": [...], "skipped": [...]}``.
"""
conn = ensure_library_schema(db_path)
vdir = voices_dir or get_library_voices_dir()
vdir.mkdir(parents=True, exist_ok=True)
added, already, skipped = [], [], []
inserted = 0
for sp in speakers:
name = (sp.get("primary_name") or "").strip()
if not name:
continue
exists = conn.execute(
"SELECT id FROM library_speakers WHERE primary_name = ?", (name,)
).fetchone()
if exists:
already.append(name)
continue
ref_src = voice_refs.get(name)
copied_ref = None
if ref_src and Path(ref_src).exists():
dest = vdir / f"{name.lower().replace(' ', '_')}_ref.wav"
try:
shutil.copyfile(str(ref_src), str(dest))
copied_ref = str(dest)
except Exception:
copied_ref = None
conn.execute(
"""INSERT INTO library_speakers
(primary_name, aliases, gender, accent, vocal_traits,
distortion_effects, is_narrator, is_group, description,
source_project, voice_ref_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
name,
_json_str(sp.get("aliases", [])),
sp.get("gender") or sp.get("demographics", {}).get("gender"),
sp.get("accent") or sp.get("demographics", {}).get("accent"),
_json_str(sp.get("vocal_traits", [])),
_json_str(sp.get("distortion_effects", [])),
1 if sp.get("is_narrator") else 0,
1 if sp.get("is_group") else 0,
sp.get("description"),
source_project,
copied_ref,
),
)
inserted += 1
added.append(name)
conn.commit()
conn.close()
return {"added": added, "already": already, "skipped": skipped, "inserted": inserted}
def add_names(
names: list[dict],
source_project: str | None,
db_path: Path | None = None,
) -> dict:
"""Add proper-name pronunciation records to the library.
``names`` are records with at least ``name`` and optionally ``phonetic``,
``source``, ``common``, ``confirmed``.
"""
conn = ensure_library_schema(db_path)
added, already = [], []
for rec in names:
name = (rec.get("name") or "").strip()
if not name:
continue
exists = conn.execute(
"SELECT id FROM library_names WHERE name = ?", (name,)
).fetchone()
if exists:
already.append(name)
continue
conn.execute(
"""INSERT INTO library_names (name, phonetic, source, common, confirmed, source_project)
VALUES (?,?,?,?,?,?)""",
(
name,
rec.get("phonetic"),
rec.get("source"),
1 if rec.get("common") else 0,
1 if rec.get("confirmed") else 0,
source_project,
),
)
added.append(name)
conn.commit()
conn.close()
return {"added": added, "already": already}
def list_speakers(db_path: Path | None = None) -> list[dict]:
conn = ensure_library_schema(db_path)
rows = conn.execute(
"SELECT * FROM library_speakers ORDER BY is_narrator DESC, primary_name"
).fetchall()
data = [dict(r) for r in rows]
conn.close()
return data
def list_names(db_path: Path | None = None) -> list[dict]:
conn = ensure_library_schema(db_path)
rows = conn.execute(
"SELECT * FROM library_names ORDER BY name"
).fetchall()
data = [dict(r) for r in rows]
conn.close()
return data
def list_utterances(db_path: Path | None = None) -> list[dict]:
conn = ensure_library_schema(db_path)
rows = conn.execute(
"SELECT * FROM library_utterances ORDER BY id"
).fetchall()
data = [dict(r) for r in rows]
conn.close()
return data
def delete_speaker(speaker_id: int, db_path: Path | None = None) -> bool:
conn = ensure_library_schema(db_path)
cur = conn.execute("DELETE FROM library_speakers WHERE id = ?", (speaker_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
def delete_name(name_id: int, db_path: Path | None = None) -> bool:
conn = ensure_library_schema(db_path)
cur = conn.execute("DELETE FROM library_names WHERE id = ?", (name_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
def speaker_voice_ref_path(speaker_id: int, db_path: Path | None = None) -> Path | None:
conn = ensure_library_schema(db_path)
row = conn.execute(
"SELECT voice_ref_path FROM library_speakers WHERE id = ?", (speaker_id,)
).fetchone()
conn.close()
if row and row["voice_ref_path"]:
return Path(row["voice_ref_path"])
return None

View File

@@ -237,21 +237,29 @@ class QwenTTSEngine:
voice_ref: Path,
ref_text: str | None = None,
slug: str | None = None,
x_vector_only: bool = True,
x_vector_only: bool = False,
force: bool = False,
) -> str:
"""Capture & cache a speaker's digital voice (a set of numbers) once.
"""Capture & cache a speaker's **vmatrix** (the digital voice) once.
Uploads the reference audio to the server's /v1/voice-prompt endpoint,
which extracts the speaker embedding ("digital voice"), caches it under
the slug, and returns the slug to pass as prompt_slug to generate().
The vmatrix is the set of numbers inside Qwen3-TTS that *is* the voice:
- the ICL codec-token "8k matrix" (``ref_code`` — the re-encoded
reference speech tokens the model conditions on), AND
- the ``ref_spk_embedding`` (the extracted speaker x-vector).
With x_vector_only=True (default) it captures ONLY the speaker embedding
(ref_code=None). Every utterance then feeds those cached numbers + text
straight into the speech generator -- no reference re-hash, no clone
re-run, no leaked reference audio. Set x_vector_only=False only if you
explicitly want the full ICL "8k matrix" (which re-speaks the reference
each utterance).
Uploads the reference WAV to the server's /v1/voice-prompt endpoint,
which computes the full vmatrix, caches it as a ``.pt`` under PROMPT_DIR
keyed by the slug, and returns the slug. Callers then pass it as
``prompt_slug`` to :meth:`generate`; every utterance reuses the exact
same cached numbers, so the voice is identical across all utterances and
chapters (this is the requirement: an approved voice = WAV to listen to
+ the captured vmatrix for consistency).
x_vector_only=False is the DEFAULT (and intended pipeline mode): capture
the FULL vmatrix (ref_code + embedding). x_vector_only=True captures only
the coarse speaker embedding (ref_code=None) — historically the default,
which is what produced the "inconsistent / changed voice" problem; keep
it only for experiments or where the extra VRAM of ICL is unavailable.
"""
if not self._use_remote:
raise ValueError("build_voice_prompt only works with the remote TTS server")

View File

@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS voice_registry (
accent_marker TEXT DEFAULT 'none',
voice_description TEXT,
voice_ref_path TEXT,
vmatrix_path TEXT,
sample_audio_path TEXT,
speaker_embedding BLOB,
source_project TEXT,
@@ -60,8 +61,19 @@ class VoiceRegistry:
self._conn = await aiosqlite.connect(str(self.db_path))
self._conn.row_factory = aiosqlite.Row
await self._conn.executescript(REGISTRY_SCHEMA)
await self._migrate()
await self._conn.commit()
async def _migrate(self) -> None:
"""Add columns introduced after the table was first created."""
cursor = await self._conn.execute("PRAGMA table_info(voice_registry)")
cols = {row["name"] for row in await cursor.fetchall()}
if "vmatrix_path" not in cols:
# vmatrix_path: path to the captured vmatrix (.pt) for the approved voice
await self._conn.execute(
"ALTER TABLE voice_registry ADD COLUMN vmatrix_path TEXT"
)
async def close(self) -> None:
if self._conn:
await self._conn.close()
@@ -149,6 +161,24 @@ class VoiceRegistry:
)
await self.conn.commit()
async def set_vmatrix(
self, registry_id: int, vmatrix_path: str | None
) -> None:
"""Record the captured vmatrix (.pt) for the approved voice.
The vmatrix is the set of numbers inside Qwen3-TTS that IS the voice
(ICL "8k matrix" ref_code + speaker embedding). An approved speaker is
therefore: the reference WAV (voice_ref_path, what you listen to) plus
this cached .pt (ponder the consistency across every utterance).
"""
await self.conn.execute(
"""UPDATE voice_registry
SET vmatrix_path = ?, updated_at = datetime('now')
WHERE id = ?""",
(vmatrix_path, registry_id),
)
await self.conn.commit()
async def update_profile(self, registry_id: int, profile: CharacterProfile) -> None:
await self.conn.execute(
"""UPDATE voice_registry

View File

@@ -547,7 +547,12 @@ def build_profile_from_speaker(speaker: dict[str, Any]) -> CharacterProfile:
if unmatched:
desc_parts.append(f"Vocal characteristics: {', '.join(unmatched[:3])}.")
if description and not unmatched:
# Description always wins: the human/author-supplied description MUST shape the
# voice, even when vocal_traits contain unmatched keywords. (Previously gated on
# `not unmatched`, which silently dropped the description for any character with
# rich vocal_traits -- e.g. Nanny -- so editing the description produced no
# audible change and the user "couldn't get a different voice".)
if description:
desc_parts.append(description[:200].rstrip(".") + ".")
voice_description = " ".join(desc_parts) + "."

View File

@@ -2689,21 +2689,54 @@ async def set_voice_status(request: Request):
ref_path = voices_dir / f"{name.lower().replace(' ', '_')}_ref.wav"
if registry_path.exists() and ref_path.exists():
try:
# Capture the approved voice's vmatrix so every utterance reuses
# the identical voice identity, not just a coarse x-vector.
# The vmatrix (.pt: ICL "8k matrix" ref_code + speaker embedding)
# is the set of numbers inside Qwen3-TTS that IS the voice; it is
# captured server-side under data_dir/voice_prompts/{slug}.pt.
vmatrix_path = None
try:
from umbraaudio.voices.qwen_tts import (
VOICE_REFERENCE_TEXT,
QwenTTSEngine,
)
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url)
slug = engine.build_voice_prompt(
ref_path,
ref_text=VOICE_REFERENCE_TEXT,
x_vector_only=False,
)
candidate = config.data_dir / "voice_prompts" / f"{slug}.pt"
if candidate.exists():
vmatrix_path = str(candidate)
except Exception as e:
logger.warning(
"Failed to capture vmatrix for approved voice %s: %s", name, e
)
rdb = sqlite3.connect(str(registry_path))
rdb.row_factory = sqlite3.Row
# ensure vmatrix_path column exists on legacy databases
has_vmatrix = any(
r["name"] == "vmatrix_path"
for r in rdb.execute("PRAGMA table_info(voice_registry)").fetchall()
)
if not has_vmatrix:
rdb.execute("ALTER TABLE voice_registry ADD COLUMN vmatrix_path TEXT")
existing = rdb.execute(
"SELECT id FROM voice_registry WHERE LOWER(character_name) = LOWER(?)",
(name,),
).fetchone()
if existing:
rdb.execute(
"UPDATE voice_registry SET status='voiced', voice_ref_path=?, updated_at=datetime('now') WHERE id=?",
(str(ref_path), existing["id"]),
"UPDATE voice_registry SET status='voiced', voice_ref_path=?, vmatrix_path=?, updated_at=datetime('now') WHERE id=?",
(str(ref_path), vmatrix_path, existing["id"]),
)
else:
rdb.execute(
"INSERT INTO voice_registry (character_name, status, voice_ref_path, source_project) VALUES (?, 'voiced', ?, ?)",
(name, str(ref_path), config.project_dir.name),
"INSERT INTO voice_registry (character_name, status, voice_ref_path, vmatrix_path, source_project) VALUES (?, 'voiced', ?, ?, ?)",
(name, str(ref_path), vmatrix_path, config.project_dir.name),
)
rdb.commit()
rdb.close()
@@ -4385,3 +4418,133 @@ async def quality_gate_check(request: Request):
"message": "Quality gate passed" if passed else f"Quality gate FAILED: {exception_rate:.2f}% exceptions exceeds 1% threshold"
})
return JSONResponse({"models": []})
# ==============================================================================
# Library (cross-project voices/names/utterances)
# ==============================================================================
@router.get("/library", response_class=HTMLResponse)
async def library_index(request: Request):
config = _get_active_config(request)
return templates.TemplateResponse(request, "library.html", {"active_tab": "speakers"})
@router.get("/library/speakers", response_class=HTMLResponse)
async def library_speakers(request: Request):
from umbraaudio.library import list_speakers
speakers = list_speakers()
return templates.TemplateResponse(
request, "_library_speakers.html", {"speakers": speakers}
)
@router.get("/library/names", response_class=HTMLResponse)
async def library_names(request: Request):
from umbraaudio.library import list_names
names = list_names()
return templates.TemplateResponse(
request, "_library_names.html", {"names": names}
)
@router.get("/library/utterances", response_class=HTMLResponse)
async def library_utterances(request: Request):
from umbraaudio.library import list_utterances
utterances = list_utterances()
return templates.TemplateResponse(
request, "_library_utterances.html", {"utterances": utterances}
)
@router.post("/api/library/speakers")
async def library_add_speakers(request: Request):
from umbraaudio.library import add_speakers
body = await request.json()
names = body.get("speaker_names") or body.get("names") or []
if not names:
return JSONResponse({"error": "speaker_names required"}, status_code=400)
config = _get_active_config(request)
speakers = _load_speakers(request)
by_name = {s["primary_name"]: s for s in speakers}
# Resolve voice ref for each selected speaker (mirrors voice_ref resolution).
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
voices_dir = config.data_dir / "voices"
voice_refs: dict[str, Path] = {}
for name in names:
if name not in by_name:
continue
sp = by_name[name]
if sp.get("is_narrator"):
ref_name = "narrator_ref.wav"
elif name.lower() in GENERAL_SPEAKER_NAMES:
ref_name = "general_ref.wav"
else:
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
ref_path = voices_dir / ref_name
if ref_path.exists():
voice_refs[name] = ref_path
to_add = [by_name[n] for n in names if n in by_name]
result = add_speakers(to_add, source_project=config.project_dir.name, voice_refs=voice_refs)
return JSONResponse({"ok": True, **result})
@router.post("/api/library/names")
async def library_add_names(request: Request):
from umbraaudio.library import add_names
body = await request.json()
names = body.get("names") or []
if not names:
return JSONResponse({"error": "names required"}, status_code=400)
config = _get_active_config(request)
phonetic = _load_pronunciation_records(config.data_dir)
records = []
for n in names:
rec = phonetic.get(n, {})
records.append(
{
"name": n,
"phonetic": rec.get("phonetic"),
"source": rec.get("source"),
"common": rec.get("common", False),
"confirmed": rec.get("confirmed", False),
}
)
result = add_names(records, source_project=config.project_dir.name)
return JSONResponse({"ok": True, **result})
@router.delete("/api/library/speakers/{speaker_id}")
async def library_delete_speaker(request: Request, speaker_id: int):
from umbraaudio.library import delete_speaker
ok = delete_speaker(speaker_id)
return JSONResponse({"ok": ok})
@router.delete("/api/library/names/{name_id}")
async def library_delete_name(request: Request, name_id: int):
from umbraaudio.library import delete_name
ok = delete_name(name_id)
return JSONResponse({"ok": ok})
@router.get("/library-audio/{speaker_id}/{filename}")
async def serve_library_audio(request: Request, speaker_id: int, filename: str):
from umbraaudio.library import speaker_voice_ref_path
audio_path = speaker_voice_ref_path(speaker_id)
if not audio_path or Path(audio_path).name != filename or not Path(audio_path).exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")

View File

@@ -0,0 +1,48 @@
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="margin:0">Library Names ({{ names|length }})</h3>
<span style="color:#64748b; font-size:0.85rem">Confirmed proper-name pronunciations added from the project Names page.</span>
</div>
{% if not names %}
<p style="color:#64748b">No names in the library yet.</p>
{% else %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Phonetic</th>
<th>Status</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
{% for n in names %}
<tr id="lib-name-{{ n.id }}">
<td><strong>{{ n.name }}</strong></td>
<td><code style="color:#f59e0b">{{ n.phonetic or '—' }}</code></td>
<td>
{% if n.confirmed %}<span class="badge badge-green">Confirmed</span>
{% elif n.common %}<span class="badge badge-blue">Common</span>
{% else %}<span class="badge badge-yellow">Pending</span>{% endif %}
</td>
<td style="font-size:0.8rem; color:#94a3b8">{{ n.source_project or 'manual' }}</td>
<td>
<button class="btn btn-reject" style="font-size:0.7rem; padding:0.2rem 0.5rem"
onclick="removeLibName({{ n.id }}, '{{ n.name|e }}')">✕</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<script>
function removeLibName(id, name) {
if (!confirm('Remove "' + name + '" from the library?')) return;
fetch('/api/library/names/' + id, {method: 'DELETE'})
.then(r => r.json()).then(d => {
htmx.ajax('GET', '/library/names', {target: '#lib-tab', swap: 'innerHTML'});
});
}
</script>

View File

@@ -0,0 +1,71 @@
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="margin:0">Library Speakers ({{ speakers|length }})</h3>
<span style="color:#64748b; font-size:0.85rem">Add speakers from the project Speakers page using the "Lib" button.</span>
</div>
{% if not speakers %}
<p style="color:#64748b">No speakers in the library yet.</p>
{% else %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Demographics</th>
<th>Voice</th>
<th>Source Project</th>
<th>Reference</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in speakers %}
<tr id="lib-speaker-{{ s.id }}">
<td>
<strong>{{ s.primary_name }}</strong>
{% if s.is_narrator %}<span class="badge badge-blue" style="margin-left:0.3rem">Narrator</span>{% endif %}
{% if s.is_group %}<span class="badge badge-yellow" style="margin-left:0.3rem">Group</span>{% endif %}
{% if s.aliases and s.aliases not in ('[]','') %}
<br><small style="color:#94a3b8">aliases: {{ s.aliases }}</small>
{% endif %}
</td>
<td style="font-size:0.8rem; color:#94a3b8">
{{ s.gender or '—' }}{% if s.accent %} · {{ s.accent }}{% endif %}
{% if s.vocal_traits and s.vocal_traits not in ('[]','') %}
<br><small>{{ s.vocal_traits }}</small>
{% endif %}
</td>
<td style="font-size:0.8rem; color:#94a3b8">
{% if s.distortion_effects and s.distortion_effects not in ('[]','') %}
<span style="color:#f59e0b">{{ s.distortion_effects }}</span>
{% else %}standard{% endif %}
</td>
<td style="font-size:0.8rem; color:#94a3b8">{{ s.source_project or 'manual' }}</td>
<td>
{% if s.voice_ref_path %}
{% set ref_name = s.voice_ref_path.split('/')[-1] %}
<audio controls preload="none" style="height:32px">
<source src="/library-audio/{{ s.id }}/{{ ref_name }}" type="audio/wav">
</audio>
{% else %}
<span style="color:#64748b">No ref</span>
{% endif %}
</td>
<td>
<button class="btn btn-reject" style="font-size:0.7rem; padding:0.2rem 0.5rem"
onclick="removeLibSpeaker({{ s.id }}, '{{ s.primary_name|e }}')">✕</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<script>
function removeLibSpeaker(id, name) {
if (!confirm('Remove "' + name + '" from the library?')) return;
fetch('/api/library/speakers/' + id, {method: 'DELETE'})
.then(r => r.json()).then(d => {
htmx.ajax('GET', '/library/speakers', {target: '#lib-tab', swap: 'innerHTML'});
});
}
</script>

View File

@@ -0,0 +1,28 @@
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="margin:0">Library Utterances ({{ utterances|length }})</h3>
<span class="badge badge-blue">Coming soon</span>
</div>
{% if not utterances %}
<p style="color:#64748b">
Captured, reusable utterances (e.g. the Umbra Audio system introduction) will appear here.
The ability to capture and add utterances is not yet available.
</p>
{% else %}
<table>
<thead>
<tr><th>#</th><th>Text</th><th>Speaker</th><th>Source Project</th></tr>
</thead>
<tbody>
{% for u in utterances %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.text }}</td>
<td>{{ u.speaker_name or '—' }}</td>
<td style="font-size:0.8rem; color:#94a3b8">{{ u.source_project or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>

View File

@@ -13,6 +13,7 @@
</a>
<button class="btn btn-generate" id="gen-all-voices-btn" onclick="generateAllVoices()">🎙 Generate All Voices</button>
<button class="btn btn-generate" onclick="toggleAddForm()">+ Add Speaker</button>
<button class="btn" style="background:#0f3460; color:#7c3aed; border:1px solid #7c3aed" onclick="openLibSpeakersModal()">🕮 Lib</button>
</div>
</div>
@@ -160,6 +161,35 @@
</div>
</div>
<div id="lib-speakers-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1001; justify-content:center; align-items:center">
<div style="background:#16213e; border-radius:8px; padding:1.5rem; max-width:560px; width:92%; border:1px solid #7c3aed">
<h3 style="color:#a78bfa; margin-bottom:0.25rem">Add Speakers to Library</h3>
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:1rem">
Selected speakers (and their voice reference samples) will be saved to the shared library for reuse on other projects.
</p>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem">
<label style="color:#94a3b8; font-size:0.85rem"><input type="checkbox" id="lib-check-all-speakers" onchange="toggleAllLibSpeakers(this)"> Select all</label>
<span style="color:#64748b; font-size:0.8rem" id="lib-speakers-count">0 selected</span>
</div>
<div id="lib-speakers-list" style="max-height:320px; overflow-y:auto; border:1px solid #333; border-radius:4px; padding:0.5rem">
{% for speaker in speakers %}
<label style="display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.25rem; border-bottom:1px solid #24263f; cursor:pointer">
<input type="checkbox" class="lib-speaker-cb" value="{{ speaker.primary_name }}" onchange="updateLibSpeakersCount()">
<strong>{{ speaker.primary_name }}</strong>
{% if speaker.is_narrator %}<span class="badge badge-blue" style="font-size:0.65rem">Narrator</span>{% endif %}
{% if speaker.has_voice_ref %}<span class="badge badge-green" style="font-size:0.65rem">voice</span>
{% else %}<span class="badge badge-yellow" style="font-size:0.65rem">no voice</span>{% endif %}
</label>
{% endfor %}
</div>
<div style="margin-top:1rem; display:flex; gap:0.5rem; justify-content:flex-end">
<button class="btn" style="background:#374151; color:#9ca3af" onclick="closeLibSpeakersModal()">Cancel</button>
<button class="btn btn-generate" id="lib-add-speakers-btn" onclick="addSelectedToLib()">Add to Library</button>
</div>
<p style="color:#10b981; font-size:0.85rem; margin-top:0.5rem" id="lib-speakers-status"></p>
</div>
</div>
<script>
function showAliasModal(speakerIdx, alias) {
window._aliasModal = { speakerIdx, alias };
@@ -233,4 +263,66 @@ function generateAllVoices() {
alert('Failed: ' + e);
});
}
// --- Library (multi-select add) ---
function openLibSpeakersModal() {
var modal = document.getElementById('lib-speakers-modal');
modal.style.display = 'flex';
updateLibSpeakersCount();
}
function closeLibSpeakersModal() {
document.getElementById('lib-speakers-modal').style.display = 'none';
document.getElementById('lib-speakers-status').textContent = '';
}
function toggleAllLibSpeakers(el) {
var boxes = document.querySelectorAll('.lib-speaker-cb');
boxes.forEach(function(b) { b.checked = el.checked; });
updateLibSpeakersCount();
}
function updateLibSpeakersCount() {
var boxes = document.querySelectorAll('.lib-speaker-cb');
var n = 0;
boxes.forEach(function(b) { if (b.checked) n++; });
document.getElementById('lib-speakers-count').textContent = n + ' selected';
}
function addSelectedToLib() {
var boxes = document.querySelectorAll('.lib-speaker-cb');
var names = [];
boxes.forEach(function(b) { if (b.checked) names.push(b.value); });
if (names.length === 0) { alert('Select at least one speaker.'); return; }
var status = document.getElementById('lib-speakers-status');
var btn = document.getElementById('lib-add-speakers-btn');
btn.disabled = true;
btn.textContent = 'Adding...';
status.style.color = '#94a3b8';
status.textContent = 'Adding to library...';
fetch('/api/library/speakers', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speaker_names: names})
}).then(r => r.json()).then(d => {
btn.disabled = false;
btn.textContent = 'Add to Library';
if (d.ok) {
var parts = [];
if (d.added && d.added.length) parts.push('Added: ' + d.added.join(', '));
if (d.already && d.already.length) parts.push('Already in library: ' + d.already.join(', '));
if (d.skipped && d.skipped.length) parts.push('Skipped: ' + d.skipped.join(', '));
status.style.color = '#10b981';
status.textContent = parts.join(' · ') || 'Done';
} else {
status.style.color = '#ef4444';
status.textContent = 'Failed: ' + (d.error || 'unknown');
}
}).catch(e => {
btn.disabled = false;
btn.textContent = 'Add to Library';
status.style.color = '#ef4444';
status.textContent = 'Error: ' + e;
});
}
</script>

View File

@@ -70,6 +70,7 @@
<a href="/chapters-audio">Chapters</a>
<a href="/quality">Quality</a>
<a href="/registry">Voice Registry</a>
<a href="/library">Lib</a>
<a href="/projects" style="margin-left: auto; border-color: #a78bfa;">Projects</a>
</nav>
{% block content %}{% endblock %}

View File

@@ -229,14 +229,64 @@ function saveVariation(uttId, index) {
});
}
function showGenPopup(message) {
let el = document.getElementById('gen-popup');
if (!el) {
el = document.createElement('div');
el.id = 'gen-popup';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
el.innerHTML = '<div style="background:#1a1a2e;border:1px solid #7c3aed;border-radius:8px;padding:2rem;text-align:center;color:#e0e0e0;min-width:260px">'
+ '<div id="gen-popup-spinner" style="border:4px solid #333;border-top:4px solid #7c3aed;border-radius:50%;width:40px;height:40px;margin:0 auto 1rem;animation:spin 1s linear infinite"></div>'
+ '<p id="gen-popup-text" style="margin:0;font-size:1rem;color:#fff"></p>'
+ '<small style="color:#94a3b8;display:block;margin-top:0.5rem">You can listen once this closes</small>'
+ '</div>';
if (!document.getElementById('gen-popup-style')) {
const st = document.createElement('style');
st.id = 'gen-popup-style';
st.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(st);
}
document.body.appendChild(el);
}
document.getElementById('gen-popup-text').textContent = message || 'Regenerating...';
el.style.display = 'flex';
}
function hideGenPopup() {
const el = document.getElementById('gen-popup');
if (el) { el.style.display = 'none'; }
}
function generateUtterance(uttId, index) {
const btn = document.getElementById('gen-btn-' + index);
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
showGenPopup('Regenerating...');
fetch('/api/utterance/' + uttId + '/generate', { method: 'POST' })
.then(r => r.json()).then(data => {
// Persist any pending text edit before generating so the wording change
// is actually used. If the edit box is open, its value may differ from
// what's stored in the DB — flush it first.
const textEdit = document.getElementById('text-edit-' + index);
const textInput = document.getElementById('text-input-' + index);
const maybeSaveText = () => {
if (textEdit && textEdit.style.display !== 'none' && textInput) {
const newText = textInput.value;
return fetch('/api/utterance/' + uttId + '/text', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: newText})
}).then(r => r.json()).then(data => {
if (!data.ok) { throw new Error('Failed to save text: ' + (data.error || 'unknown')); }
});
}
return Promise.resolve();
};
maybeSaveText().then(() => {
return fetch('/api/utterance/' + uttId + '/generate', { method: 'POST' });
}).then(r => r.json()).then(data => {
if (data.ok) {
btn.textContent = '⏳';
btn.title = 'Generating...';
@@ -244,17 +294,19 @@ function generateUtterance(uttId, index) {
} else {
btn.disabled = false;
btn.textContent = origText;
hideGenPopup();
alert('Failed: ' + (data.error || 'unknown'));
}
}).catch(() => {
}).catch((err) => {
btn.disabled = false;
btn.textContent = origText;
hideGenPopup();
alert('Error: ' + (err.message || 'unknown'));
});
}
function pollGeneration(uttId, index, filename) {
const btn = document.getElementById('gen-btn-' + index);
const row = document.getElementById('utterance-' + index);
let elapsed = 0;
const checkInterval = setInterval(() => {
elapsed += 2000;
@@ -262,6 +314,7 @@ function pollGeneration(uttId, index, filename) {
.then(r => {
if (r.ok) {
clearInterval(checkInterval);
hideGenPopup();
location.reload();
}
}).catch(() => {});
@@ -273,6 +326,7 @@ function pollGeneration(uttId, index, filename) {
btn.disabled = false;
btn.textContent = '⟳';
btn.title = 'Generation may still be in progress';
hideGenPopup();
}
}, 180000);
}

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h2 style="margin:0">Library</h2>
<p style="color:#94a3b8; margin:0.25rem 0 0">
Reusable voices, proper-name pronunciations, and captured utterances shared across projects.
</p>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap">
<button class="btn" style="border:1px solid #7c3aed; color:#7c3aed" onclick="setLibTab('speakers', this)">Speakers</button>
<button class="btn" style="border:1px solid #7c3aed; color:#7c3aed" onclick="setLibTab('names', this)">Names</button>
<button class="btn" style="border:1px solid #7c3aed; color:#7c3aed" onclick="setLibTab('utterances', this)">Utterances</button>
</div>
<div id="lib-content">
<div id="lib-tab" hx-get="/library/speakers" hx-trigger="load" hx-swap="innerHTML">
<p>Loading...</p>
</div>
</div>
<script>
function setLibTab(tab, btn) {
document.querySelectorAll('#lib-content .lib-tab-swap').forEach(function() {});
var el = document.getElementById('lib-tab');
var urls = {speakers: '/library/speakers', names: '/library/names', utterances: '/library/utterances'};
el.setAttribute('hx-get', urls[tab]);
htmx.process(el);
htmx.ajax('GET', urls[tab], {target: '#lib-tab', swap: 'innerHTML'});
document.querySelectorAll('button').forEach(function(b) { b.style.background = ''; });
if (btn) { btn.style.background = '#7c3aed'; btn.style.color = '#fff'; }
}
</script>
{% endblock %}

View File

@@ -26,6 +26,7 @@
<option value="none">No pronunciation</option>
</select>
<span style="color:#94a3b8; font-size:0.85rem" id="name-count">{{ names|length }} names found</span>
<button class="btn" style="background:#0f3460; color:#7c3aed; border:1px solid #7c3aed" onclick="openLibNamesModal()">🕮 Lib</button>
<button class="btn btn-generate" id="auto-lookup-btn" onclick="autoLookupAll()" style="font-size:0.8rem; margin-left:auto">
Look Up All Pronunciations
</button>
@@ -106,6 +107,36 @@
</tbody>
</table>
<div id="lib-names-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1001; justify-content:center; align-items:center">
<div style="background:#16213e; border-radius:8px; padding:1.5rem; max-width:560px; width:92%; border:1px solid #7c3aed">
<h3 style="color:#a78bfa; margin-bottom:0.25rem">Add Names to Library</h3>
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:1rem">
Selected proper-name pronunciations will be saved to the shared library for reuse on other projects.
</p>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem">
<label style="color:#94a3b8; font-size:0.85rem"><input type="checkbox" id="lib-check-all-names" onchange="toggleAllLibNames(this)"> Select all</label>
<span style="color:#64748b; font-size:0.8rem" id="lib-names-count">0 selected</span>
</div>
<div id="lib-names-list" style="max-height:320px; overflow-y:auto; border:1px solid #333; border-radius:4px; padding:0.5rem">
{% for entry in names %}
<label style="display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.25rem; border-bottom:1px solid #24263f; cursor:pointer">
<input type="checkbox" class="lib-name-cb" value="{{ entry.name }}" onchange="updateLibNamesCount()">
<strong>{{ entry.name }}</strong>
{% if entry.phonetic %}<code style="color:#f59e0b; font-size:0.8rem">{{ entry.phonetic }}</code>{% endif %}
{% if entry.common %}<span class="badge badge-blue" style="font-size:0.65rem">common</span>
{% elif entry.confirmed %}<span class="badge badge-green" style="font-size:0.65rem">confirmed</span>
{% else %}<span class="badge badge-yellow" style="font-size:0.65rem">pending</span>{% endif %}
</label>
{% endfor %}
</div>
<div style="margin-top:1rem; display:flex; gap:0.5rem; justify-content:flex-end">
<button class="btn" style="background:#374151; color:#9ca3af" onclick="closeLibNamesModal()">Cancel</button>
<button class="btn btn-generate" id="lib-add-names-btn" onclick="addNamesToLib()">Add to Library</button>
</div>
<p style="color:#10b981; font-size:0.85rem; margin-top:0.5rem" id="lib-names-status"></p>
</div>
</div>
<script>
function rowOf(el) {
return el.closest('tr');
@@ -327,5 +358,66 @@ function autoLookupAll() {
renderIntoRow(row, rec);
});
})();
// --- Library (multi-select add names) ---
function openLibNamesModal() {
var modal = document.getElementById('lib-names-modal');
modal.style.display = 'flex';
updateLibNamesCount();
}
function closeLibNamesModal() {
document.getElementById('lib-names-modal').style.display = 'none';
document.getElementById('lib-names-status').textContent = '';
}
function toggleAllLibNames(el) {
var boxes = document.querySelectorAll('.lib-name-cb');
boxes.forEach(function(b) { b.checked = el.checked; });
updateLibNamesCount();
}
function updateLibNamesCount() {
var boxes = document.querySelectorAll('.lib-name-cb');
var n = 0;
boxes.forEach(function(b) { if (b.checked) n++; });
document.getElementById('lib-names-count').textContent = n + ' selected';
}
function addNamesToLib() {
var boxes = document.querySelectorAll('.lib-name-cb');
var names = [];
boxes.forEach(function(b) { if (b.checked) names.push(b.value); });
if (names.length === 0) { alert('Select at least one name.'); return; }
var status = document.getElementById('lib-names-status');
var btn = document.getElementById('lib-add-names-btn');
btn.disabled = true;
btn.textContent = 'Adding...';
status.style.color = '#94a3b8';
status.textContent = 'Adding to library...';
fetch('/api/library/names', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({names: names})
}).then(r => r.json()).then(d => {
btn.disabled = false;
btn.textContent = 'Add to Library';
if (d.ok) {
var parts = [];
if (d.added && d.added.length) parts.push('Added: ' + d.added.join(', '));
if (d.already && d.already.length) parts.push('Already in library: ' + d.already.join(', '));
status.style.color = '#10b981';
status.textContent = parts.join(' · ') || 'Done';
} else {
status.style.color = '#ef4444';
status.textContent = 'Failed: ' + (d.error || 'unknown');
}
}).catch(e => {
btn.disabled = false;
btn.textContent = 'Add to Library';
status.style.color = '#ef4444';
status.textContent = 'Error: ' + e;
});
}
</script>
{% endblock %}