Files
UmbraAudio/umbraaudio/web/routes.py
B.D. Murphy a172610b0e 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
2026-09-02 17:33:15 -07:00

4551 lines
170 KiB
Python

from __future__ import annotations
import hashlib
import json
import logging
import re
import shutil
import sqlite3
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from umbraaudio.analysis import VALID_PITCH_REGISTERS
from umbraaudio.analysis.voice_analysis import load_analysis_status
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
from umbraaudio.generation.audio_effects import (
apply_effects_to_file_by_name,
load_speaker_distortion_effects,
)
from umbraaudio.pronunciation import (
PRONUNCIATION_STATUS_FILE,
generate_pronunciation_audio,
lookup_pronunciation,
pronunciation_audio_path,
)
from umbraaudio.pronunciation import (
apply_phonetics as _apply_phonetics,
)
from umbraaudio.pronunciation import (
load_phonetic_names as _load_phonetic_names,
)
from umbraaudio.pronunciation import (
load_records as _load_pronunciation_records,
)
from umbraaudio.pronunciation import (
save_records as _save_pronunciation_records,
)
SCHEMA_SQL = """
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'
);
CREATE TABLE IF NOT EXISTS chapters (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
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'))
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '[]',
gender TEXT,
age_range TEXT,
is_narrator INTEGER NOT NULL DEFAULT 0,
registry_id INTEGER,
profile_json TEXT,
voice_description TEXT,
voice_ref_path TEXT,
speaker_embedding BLOB,
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',
status TEXT NOT NULL DEFAULT 'identified',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS voice_review_history (
id INTEGER PRIMARY KEY,
character_name TEXT NOT NULL,
action TEXT NOT NULL,
prompt_text TEXT,
resulting_exaggeration REAL,
resulting_cfg_weight REAL,
taxonomy_snapshot_json TEXT,
reviewed_by TEXT,
reviewed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pronunciation_guide (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
term TEXT NOT NULL,
phonetic TEXT NOT NULL,
notes TEXT,
UNIQUE(project_id, term)
);
CREATE TABLE IF NOT EXISTS utterances (
id INTEGER PRIMARY KEY,
chapter_id INTEGER NOT NULL REFERENCES chapters(id),
character_id INTEGER REFERENCES characters(id),
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,
attribution_confidence REAL DEFAULT 1.0,
attribution_reasoning TEXT,
emotion_confidence REAL DEFAULT 1.0,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE IF NOT EXISTS generated_audio (
id INTEGER PRIMARY KEY,
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
character_id INTEGER REFERENCES characters(id),
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'))
);
CREATE TABLE IF NOT EXISTS quality_reports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
chapter_id INTEGER REFERENCES chapters(id),
report_type TEXT NOT NULL,
metrics_json TEXT NOT NULL,
passed INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS review_decisions (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
decision TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS exception_reviews (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
chapter_id INTEGER NOT NULL REFERENCES chapters(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
utterance_index INTEGER NOT NULL,
current_speaker TEXT,
attributed_speaker TEXT,
confidence REAL,
ambiguity_type TEXT,
evidence TEXT,
reasoning TEXT,
alternatives TEXT,
text_preview TEXT,
status TEXT NOT NULL DEFAULT 'pending',
resolved_speaker TEXT,
resolved_by TEXT,
resolved_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_exception_reviews_chapter ON exception_reviews(chapter_id);
CREATE INDEX IF NOT EXISTS idx_exception_reviews_utterance ON exception_reviews(utterance_id);
CREATE INDEX IF NOT EXISTS idx_exception_reviews_status ON exception_reviews(status);
CREATE INDEX IF NOT EXISTS idx_utterances_chapter ON utterances(chapter_id);
CREATE INDEX IF NOT EXISTS idx_utterances_character ON utterances(character_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);
CREATE INDEX IF NOT EXISTS idx_characters_project ON characters(project_id);
"""
def _get_db(request: Request):
config = _get_active_config(request)
import sqlite3
db_path = config.data_dir / "project.db"
if not db_path.exists():
db_path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
db.executescript(SCHEMA_SQL)
return db
def _get_pipeline(request: Request):
from umbraaudio.pipeline_manager import PipelineManager
config = _get_active_config(request)
return PipelineManager(config.data_dir)
def _get_gen_manager(request: Request):
from umbraaudio.voices.generation_manager import GenerationManager
config = _get_active_config(request)
return GenerationManager(config.data_dir)
def _get_active_config(request: Request):
from umbraaudio.config import load_config
active_slug = request.cookies.get("active_project")
if active_slug:
return load_config(active_slug)
return request.app.state.config
@router.get("/projects", response_class=HTMLResponse)
async def project_list(request: Request):
from umbraaudio.config import list_projects
projects = list_projects()
return templates.TemplateResponse(
request,
"project_list.html",
{"projects": projects},
)
@router.post("/api/projects/activate/{slug}")
async def activate_project(request: Request, slug: str):
from fastapi.responses import RedirectResponse
from umbraaudio.config import load_config
cfg = load_config(slug)
if not cfg.data_dir.exists():
return JSONResponse({"error": f"Project '{slug}' not found"}, status_code=404)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie("active_project", slug, max_age=86400 * 365)
return response
@router.post("/api/upload-manuscript")
async def upload_manuscript(request: Request):
import shutil
import tempfile
from umbraaudio.parsers import check_markers, detect_format, parse_manuscript, strip_accent_tag
form = await request.form()
upload = form.get("file")
mode = form.get("mode", "new")
if not upload or not hasattr(upload, "filename"):
return JSONResponse({"error": "No file uploaded"}, status_code=400)
filename = upload.filename
suffix = Path(filename).suffix.lower()
if suffix not in (".docx", ".epub", ".txt", ".md", ".pdf"):
return JSONResponse({"error": f"Unsupported format: {suffix}"}, status_code=400)
config = _get_active_config(request)
tmp_dir = Path(tempfile.mkdtemp())
tmp_path = tmp_dir / filename
with open(tmp_path, "wb") as f:
content = await upload.read()
f.write(content)
permanent_dir = config.data_dir / "uploads"
permanent_dir.mkdir(exist_ok=True)
permanent_path = permanent_dir / filename
shutil.copy2(tmp_path, permanent_path)
debug_dir = config.data_dir / "last_upload"
debug_dir.mkdir(exist_ok=True)
shutil.copy2(tmp_path, debug_dir / filename)
try:
fmt = detect_format(tmp_path)
chapters = parse_manuscript(tmp_path, fmt)
missing = check_markers(chapters)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
if project:
db.execute(
"UPDATE project SET name=?, source_path=?, source_format=?, status='parsed' WHERE id=?",
(filename, str(permanent_path), fmt, project["id"]),
)
project_id = project["id"]
else:
cur = db.execute(
"INSERT INTO project (name, source_path, source_format, status) VALUES (?, ?, ?, 'parsed')",
(filename, str(permanent_path), fmt),
)
project_id = cur.lastrowid
db.execute(
"DELETE FROM generated_audio WHERE utterance_id IN (SELECT id FROM utterances WHERE chapter_id IN (SELECT id FROM chapters WHERE project_id=?))",
(project_id,),
)
db.execute(
"DELETE FROM utterances WHERE chapter_id IN (SELECT id FROM chapters WHERE project_id=?)",
(project_id,),
)
db.execute("DELETE FROM quality_reports WHERE project_id=?", (project_id,))
db.execute("DELETE FROM review_decisions WHERE project_id=?", (project_id,))
db.execute("DELETE FROM pronunciation_guide WHERE project_id=?", (project_id,))
db.execute("DELETE FROM chapters WHERE project_id=?", (project_id,))
db.execute("DELETE FROM characters WHERE project_id=?", (project_id,))
if mode == "new":
import datetime
archive_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
archive_dir = config.data_dir / "archives" / archive_ts
archive_dir.mkdir(parents=True, exist_ok=True)
for fname in (
"speakers.json",
"voice_assignments.json",
"phonetic_names.json",
"voice_status.json",
"pipeline_status.json",
"pipeline_checkpoint.json",
"analysis_status.json",
"defects.json",
"scan_results.json",
):
src = config.data_dir / fname
if src.exists():
shutil.copy2(src, archive_dir / fname)
for dname in ("voices", "voice_tests", "chapter_audio", "analysis"):
src = config.data_dir / dname
if src.exists():
shutil.copytree(src, archive_dir / dname)
always_delete = (
config.data_dir / "pipeline_status.json",
config.data_dir / "pipeline_checkpoint.json",
config.data_dir / "analysis_status.json",
config.data_dir / "defects.json",
config.data_dir / "scan_results.json",
)
for f in always_delete:
if f.exists():
f.unlink()
if mode == "new":
new_delete = (
config.data_dir / "speakers.json",
config.data_dir / "voice_assignments.json",
config.data_dir / "phonetic_names.json",
config.data_dir / "voice_status.json",
)
for f in new_delete:
if f.exists():
f.unlink()
for dname in ("voices", "voice_tests", "chapter_audio", "analysis"):
dpath = config.data_dir / dname
if dpath.exists():
shutil.rmtree(dpath)
else:
for dname in ("chapter_audio", "voice_tests", "analysis"):
dpath = config.data_dir / dname
if dpath.exists():
shutil.rmtree(dpath)
db.commit()
for i, ch in enumerate(chapters):
text = strip_accent_tag(ch.get("text", ""))
title = ch.get("title")
cur = db.execute(
"INSERT INTO chapters (project_id, chapter_index, title, source_text, word_count) VALUES (?, ?, ?, ?, ?)",
(project_id, i, title, text, len(text.split())),
)
ch_id = cur.lastrowid
if text.strip():
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
utt_idx = 0
for para in paragraphs:
has_quote = any(qc in para for qc in ('"', "'", "\u201c", "\u2018"))
if has_quote:
parts = re.split(r"(?<=[.!?])\s+", para)
for part in parts:
part = part.strip()
if not part:
continue
db.execute(
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index) VALUES (?, 'narrator', ?, ?)",
(ch_id, part, utt_idx),
)
utt_idx += 1
else:
db.execute(
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index) VALUES (?, 'narrator', ?, ?)",
(ch_id, para, utt_idx),
)
utt_idx += 1
db.commit()
utt_count = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()["cnt"]
db.close()
result = {
"ok": True,
"filename": filename,
"format": fmt,
"chapters": len(chapters),
"mode": mode,
}
if missing:
result["missing_markers"] = missing
result["warning"] = (
f"Missing marker tags: {', '.join(missing)}. Analysis will continue but may be less accurate."
)
scan_path = config.data_dir / "scan_results.json"
all_markers = ["[book start]", "[author note]", "[toc]", "[chapter]", "[back matter]"]
found_markers = [m for m in all_markers if m not in missing]
with open(scan_path, "w") as f:
json.dump(
{
"filename": filename,
"format": fmt,
"chapters": len(chapters),
"utterances": utt_count,
"found_markers": found_markers,
"missing_markers": missing,
"all_markers": all_markers,
},
f,
)
pipeline_path = config.data_dir / "pipeline_status.json"
with open(pipeline_path, "w") as f:
json.dump(
{
"step": "scan",
"status": "complete",
"detail": f"Loaded {len(chapters)} chapters",
"progress": 100,
},
f,
)
import threading
threading.Thread(target=_run_pronunciation_batch, args=(config,), daemon=True).start()
return JSONResponse(result)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _load_scan_results(data_dir: Path) -> dict:
scan_path = data_dir / "scan_results.json"
if scan_path.exists():
with open(scan_path) as f:
return json.load(f)
return {}
def _run_pronunciation_batch(config: Any) -> None:
"""Background lookup over all proper nouns.
Common-word hits (dictionaryapi) are well understood and auto-confirm;
Wiktionary hits for proper names are stored as unconfirmed candidates and
get a generated preview audio so the reviewer can confirm after listening.
Existing records are left untouched. Progress is mirrored to
pronunciation_status.json so the UI can poll.
"""
status_path = config.data_dir / PRONUNCIATION_STATUS_FILE
def _status(status: str, detail: str, done: int = 0, total: int = 0) -> None:
try:
status_path.parent.mkdir(parents=True, exist_ok=True)
with open(status_path, "w") as f:
json.dump({"status": status, "detail": detail, "done": done, "total": total}, f)
except OSError:
pass
try:
_status("running", "Scanning chapters", 0, 0)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
rows = db.execute("SELECT source_text FROM chapters").fetchall()
db.close()
all_text = " ".join(r["source_text"] or "" for r in rows)
if not all_text.strip():
_status("complete", "No chapters to scan", 0, 0)
return
proper_nouns = _extract_proper_nouns(all_text)
records = _load_pronunciation_records(config.data_dir)
proper_nouns = _proper_name_candidates(
proper_nouns, all_text, _load_speakers_from_disk(config.data_dir), records
)
total = len(proper_nouns)
looked_up = 0
skipped = 0
changed = False
for i, entry in enumerate(proper_nouns, start=1):
name = entry["name"]
existing = records.get(name)
if existing and existing.get("source") != "legacy":
skipped += 1
else:
result = lookup_pronunciation(name)
if result.get("ok"):
rec = {
"phonetic": result["simple"],
"source": result["source"],
"confirmed": bool(result.get("common")),
"common": bool(result.get("common")),
"audio_error": "",
}
if not rec["confirmed"]:
audio = generate_pronunciation_audio(config, name, result["simple"])
if not audio.get("ok"):
rec["audio_error"] = audio.get("error")
records[name] = rec
changed = True
looked_up += 1
else:
records[name] = {
"phonetic": "", "source": "", "confirmed": False, "common": False,
}
if i % 10 == 0:
_status("running", f"Looked up {looked_up} names so far", i, total)
if i % 25 == 0 and changed:
_save_pronunciation_records(config.data_dir, records)
if changed:
_save_pronunciation_records(config.data_dir, records)
_status("complete", f"Looked up {looked_up}, skipped {skipped} of {total}", total, total)
logger.info(
"Auto-pronunciation: %d looked up, %d skipped, %d total proper nouns",
looked_up, skipped, total,
)
except Exception:
logger.exception("Pronunciation batch failed")
_status("error", "Lookup batch failed")
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
config = _get_active_config(request)
active_slug = request.cookies.get("active_project")
if not active_slug:
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/projects", status_code=303)
db = _get_db(request)
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
if not project:
db.close()
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/projects", status_code=303)
chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()
audio = db.execute("SELECT COUNT(*) as cnt FROM generated_audio").fetchone()
characters = db.execute(
"SELECT COUNT(*) as cnt FROM characters WHERE is_narrator = 0"
).fetchone()
utterances = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()
db.close()
speakers_path = config.data_dir / "speakers.json"
speaker_count = 0
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
speaker_count = len(data.get("speakers", []))
scene_count = 0
db2 = _get_db(request)
rows = db2.execute("SELECT source_text FROM chapters").fetchall()
db2.close()
for row in rows:
text = row["source_text"] or ""
scene_count += text.count("<<<>>>")
pm = _get_pipeline(request)
pipeline_status = pm.load_status()
export_dir = config.export_dir
export_files = []
if export_dir.exists():
for f in sorted(export_dir.rglob("*")):
if f.is_file():
rel = f.relative_to(export_dir)
size_mb = f.stat().st_size / (1024 * 1024)
export_files.append({"name": str(rel), "size_mb": round(size_mb, 1)})
voices_dir = config.data_dir / "voices"
voice_count = len(list(voices_dir.glob("*_ref.wav"))) if voices_dir.exists() else 0
# Fetch model status
qwen_health = {"status": "unreachable", "voicedesign_loaded": False, "base_loaded": False, "cuda_available": False}
lm_status = {"loaded": False, "model": None, "instance_id": None}
lm_models = []
if config.tts.qwen_tts_url:
try:
import httpx
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{config.tts.qwen_tts_url}/health")
if r.status_code < 500:
qwen_health = r.json()
except Exception:
pass
if config.llm.lmstudio_base_url:
try:
import httpx
base = config.llm.lmstudio_base_url.replace("/v1", "")
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{base}/api/v1/models")
if r.status_code < 500:
data = r.json()
for model in data.get("models", []):
lm_models.append({
"key": model.get("key"),
"display_name": model.get("display_name"),
"params": model.get("params_string"),
"size_gb": round(model.get("size_bytes", 0) / 1e9, 1),
"loaded": len(model.get("loaded_instances", [])) > 0,
})
for inst in model.get("loaded_instances", []):
lm_status = {
"loaded": True,
"model": model.get("key"),
"instance_id": inst.get("id"),
"context_length": inst.get("config", {}).get("context_length"),
}
except Exception:
pass
return templates.TemplateResponse(
request,
"index.html",
{
"project": dict(project) if project else {},
"chapter_count": chapters["cnt"] if chapters else 0,
"character_count": characters["cnt"] if characters else 0,
"audio_count": audio["cnt"] if audio else 0,
"utterance_count": utterances["cnt"] if utterances else 0,
"scene_count": scene_count,
"speaker_count": speaker_count,
"voice_count": voice_count,
"speakers_json_exists": speakers_path.exists(),
"scan_results": _load_scan_results(config.data_dir),
"pipeline_status": pipeline_status,
"export_files": export_files,
"qwen_health": qwen_health,
"lm_status": lm_status,
"lm_models": lm_models,
},
)
@router.get("/voices", response_class=HTMLResponse)
async def voice_review(request: Request):
db = _get_db(request)
characters = db.execute("SELECT * FROM characters ORDER BY is_narrator DESC, name").fetchall()
voices_dir = _get_active_config(request).data_dir / "voices"
db.close()
return templates.TemplateResponse(
request,
"voice_review.html",
{
"characters": [dict(c) for c in characters],
"voices_dir": str(voices_dir),
},
)
def _is_unknown_speaker(s: dict) -> bool:
traits = s.get("vocal_traits", [])
gender = s.get("demographics", {}).get("gender", "Unknown")
desc = s.get("description", "")
register = s.get("communication_style", {}).get("primary_register", "")
return len(traits) == 0 and gender == "Unknown" and not desc and register in ("", "Casual")
def _all_speaker_names_and_aliases(speakers: list[dict]) -> set[str]:
names: set[str] = set()
for s in speakers:
names.add(s["primary_name"].lower())
for alias in s.get("aliases", []):
names.add(alias.lower())
return names
def _speaker_matches_name(speaker_name: str, names_set: set[str]) -> bool:
return speaker_name.lower() in names_set
def _load_speakers_from_disk(data_dir: Path) -> list[dict]:
"""Load speaker entries without a request object (background threads)."""
speakers_path = data_dir / "speakers.json"
if not speakers_path.exists():
return []
with open(speakers_path) as f:
return json.load(f).get("speakers", [])
def _load_speakers(request: Request) -> list[dict]:
config = _get_active_config(request)
speakers_path = config.data_dir / "speakers.json"
if not speakers_path.exists():
return []
with open(speakers_path) as f:
data = json.load(f)
speakers = data.get("speakers", [])
voices_dir = config.data_dir / "voices"
voice_status_path = config.data_dir / "voice_status.json"
voice_status = {}
if voice_status_path.exists():
with open(voice_status_path) as f:
voice_status = json.load(f)
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
speakers.sort(key=lambda s: s.get("primary_name", "").lower())
for s in speakers:
name = s["primary_name"]
if s.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"
s["has_voice_ref"] = (voices_dir / ref_name).exists()
s["flagged"] = _is_unknown_speaker(s)
s["voice_status"] = voice_status.get(name, "pending")
return speakers
def _save_speakers(request: Request, speakers: list[dict]) -> None:
config = _get_active_config(request)
speakers_path = config.data_dir / "speakers.json"
existing = {}
if speakers_path.exists():
with open(speakers_path) as f:
existing = json.load(f)
existing["speakers"] = speakers
with open(speakers_path, "w") as f:
json.dump(existing, f, indent=2)
@router.get("/speakers", response_class=HTMLResponse)
async def speakers_review(request: Request):
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"speakers.html",
{
"speakers": speakers,
},
)
_COMMON_ENGLISH_WORDS = {
"the", "a", "an", "and", "but", "or", "for", "nor", "yet", "so", "of", "in", "on", "at",
"to", "from", "by", "with", "without", "about", "after", "before",
"until", "while",
"against", "among", "between", "during", "into", "through", "under", "over", "out", "up",
"down", "off", "near", "beyond", "across", "along", "behind", "below", "beneath", "beside",
"besides", "inside", "outside", "upon", "within",
"i", "you", "he", "she", "it", "we", "they", "them", "his", "her", "their", "our", "your",
"my", "me", "him", "us", "its",
"this", "that", "these", "those", "there", "then", "when", "where", "here", "why", "how",
"what", "which", "who", "whom", "whose",
"not", "no", "yes", "very", "too", "also", "just", "only", "even", "still",
"again", "ever",
"never", "always", "often", "once", "sometimes", "really", "quite", "almost", "already",
"away", "instead", "however", "therefore", "although", "though", "because", "anyway",
"all", "some", "any", "many", "much", "more", "most", "few", "less", "both", "each", "every",
"other", "another", "such", "same", "own", "several",
"do", "does", "did", "done", "doing", "have", "has", "had", "having", "be", "been", "being",
"was", "were", "is", "are", "am", "will", "would", "can", "could", "shall", "should", "may",
"might", "must", "need", "get", "let", "say", "see", "know", "think", "want", "look", "take",
"go", "make", "come", "tell", "ask", "give", "find",
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"first", "last", "next", "new", "old", "big", "small", "high", "low", "long", "short",
"day", "year", "week", "month", "time", "thing", "way", "life", "world", "home", "house",
"room", "place", "point", "hand", "head", "eye", "face", "sound", "voice", "man", "men",
"woman", "women", "boy", "girl", "child", "hour", "minute", "second",
"morning", "afternoon", "evening", "night", "today", "yesterday", "tomorrow", "tonight",
"everyone", "everything", "everybody", "everywhere", "someone", "something", "somebody",
"somewhere", "anyone", "anything", "anybody", "anywhere", "nobody", "nothing", "nowhere",
"wow", "ahh", "cheers", "apologies", "greetings", "congratulations",
"additionally", "moreover", "furthermore", "meanwhile",
"australian", "canadian", "british", "european", "american",
"celsius", "fahrenheit", "nanite",
"keywords", "titles", "acknowledgments", "acknowledgements",
"ltd", "inc", "corp", "llc", "dail", "pwr", "goodreads", "lucasfilm", "gemini",
}
def _extract_proper_nouns(text: str) -> list[dict]:
"""Extract single-word proper-noun candidates from capitalized text.
Multi-word phrases ("Princess Kainda Murphy") are flattened into their
component words so each entry is one confirmable name; component words that
are titles or stopwords ("Princess", "The") are dropped. Occurrence counts
aggregate across standalone and multi-word usage.
"""
titles = {
"Mr", "Mrs", "Ms", "Dr", "Prof", "Sir", "Dame", "Lord", "Lady",
"Captain", "Colonel", "Major", "General", "Sergeant", "Private",
"Admiral", "Commander", "Lieutenant", "Corporal",
"President", "Minister", "Senator", "Governor", "Mayor",
"King", "Queen", "Prince", "Princess", "Duke", "Duchess",
"Count", "Countess", "Baron", "Baroness",
}
stopwords = {
"The", "And", "But", "For", "Not", "You", "All", "Can", "Had",
"Her", "Was", "One", "Our", "Out", "Day", "Get", "Has", "Him",
"His", "How", "Its", "May", "New", "Now", "Old", "See", "Way",
"Who", "Boy", "Did", "Let", "Put", "Say", "She", "Too", "Use",
"Man", "Big", "End", "Why", "Try", "Ask", "Men",
"Set", "Two", "Top", "Six", "Ten",
}
freq: dict[str, int] = {}
for noun in re.findall(r"\b([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\b", text):
for word in noun.split():
if word in titles or word in stopwords or len(word) <= 2:
continue
freq[word] = freq.get(word, 0) + 1
return [
{"name": word, "count": count} for word, count in sorted(freq.items(), key=lambda x: -x[1])
]
def _lowercase_words(text: str) -> set[str]:
"""Tokens that appear literally all-lowercase in the manuscript.
Common words ("they", "after") are written in lowercase while proper names
("Kainda") are not, so their lowercase forms never appear.
"""
return {token.lower() for token in re.findall(r"[A-Za-z]+", text) if token == token.lower()}
def _speaker_name_words(speakers: list[dict], lowercase_words: set[str]) -> set[str]:
"""Component words of speaker names/aliases that read as real names.
Words that also appear lowercase in the manuscript ("captain", "the",
"robot") are generic descriptors rather than name words, so they are
excluded.
"""
out: set[str] = set()
for speaker in speakers:
names = [speaker.get("primary_name", "")]
names.extend(speaker.get("aliases", []))
for full in names:
for word in str(full).split():
wl = word.lower()
if len(word) <= 2 or wl in _COMMON_ENGLISH_WORDS or wl in lowercase_words:
continue
out.add(wl)
return out
def _mid_sentence_counts(text: str, candidates: set[str]) -> dict[str, int]:
"""Count each candidate's appearances directly after a lowercase word.
Real names routinely appear mid-sentence (after a lowercase word), while
sentence-initial common words ("Additionally", "Wow") never do.
"""
counts: dict[str, int] = {}
for match in re.finditer(r"[A-Z][a-z]+", text):
word = match.group(0)
if word not in candidates:
continue
prev = text[max(0, match.start() - 8) : match.start()]
if re.search(r"[a-z]\s+$", prev):
counts[word] = counts.get(word, 0) + 1
return counts
def _proper_name_candidates(
entries: list[dict], text: str, speakers: list[dict], records: dict[str, Any]
) -> list[dict]:
"""Keep single-word candidates that read as names rather than prose.
A candidate is dropped when it is a known common word, appears lowercase in
the manuscript, or only ever appears at the start of a sentence -- unless it
is part of a speaker's name or already confirmed by the reviewer. Each kept
entry gains an ``is_speaker`` flag.
"""
lowercase_words = _lowercase_words(text)
speaker_words = _speaker_name_words(speakers, lowercase_words)
candidates = {entry["name"] for entry in entries}
mid_counts = _mid_sentence_counts(text, candidates)
kept = []
for entry in entries:
name = entry["name"]
wl = name.lower()
rec = records.get(name, {})
is_speaker = wl in speaker_words
if wl in _COMMON_ENGLISH_WORDS or rec.get("common"):
continue
if wl in lowercase_words and not is_speaker and not rec.get("confirmed"):
continue
if mid_counts.get(name, 0) == 0 and not is_speaker and not rec.get("confirmed"):
continue
kept.append({**entry, "is_speaker": is_speaker})
return kept
@router.get("/proper-names", response_class=HTMLResponse)
@router.get("/character-names", response_class=HTMLResponse)
async def proper_names_review(request: Request):
config = _get_active_config(request)
speakers = _load_speakers(request)
records = _load_pronunciation_records(config.data_dir)
db = _get_db(request)
rows = db.execute("SELECT source_text FROM chapters").fetchall()
total_chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()["cnt"]
db.close()
all_text = " ".join(r["source_text"] or "" for r in rows)
proper_nouns = _proper_name_candidates(
_extract_proper_nouns(all_text), all_text, speakers, records
)
names_data = []
for noun_info in proper_nouns:
name = noun_info["name"]
count = noun_info["count"]
is_speaker = noun_info["is_speaker"]
rec = records.get(name, {})
names_data.append(
{
"name": name,
"count": count,
"is_speaker": is_speaker,
"phonetic": rec.get("phonetic", ""),
"confirmed": bool(rec.get("confirmed")),
"common": bool(rec.get("common")),
"source": rec.get("source", ""),
"audio_error": rec.get("audio_error", ""),
"has_audio": pronunciation_audio_path(config.data_dir, name).exists(),
}
)
return templates.TemplateResponse(
request,
"proper_names.html",
{
"names": names_data,
"total_chapters": total_chapters,
},
)
@router.put("/api/phonetic/{speaker_name}")
async def update_phonetic_name(request: Request, speaker_name: str):
"""User edits a spelling: store it as an unconfirmed candidate and regenerate audio."""
body = await request.json()
phonetic_text = body.get("phonetic", "").strip()
config = _get_active_config(request)
records = _load_pronunciation_records(config.data_dir)
if not phonetic_text:
records.pop(speaker_name, None)
_save_pronunciation_records(config.data_dir, records)
audio_path = pronunciation_audio_path(config.data_dir, speaker_name)
if audio_path.exists():
audio_path.unlink()
return JSONResponse({"ok": True, "record": None})
rec = records.get(speaker_name, {})
rec["phonetic"] = phonetic_text
rec["source"] = "user"
rec["confirmed"] = False
rec["common"] = bool(rec.get("common"))
audio = generate_pronunciation_audio(config, speaker_name, phonetic_text)
rec["audio_error"] = None if audio.get("ok") else audio.get("error")
records[speaker_name] = rec
_save_pronunciation_records(config.data_dir, records)
return JSONResponse({"ok": True, "record": rec, "audio": audio})
@router.get("/api/pronunciation/{name}")
async def get_pronunciation_lookup(request: Request, name: str):
result = lookup_pronunciation(name)
return JSONResponse(result)
@router.post("/api/pronunciations/{name}/lookup")
async def pronunciation_lookup(request: Request, name: str):
"""Internet lookup -> candidate phonetic + generated preview audio.
Common-word hits (dictionaryapi) are well understood and auto-confirm;
Wiktionary hits for proper names require the user to confirm after listening.
"""
config = _get_active_config(request)
result = lookup_pronunciation(name)
if not result.get("ok"):
return JSONResponse(result)
confirmed = bool(result.get("common"))
records = _load_pronunciation_records(config.data_dir)
rec = {
"phonetic": result["simple"],
"source": result["source"],
"confirmed": confirmed,
"common": confirmed,
}
audio = {"ok": True, "audio": None}
if not confirmed:
audio = generate_pronunciation_audio(config, name, result["simple"])
rec["audio_error"] = None if audio.get("ok") else audio.get("error")
records[name] = rec
_save_pronunciation_records(config.data_dir, records)
return JSONResponse({"ok": True, "record": rec, "audio": audio})
@router.post("/api/pronunciations/{name}/confirm")
async def confirm_pronunciation(request: Request, name: str):
config = _get_active_config(request)
records = _load_pronunciation_records(config.data_dir)
rec = records.get(name)
if not rec or not rec.get("phonetic"):
return JSONResponse({"ok": False, "error": "No phonetic spelling to confirm"})
rec["confirmed"] = True
_save_pronunciation_records(config.data_dir, records)
return JSONResponse({"ok": True, "record": rec})
@router.post("/api/pronunciations/{name}/clear")
async def clear_pronunciation(request: Request, name: str):
config = _get_active_config(request)
records = _load_pronunciation_records(config.data_dir)
records.pop(name, None)
_save_pronunciation_records(config.data_dir, records)
audio_path = pronunciation_audio_path(config.data_dir, name)
if audio_path.exists():
audio_path.unlink()
return JSONResponse({"ok": True})
@router.get("/api/pronunciations/audio")
async def serve_pronunciation_audio(request: Request, name: str):
config = _get_active_config(request)
audio_path = pronunciation_audio_path(config.data_dir, name)
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/api/pronunciations/status")
async def pronunciation_status(request: Request):
config = _get_active_config(request)
status_path = config.data_dir / PRONUNCIATION_STATUS_FILE
if not status_path.exists():
return JSONResponse({"status": "idle"})
with open(status_path) as f:
data = json.load(f)
return JSONResponse(data)
@router.post("/api/auto-pronunciations")
async def auto_lookup_pronunciations(request: Request):
import threading
config = _get_active_config(request)
threading.Thread(target=_run_pronunciation_batch, args=(config,), daemon=True).start()
return JSONResponse({"ok": True, "detail": "Auto-pronunciation lookup started"})
@router.get("/speakers/partial", response_class=HTMLResponse)
async def speakers_list_partial(request: Request):
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"speakers": speakers,
},
)
@router.get("/speakers/partial-row/{index}", response_class=HTMLResponse)
async def speaker_row_partial(request: Request, index: int):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return HTMLResponse("")
return templates.TemplateResponse(
request,
"_speaker_row.html",
{
"speaker": speakers[index],
"speaker_idx": index,
"speakers": speakers,
},
)
@router.post("/api/speakers")
async def add_speaker(
request: Request,
primary_name: str = Form(...),
gender: str = Form("Unknown"),
speaker_type: str = Form("Human"),
aliases: str = Form(""),
vocal_traits: str = Form(""),
communication_register: str = Form("Casual"),
style_notes: str = Form(""),
age: str = Form("Unknown"),
pitch: str = Form("mid"),
description: str = Form(""),
sample_dialogue: str = Form(""),
is_narrator: bool = Form(False),
is_group: bool = Form(False),
accent: str = Form("none"),
):
speakers = _load_speakers(request)
alias_list = [a.strip() for a in aliases.split(",") if a.strip()]
trait_list = [t.strip() for t in vocal_traits.split(",") if t.strip()]
cleaned_traits, final_notes, _conflict_message, cleaned_age, cleaned_pitch = _analyze_and_clean_speaker_params(
vocal_traits, communication_register, style_notes, age, pitch
)
new_speaker = {
"primary_name": primary_name,
"aliases": alias_list,
"demographics": {
"type": speaker_type,
"gender": gender,
"age": cleaned_age,
"pitch": cleaned_pitch,
},
"vocal_traits": cleaned_traits,
"communication_style": {
"primary_register": communication_register,
"style_shifts_or_notes": final_notes,
},
"is_narrator": is_narrator,
"is_group": is_group,
"description": description,
"sample_dialogue": sample_dialogue,
"chapters_appeared": [],
"accent": accent,
}
speakers.append(new_speaker)
_save_speakers(request, speakers)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
},
)
def _analyze_and_clean_speaker_params(vocal_traits: str, communication_register: str, style_notes: str, age: str = "", pitch: str = "") -> tuple[list[str], str, str, str, str]:
"""Cleans and refines vocal traits and notes to remove duplicates and conflicts."""
# 1. Clean Traits (Deduplication and trimming)
trait_set = {t.strip().lower() for t in vocal_traits.split(",") if t.strip()}
cleaned_traits = sorted(list(trait_set))
# 2. Analyze/Refine Style Notes & Register
processed_notes = []
conflicts = []
all_descriptors = set(cleaned_traits)
# Conflict check example: Formal vs Casual (This is a simplified AI proxy)
register_lower = communication_register.lower()
if any(d in register_lower for d in ['casual', 'informal']) and ('formal' in register_lower or 'academic' in register_lower):
conflicts.append("Conflict: Casual style conflicts with a formal register setting.")
# Deduplicate and normalize style notes
for note in style_notes.split(","):
note = note.strip()
if not note: continue
normalized = note.lower().replace(" ", "").replace("-", "")
if normalized not in [n.lower().replace(" ", "").replace("-", "") for n in processed_notes]:
processed_notes.append(note)
final_notes = ", ".join(processed_notes) if processed_notes else ""
# 3. Clean Age
cleaned_age = age.strip()
valid_ages = {"Child", "Young", "Adult", "Middle-aged", "Elderly", "Unknown"}
if cleaned_age and cleaned_age not in valid_ages:
cleaned_age = "Unknown"
# Clean Pitch (full taxonomy, not just low/mid/high)
cleaned_pitch = pitch.strip()
if cleaned_pitch.lower() not in VALID_PITCH_REGISTERS:
cleaned_pitch = "mid"
return cleaned_traits, final_notes, "; ".join(conflicts), cleaned_age, cleaned_pitch
@router.put("/api/speakers/{index}")
async def update_speaker(
request: Request,
index: int,
primary_name: str = Form(...),
gender: str = Form("Unknown"),
speaker_type: str = Form("Human"),
aliases: str = Form(""),
vocal_traits: str = Form(""),
communication_register: str = Form("Casual"),
style_notes: str = Form(""),
age: str = Form(""),
pitch: str = Form(""),
description: str = Form(""),
sample_dialogue: str = Form(""),
accent: str = Form(""),
accent_strength: str = Form("medium"),
distortion_effects: list[str] = Form([]),
):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
speaker = speakers[index]
old_primary_name = speaker["primary_name"]
# --- NEW STEP: Parameter Analysis and Cleaning ---
cleaned_traits, final_notes, conflict_message, cleaned_age, cleaned_pitch = _analyze_and_clean_speaker_params(
vocal_traits, communication_register, style_notes, age, pitch
)
updated_data = {
"primary_name": primary_name,
"demographics": {"type": speaker_type, "gender": gender, "age": cleaned_age, "pitch": cleaned_pitch},
"accent": accent or speaker.get("accent"),
"accent_strength": accent_strength,
"vocal_traits": cleaned_traits,
"communication_style": {"primary_register": communication_register, "style_shifts_or_notes": final_notes},
"description": description,
"sample_dialogue": sample_dialogue,
"distortion_effects": distortion_effects if distortion_effects else [],
}
# Update the speaker dictionary with new/cleaned data
speakers[index].update(updated_data)
# --- Save Speakers (Handles persistence) ---
_save_speakers(request, speakers)
# --- Regeneration Triggering and Feedback ---
return templates.TemplateResponse(
request,
"_speaker_row.html",
{
"request": request,
"speaker": speakers[index],
"speaker_idx": index,
"speakers": speakers,
},
)
@router.post("/api/speakers/{index}/alias")
async def add_alias(request: Request, index: int, alias: str = Form(...)):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
if "aliases" not in speakers[index]:
speakers[index]["aliases"] = []
if alias not in speakers[index]["aliases"]:
speakers[index]["aliases"].append(alias)
_save_speakers(request, speakers)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speaker_row.html",
{
"request": request,
"speaker": speakers[index],
"speaker_idx": index,
"speakers": speakers,
},
)
@router.delete("/api/speakers/{index}/alias/{alias}")
async def remove_alias(request: Request, index: int, alias: str):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
if "aliases" in speakers[index] and alias in speakers[index]["aliases"]:
speakers[index]["aliases"].remove(alias)
_save_speakers(request, speakers)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
},
)
@router.post("/api/speakers/{index}/alias/{alias}/move/{target_index}")
async def move_alias(request: Request, index: int, alias: str, target_index: int):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid source index"}, status_code=400)
if target_index < 0 or target_index >= len(speakers):
return JSONResponse({"error": "invalid target index"}, status_code=400)
if index == target_index:
return JSONResponse({"error": "cannot move alias to same speaker"}, status_code=400)
if "aliases" not in speakers[index] or alias not in speakers[index]["aliases"]:
return JSONResponse({"error": "alias not found"}, status_code=404)
speakers[index]["aliases"].remove(alias)
if "aliases" not in speakers[target_index]:
speakers[target_index]["aliases"] = []
speakers[target_index]["aliases"].append(alias)
_save_speakers(request, speakers)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
},
)
@router.post("/api/speakers/{index}/alias/{alias}/create")
async def create_speaker_from_alias(request: Request, index: int, alias: str):
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
if "aliases" not in speakers[index] or alias not in speakers[index]["aliases"]:
return JSONResponse({"error": "alias not found"}, status_code=404)
speakers[index]["aliases"].remove(alias)
new_speaker = {
"primary_name": alias,
"aliases": [],
"demographics": {
"type": "Unknown",
"gender": "Unknown",
},
"vocal_traits": [],
"communication_style": {
"primary_register": "Casual",
"style_shifts_or_notes": "",
},
"is_narrator": False,
"is_group": False,
"description": f"Created from alias of {speakers[index]['primary_name']}",
"sample_dialogue": "",
"chapters_appeared": [],
"accent": "none",
}
speakers.append(new_speaker)
_save_speakers(request, speakers)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
},
)
@router.post("/api/speakers/{source_index}/merge/{target_index}")
async def merge_speakers(request: Request, source_index: int, target_index: int):
speakers = _load_speakers(request)
if source_index < 0 or source_index >= len(speakers):
return JSONResponse({"error": "invalid source index"}, status_code=400)
if target_index < 0 or target_index >= len(speakers):
return JSONResponse({"error": "invalid target index"}, status_code=400)
if source_index == target_index:
return JSONResponse({"error": "cannot merge speaker into itself"}, status_code=400)
source = speakers[source_index]
target = speakers[target_index]
source_name = source.get("primary_name", source.get("canonical_name", ""))
target_name = target.get("primary_name", "")
old_names = [source_name] + source.get("aliases", [])
old_names = [n for n in old_names if n]
if source_name and source_name not in target.get("aliases", []):
target.setdefault("aliases", []).append(source_name)
for alias in source.get("aliases", []):
if alias not in target.get("aliases", []):
target.setdefault("aliases", []).append(alias)
source_demos = source.get("demographics", {})
target_demos = target.setdefault("demographics", {})
if source_demos.get("type") and source_demos["type"] != "Human":
target_demos.setdefault("type", source_demos["type"])
if source_demos.get("gender") and source_demos["gender"] != "Unknown":
target_demos.setdefault("gender", source_demos["gender"])
for trait in source.get("vocal_traits", []):
if trait not in target.get("vocal_traits", []):
target.setdefault("vocal_traits", []).append(trait)
source_chapters = set(source.get("chapters_appeared", []))
target_chapters = set(target.get("chapters_appeared", []))
target["chapters_appeared"] = sorted(source_chapters | target_chapters)
del speakers[source_index]
_save_speakers(request, speakers)
db = _get_db(request)
for old_name in old_names:
db.execute(
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
(target_name, old_name),
)
db.commit()
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
},
)
@router.get("/orphaned-utterances", response_class=HTMLResponse)
async def orphaned_utterances_page(request: Request):
db = _get_db(request)
speakers = _load_speakers(request)
known = _all_speaker_names_and_aliases(speakers)
rows = db.execute(
"SELECT id, chapter_id, speaker_name, text, utterance_index FROM utterances "
"WHERE speaker_name IS NOT NULL AND speaker_name != '' AND speaker_name != '__unassigned__'"
).fetchall()
orphaned = []
for r in rows:
if r["speaker_name"].lower() not in known:
orphaned.append(dict(r))
unassigned = db.execute(
"SELECT id, chapter_id, speaker_name, text, utterance_index FROM utterances "
"WHERE speaker_name = '__unassigned__'"
).fetchall()
return templates.TemplateResponse(
request,
"orphaned_utterances.html",
{
"orphaned": orphaned,
"unassigned": [dict(r) for r in unassigned],
"speakers": speakers,
},
)
@router.post("/api/orphaned-utterances/{utt_id}/reassign")
async def reassign_utterance(request: Request, utt_id: int):
body = await request.json()
new_speaker = body.get("speaker_name", "").strip()
if not new_speaker:
return JSONResponse({"error": "speaker_name required"}, status_code=400)
db = _get_db(request)
db.execute(
"UPDATE utterances SET speaker_name = ? WHERE id = ?",
(new_speaker, utt_id),
)
db.commit()
await generate_utterance_audio(request, utt_id)
return JSONResponse({"ok": True, "speaker_name": new_speaker})
@router.post("/api/orphaned-utterances/reassign-all")
async def reassign_all_orphans(request: Request):
body = await request.json()
new_speaker = body.get("speaker_name", "").strip()
old_names = body.get("old_names", [])
if not new_speaker or not old_names:
return JSONResponse({"error": "speaker_name and old_names required"}, status_code=400)
db = _get_db(request)
for old_name in old_names:
db.execute(
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
(new_speaker, old_name),
)
db.commit()
return JSONResponse({"ok": True, "count": len(old_names)})
@router.get("/voices/partial", response_class=HTMLResponse)
async def voice_list_partial(request: Request):
db = _get_db(request)
characters = db.execute("SELECT * FROM characters ORDER BY is_narrator DESC, name").fetchall()
voices_dir = _get_active_config(request).data_dir / "voices"
character_data = []
for c in characters:
cd = dict(c)
if cd.get("profile_json"):
try:
cd["profile"] = json.loads(cd["profile_json"])
except (json.JSONDecodeError, TypeError):
cd["profile"] = {}
else:
cd["profile"] = {}
name_hash = int(hashlib.md5(cd["name"].encode()).hexdigest()[:8], 16)
energy = cd.get("energy", "measured")
from umbraaudio.voices.voice_designer import (
ATTRIBUTE_PARAM_MAP,
NARRATOR_CFG_WEIGHT,
NARRATOR_EXAGGERATION,
)
if cd.get("is_narrator") and cd["name"] == "Narrator":
cd["exaggeration"] = NARRATOR_EXAGGERATION
cd["cfg_weight"] = NARRATOR_CFG_WEIGHT
elif energy in ATTRIBUTE_PARAM_MAP:
exag_range, cfg_range = ATTRIBUTE_PARAM_MAP[energy]
cd["exaggeration"] = round(
exag_range[0] + ((name_hash % 100) / 100.0) * (exag_range[1] - exag_range[0]), 3
)
cd["cfg_weight"] = round(
cfg_range[0] + (((name_hash >> 8) % 100) / 100.0) * (cfg_range[1] - cfg_range[0]), 3
)
else:
cd["exaggeration"] = 0.35
cd["cfg_weight"] = 0.45
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
if cd.get("is_narrator"):
ref_name = "narrator_ref.wav"
elif cd["name"].lower() in GENERAL_SPEAKER_NAMES:
ref_name = "general_ref.wav"
else:
ref_name = cd["name"].lower().replace(" ", "_") + "_ref.wav"
cd["ref_exists"] = (Path(voices_dir) / ref_name).exists()
cd["ref_name"] = ref_name
character_data.append(cd)
db.close()
return templates.TemplateResponse(
request,
"_voice_list.html",
{
"request": request,
"characters": character_data,
"voices_dir": str(voices_dir),
},
)
@router.get("/voices/{character_name}/history", response_class=HTMLResponse)
async def voice_history(request: Request, character_name: str):
db = _get_db(request)
history = db.execute(
"SELECT * FROM voice_review_history WHERE character_name = ? ORDER BY reviewed_at DESC LIMIT 20",
(character_name,),
).fetchall()
db.close()
return templates.TemplateResponse(
request,
"_voice_history.html",
{
"request": request,
"character_name": character_name,
"history": [dict(h) for h in history],
},
)
@router.get("/chapters/{chapter_id}", response_class=HTMLResponse)
async def chapter_review(request: Request, chapter_id: int):
db = _get_db(request)
chapter = db.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
utterances = db.execute(
"""
SELECT u.*, ga.audio_path, ga.duration_ms, u.speaker_name
FROM utterances u
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
WHERE u.chapter_id = ?
ORDER BY u.utterance_index
""",
(chapter_id,),
).fetchall()
total_chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()["cnt"]
db.close()
return templates.TemplateResponse(
request,
"chapter_review.html",
{
"request": request,
"chapter": dict(chapter) if chapter else {},
"utterances": [dict(u) for u in utterances],
"chapter_id": chapter_id,
"total_chapters": total_chapters,
},
)
@router.get("/chapters/{chapter_id}/partial", response_class=HTMLResponse)
async def chapter_utterances_partial(request: Request, chapter_id: int):
db = _get_db(request)
utterances = db.execute(
"""
SELECT u.*, ga.audio_path, ga.duration_ms, u.speaker_name
FROM utterances u
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
WHERE u.chapter_id = ?
ORDER BY u.utterance_index
""",
(chapter_id,),
).fetchall()
db.close()
return templates.TemplateResponse(
request,
"_utterance_list.html",
{
"request": request,
"utterances": [dict(u) for u in utterances],
"chapter_id": chapter_id,
},
)
@router.get("/quality", response_class=HTMLResponse)
async def quality_report(request: Request):
db = _get_db(request)
reports = db.execute(
"SELECT * FROM quality_reports ORDER BY created_at DESC LIMIT 50"
).fetchall()
defects = []
defects_path = _get_active_config(request).data_dir / "defects.json"
if defects_path.exists():
with open(defects_path) as f:
defects = json.load(f)
chapters = db.execute("""
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
""").fetchall()
db.close()
return templates.TemplateResponse(
request,
"quality_report.html",
{
"request": request,
"reports": [dict(r) for r in reports],
"defects": defects,
"chapters": [dict(c) for c in chapters],
},
)
@router.get("/audio/{chapter_id}/{filename}")
async def serve_audio(request: Request, chapter_id: int, filename: str):
config = _get_active_config(request)
audio_path = config.data_dir / "audio_normalized" / f"ch{chapter_id}" / filename
if not audio_path.exists():
audio_path = config.data_dir / "audio" / f"ch{chapter_id}" / filename
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/voice-audio/{filename}")
async def serve_voice_audio(request: Request, filename: str):
config = _get_active_config(request)
audio_path = config.data_dir / "voices" / filename
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/voice-tests", response_class=HTMLResponse)
async def voice_tests_review(request: Request):
config = _get_active_config(request)
tests_dir = config.data_dir / "voice_tests"
# Load voice assignments
assignments_path = config.data_dir / "voice_assignments.json"
assignments = {}
if assignments_path.exists():
with open(assignments_path) as f:
assignments = json.load(f)
# Load speakers for context
speakers_path = config.data_dir / "speakers.json"
speakers = {}
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
# Build list of test samples with metadata
test_samples = []
if tests_dir.exists():
for wav_file in sorted(tests_dir.glob("*.wav")):
speaker_name = wav_file.stem.replace("_", " ")
voice = assignments.get(speaker_name, "unknown")
speaker_info = speakers.get(speaker_name, {})
test_samples.append(
{
"speaker_name": speaker_name,
"filename": wav_file.name,
"voice": voice,
"demographics": speaker_info.get("demographics", {}),
"vocal_traits": speaker_info.get("vocal_traits", []),
"chapters": len(speaker_info.get("chapters_appeared", [])),
}
)
return templates.TemplateResponse(
request,
"voice_tests.html",
{
"request": request,
"test_samples": test_samples,
"total_count": len(test_samples),
},
)
@router.get("/voice-tests/partial", response_class=HTMLResponse)
async def voice_tests_partial(request: Request):
config = _get_active_config(request)
tests_dir = config.data_dir / "voice_tests"
# Load voice assignments
assignments_path = config.data_dir / "voice_assignments.json"
assignments = {}
if assignments_path.exists():
with open(assignments_path) as f:
assignments = json.load(f)
# Load speakers for context
speakers_path = config.data_dir / "speakers.json"
speakers = {}
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
# Build list of test samples with metadata
test_samples = []
if tests_dir.exists():
for wav_file in sorted(tests_dir.glob("*.wav")):
speaker_name = wav_file.stem.replace("_", " ")
voice = assignments.get(speaker_name, "unknown")
speaker_info = speakers.get(speaker_name, {})
test_samples.append(
{
"speaker_name": speaker_name,
"filename": wav_file.name,
"voice": voice,
"demographics": speaker_info.get("demographics", {}),
"vocal_traits": speaker_info.get("vocal_traits", []),
"chapters": len(speaker_info.get("chapters_appeared", [])),
}
)
return templates.TemplateResponse(
request,
"_voice_tests_list.html",
{
"request": request,
"test_samples": test_samples,
"total_count": len(test_samples),
},
)
@router.get("/voice-analysis/partial", response_class=HTMLResponse)
async def voice_analysis_partial(request: Request):
config = _get_active_config(request)
analysis_dir = config.data_dir / "analysis"
report_path = analysis_dir / "voice_analysis.json"
report = None
if report_path.exists():
with open(report_path) as f:
report = json.load(f)
status = load_analysis_status(analysis_dir)
return templates.TemplateResponse(
request,
"_voice_analysis_report.html",
{
"request": request,
"report": report,
"status": status,
},
)
@router.post("/api/voice-analysis/run")
async def run_voice_analysis(request: Request):
config = _get_active_config(request)
speakers_path = config.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", [])}
from umbraaudio.analysis.voice_analysis import run_voice_analysis_in_background
run_voice_analysis_in_background(
refs_dir=config.data_dir / "voices",
output_dir=config.data_dir / "analysis",
target_sample_rate=config.tts.sample_rate,
speaker_names=speaker_names,
)
return JSONResponse({"ok": True})
@router.get("/voice-test-audio/{filename}")
async def serve_voice_test_audio(request: Request, filename: str):
config = _get_active_config(request)
audio_path = config.data_dir / "voices" / filename
if not audio_path.exists():
audio_path = config.data_dir / "voice_tests" / filename
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/api/speakers/{index}/preview-effects")
async def preview_speaker_effects(request: Request, index: int, effects: str = ""):
config = _get_active_config(request)
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
speaker = speakers[index]
ref_name = speaker["primary_name"].lower().replace(" ", "_")
ref_file = f"{ref_name}_ref.wav"
src = config.data_dir / "voices" / ref_file
if not src.exists():
return JSONResponse({"error": "no voice ref"}, status_code=404)
if effects:
speaker_effects = [e.strip() for e in effects.split(",") if e.strip()]
else:
saved = load_speaker_distortion_effects(
config.data_dir / "speakers.json"
)
speaker_effects = saved.get(speaker["primary_name"], [])
if not speaker_effects:
return FileResponse(str(src), media_type="audio/wav")
try:
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
shutil.copy2(src, tmp.name)
ok = apply_effects_to_file_by_name(Path(tmp.name), speaker_effects)
if not ok:
return FileResponse(str(src), media_type="audio/wav")
return FileResponse(tmp.name, media_type="audio/wav")
except Exception as exc:
logger.error("Preview effects failed: %s", exc, exc_info=True)
return JSONResponse({"error": str(exc)}, status_code=500)
@router.get("/chapters-audio", response_class=HTMLResponse)
async def chapters_audio_review(request: Request):
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
rows = db.execute("""
SELECT c.id, c.title, c.chapter_index,
COUNT(u.id) as utt_count
FROM chapters c
LEFT JOIN utterances u ON u.chapter_id = c.id
GROUP BY c.id
ORDER BY c.chapter_index
""").fetchall()
db.close()
chapters = []
for row in rows:
ch_id = row["id"]
chapters.append(
{
"id": ch_id,
"title": row["title"] or f"Chapter {row['chapter_index'] + 1}",
"chapter_index": row["chapter_index"],
"audio_count": row["utt_count"],
"directory": f"ch{ch_id}",
}
)
return templates.TemplateResponse(
request,
"chapters_audio.html",
{
"request": request,
"chapters": chapters,
"total_chapters": len(chapters),
},
)
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
async def chapter_audio_review(request: Request, chapter_dir: str):
config = _get_active_config(request)
audio_base = config.data_dir / "chapter_audio" / chapter_dir
assignments_path = config.data_dir / "voice_assignments.json"
assignments = {}
if assignments_path.exists():
with open(assignments_path) as f:
assignments = json.load(f)
speakers_path = config.data_dir / "speakers.json"
speakers = {}
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
try:
ch_id = int(chapter_dir[2:])
except (ValueError, IndexError):
ch_id = 0
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
chapter = (
db.execute("SELECT title, chapter_index FROM chapters WHERE id = ?", (ch_id,)).fetchone()
if ch_id
else None
)
utterances = []
if ch_id:
utterances = db.execute(
"""
SELECT u.id, u.speaker_name, u.text, u.utterance_index,
u.emotion, u.pace, u.instruct
FROM utterances u
WHERE u.chapter_id = ?
ORDER BY u.utterance_index
""",
(ch_id,),
).fetchall()
db.close()
title = chapter["title"] if chapter else f"Chapter {chapter_dir}"
existing_wav = {}
if audio_base.exists():
for wav_file in audio_base.glob("*.wav"):
try:
idx = int(wav_file.stem.split("_")[1])
except (IndexError, ValueError):
idx = 0
existing_wav[idx] = wav_file.name
speaker_names = sorted(speakers.keys())
if "Narrator" not in speaker_names:
speaker_names.append("Narrator")
speaker_names = sorted(speaker_names)
lower_to_canonical = {n.lower(): n for n in speaker_names}
for spk in speakers.values():
for alias in spk.get("aliases", []):
if alias and alias.lower() not in lower_to_canonical:
lower_to_canonical[alias.lower()] = spk["primary_name"]
for spk in speakers.values():
primary = spk.get("primary_name", "")
if primary and primary.split()[0].lower() not in lower_to_canonical:
lower_to_canonical[primary.split()[0].lower()] = primary
audio_files = []
for u in utterances:
idx = u["utterance_index"]
speaker = (u["speaker_name"] or "Narrator").strip()
speaker = lower_to_canonical.get(speaker.lower(), speaker)
text = u["text"] or ""
wav = existing_wav.get(idx)
voice = assignments.get(speaker, "unknown")
audio_files.append(
{
"filename": wav,
"index": idx,
"utt_id": u["id"],
"speaker": speaker,
"text": text[:100] + "..." if len(text) > 100 else text,
"full_text": text,
"voice": voice,
"emotion": u["emotion"] or "neutral",
"pace": u["pace"] or "moderate",
"instruct": u["instruct"] or "",
}
)
last_utt_id = audio_files[-1]["utt_id"] if audio_files else None
chapter_full_path = config.data_dir / "chapter_audio" / f"ch{ch_id}_full.wav"
has_chapter_audio = chapter_full_path.exists()
return templates.TemplateResponse(
request,
"chapter_audio_detail.html",
{
"request": request,
"chapter_title": title,
"chapter_dir": chapter_dir,
"chapter_id": ch_id,
"audio_files": audio_files,
"total_utterances": len(audio_files),
"speaker_names": speaker_names,
"last_utt_id": last_utt_id,
"has_chapter_audio": has_chapter_audio,
},
)
@router.get("/chapter-audio-file/{chapter_dir}/{filename}")
async def serve_chapter_audio(request: Request, chapter_dir: str, filename: str):
config = _get_active_config(request)
audio_path = config.data_dir / "chapter_audio" / chapter_dir / filename
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/api/chapter/{chapter_id}/full-audio")
async def serve_chapter_full_audio(request: Request, chapter_id: int):
config = _get_active_config(request)
audio_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
if not audio_path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.get("/api/chapter/{chapter_id}/download-wav")
async def download_chapter_wav(request: Request, chapter_id: int):
config = _get_active_config(request)
audio_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
if not audio_path.exists():
return JSONResponse({"error": "Chapter audio not built yet"}, status_code=404)
db = sqlite3.connect(str(config.data_dir / "project.db"))
row = db.execute("SELECT title FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
db.close()
title = row[0] if row and row[0] else f"chapter_{chapter_id}"
safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip().replace(" ", "_")
return FileResponse(
str(audio_path),
media_type="audio/wav",
filename=f"{safe}.wav",
)
@router.get("/api/chapter/{chapter_id}/download-mp3")
async def download_chapter_mp3(request: Request, chapter_id: int):
config = _get_active_config(request)
full_wav = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
if not full_wav.exists():
return JSONResponse({"error": "Chapter audio not built yet"}, status_code=404)
db = sqlite3.connect(str(config.data_dir / "project.db"))
row = db.execute("SELECT title FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
db.close()
title = row[0] if row and row[0] else f"chapter_{chapter_id}"
safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip().replace(" ", "_")
import subprocess
import tempfile
mp3_path = Path(tempfile.mktemp(suffix=".mp3"))
result = subprocess.run(
[
"ffmpeg", "-y", "-i", str(full_wav),
"-codec:a", "libmp3lame", "-b:a", "192k",
str(mp3_path),
],
capture_output=True,
text=True,
)
if result.returncode != 0:
err_msg = result.stderr[:200] if result.stderr else "unknown error"
return JSONResponse(
{"error": f"MP3 encoding failed: {err_msg}"}, status_code=500
)
from starlette.responses import StreamingResponse
def _stream_and_cleanup():
with open(mp3_path, "rb") as f:
while chunk := f.read(65536):
yield chunk
mp3_path.unlink(missing_ok=True)
return StreamingResponse(
_stream_and_cleanup(),
media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{safe}.mp3"'},
)
@router.put("/api/utterance/{utt_id}/speaker")
async def update_utterance_speaker(request: Request, utt_id: int):
body = await request.json()
new_speaker = body.get("speaker_name", "").strip()
if not new_speaker:
return JSONResponse({"error": "speaker_name required"}, status_code=400)
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
db.execute("UPDATE utterances SET speaker_name = ? WHERE id = ?", (new_speaker, utt_id))
utt = db.execute(
"SELECT chapter_id, utterance_index, text FROM utterances WHERE id = ?", (utt_id,)
).fetchone()
db.commit()
db.close()
if utt:
audio_dir = config.data_dir / "chapter_audio" / f"ch{utt['chapter_id']}"
old_audio = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
if old_audio.exists():
old_audio.unlink()
def _regen():
from umbraaudio.analysis import CharacterProfile
from umbraaudio.voices.voice_designer import VoiceDesigner
try:
designer = VoiceDesigner(config.tts)
output_path = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
audio_dir.mkdir(parents=True, exist_ok=True)
designer._generate_edge_fallback(
CharacterProfile(
name=new_speaker,
pitch_register="mid",
energy="measured",
gender=None,
timbre="clear",
),
text=utt["text"],
output_path=output_path,
)
except Exception as e:
logger.error("Utterance regen failed: %s", e)
import threading
threading.Thread(target=_regen, daemon=True).start()
return JSONResponse({"ok": True, "speaker_name": new_speaker})
@router.put("/api/utterance/{utt_id}/text")
async def update_utterance_text(request: Request, utt_id: int):
body = await request.json()
new_text = body.get("text", "").strip()
if not new_text:
return JSONResponse({"error": "text required"}, status_code=400)
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
utt = db.execute(
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
).fetchone()
db.execute("UPDATE utterances SET text = ?, status = 'pending' WHERE id = ?", (new_text, utt_id))
db.commit()
db.close()
if utt:
audio_dir = config.data_dir / "chapter_audio" / f"ch{utt['chapter_id']}"
old_audio = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
if old_audio.exists():
old_audio.unlink()
return JSONResponse({"ok": True, "text": new_text})
@router.put("/api/utterance/{utt_id}/variation")
async def update_utterance_variation(request: Request, utt_id: int):
body = await request.json()
emotion = body.get("emotion", "").strip() or None
pace = body.get("pace", "").strip() or None
instruct = body.get("instruct", "").strip() or None
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
utt = db.execute(
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
).fetchone()
if not utt:
db.close()
return JSONResponse({"error": "not found"}, status_code=404)
db.execute(
"UPDATE utterances SET emotion = ?, pace = ?, instruct = ? WHERE id = ?",
(emotion, pace, instruct, utt_id),
)
db.commit()
db.close()
return JSONResponse({"ok": True, "emotion": emotion, "pace": pace, "instruct": instruct})
@router.post("/api/utterance/{utt_id}/generate")
async def generate_utterance_audio(request: Request, utt_id: int):
import threading
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
utt = db.execute(
"SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct "
"FROM utterances WHERE id = ?",
(utt_id,),
).fetchone()
if not utt:
db.close()
return JSONResponse({"error": "not found"}, status_code=404)
ch_id = utt["chapter_id"]
idx = utt["utterance_index"]
text = utt["text"] or ""
speaker = (utt["speaker_name"] or "Narrator").strip()
emotion = utt["emotion"] or "neutral"
pace = utt["pace"] or "moderate"
instruct = utt["instruct"] or ""
db.close()
if not text.strip():
return JSONResponse({"error": "utterance text is empty"}, status_code=400)
if text.strip() == "<<<>>>":
return JSONResponse({"error": "scene breaks cannot be voiced"}, status_code=400)
audio_dir = config.data_dir / "chapter_audio" / f"ch{ch_id}"
audio_dir.mkdir(parents=True, exist_ok=True)
output_path = audio_dir / f"utt_{idx:04d}.wav"
if output_path.exists():
output_path.unlink()
def _generate():
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
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.chapter import EMOTION_INSTRUCT_MAP
from umbraaudio.generation.staleness import write_marker
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine, QwenTTSUnavailableError
try:
phonetic = _load_phonetic_names(config.data_dir)
spoken_text = _apply_phonetics(text, phonetic)
voices_dir = config.data_dir / "voices"
speakers = _load_speakers(request)
alias_map = {}
for s in speakers:
alias_map[s["primary_name"]] = s
for a in s.get("aliases", []):
alias_map[a] = s
speaker_info = alias_map.get(speaker)
voice_ref = None
if speaker_info:
ref_name = speaker.lower().replace(" ", "_") + "_ref.wav"
ref_path = voices_dir / ref_name
if ref_path.exists():
voice_ref = ref_path
if not voice_ref:
for vf in voices_dir.glob("*_ref.wav"):
if speaker.lower().replace(" ", "_") in vf.stem.lower():
voice_ref = vf
break
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url)
tts_instruct = instruct
if not tts_instruct and emotion in EMOTION_INSTRUCT_MAP:
tts_instruct = EMOTION_INSTRUCT_MAP[emotion]
if voice_ref:
engine.generate(
text=spoken_text,
output_path=output_path,
voice_ref=voice_ref,
ref_text=VOICE_REFERENCE_TEXT,
instruct=tts_instruct or None,
)
else:
engine.generate(
text=spoken_text,
output_path=output_path,
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
)
if output_path.exists():
explicit = load_speaker_distortion_effects(
config.data_dir / "speakers.json"
).get(speaker, [])
if explicit:
apply_effects_to_file_by_name(output_path, explicit)
else:
traits = load_speaker_traits(
config.data_dir / "speakers.json"
).get(speaker, [])
if traits:
apply_effects_to_file(output_path, traits)
write_marker(spoken_text, output_path, speaker=speaker, voice_ref=voice_ref)
logger.info("Generated utterance %d audio: %s", utt_id, output_path)
except QwenTTSUnavailableError as e:
logger.error("TTS unavailable for utterance %d: %s", utt_id, e)
except Exception as e:
logger.error("Utterance %d generation failed: %s", utt_id, e)
threading.Thread(target=_generate, daemon=True).start()
return JSONResponse({
"ok": True,
"utterance_id": utt_id,
"filename": f"utt_{idx:04d}.wav",
"speaker": speaker,
"emotion": emotion,
})
@router.delete("/api/utterance/{utt_id}")
async def delete_utterance(request: Request, utt_id: int):
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
utt = db.execute(
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
).fetchone()
if not utt:
db.close()
return JSONResponse({"error": "not found"}, status_code=404)
ch_id = utt["chapter_id"]
db.execute("DELETE FROM utterances WHERE id = ?", (utt_id,))
remaining = db.execute(
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
(ch_id,),
).fetchall()
for i, row in enumerate(remaining):
db.execute("UPDATE utterances SET utterance_index = ? WHERE id = ?", (i, row["id"]))
db.commit()
db.close()
audio_dir = config.data_dir / "chapter_audio" / f"ch{ch_id}"
for f in list(audio_dir.glob("utt_*.wav")) + list(audio_dir.glob("utt_*.wav.txt")):
if f.is_file():
f.unlink()
import sys as _sys
import threading
db2 = sqlite3.connect(str(db_path))
db2.row_factory = sqlite3.Row
remaining = db2.execute(
"SELECT utterance_index, text FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
(ch_id,),
).fetchall()
db2.close()
def _regen():
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine, QwenTTSUnavailableError
try:
phonetic = _load_phonetic_names(config.data_dir)
voices_dir = config.data_dir / "voices"
narrator_ref = None
for vf in voices_dir.glob("*_ref.wav"):
if "Narrator" in vf.stem.lower():
narrator_ref = vf
break
if "kainda" in vf.stem.lower():
narrator_ref = vf
break
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url)
for u in remaining:
out = audio_dir / f"utt_{u['utterance_index']:04d}.wav"
if out.exists():
continue
spoken_text = _apply_phonetics(u["text"], phonetic)
if narrator_ref:
engine.generate(text=spoken_text, output_path=out, voice_ref=narrator_ref, ref_text=VOICE_REFERENCE_TEXT)
else:
engine.generate(
text=spoken_text,
output_path=out,
instruct="A calm, neutral voice speaking clearly.",
)
except QwenTTSUnavailableError as e:
logger.error("TTS unavailable for utterance regen: %s", e)
except Exception as e:
logger.error("Utterance regen failed: %s", e)
threading.Thread(target=_regen, daemon=True).start()
return JSONResponse({"ok": True})
@router.post("/api/chapter/{chapter_id}/utterance")
async def create_utterance(request: Request, chapter_id: int):
body = await request.json()
text = body.get("text", "").strip()
speaker = body.get("speaker_name", "Narrator").strip()
if not text:
return JSONResponse({"error": "text required"}, status_code=400)
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
max_idx = db.execute(
"SELECT MAX(utterance_index) as mx FROM utterances WHERE chapter_id = ?",
(chapter_id,),
).fetchone()["mx"]
next_idx = (max_idx or 0) + 1
db.execute(
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index) VALUES (?, ?, ?, ?)",
(chapter_id, speaker, text, next_idx),
)
db.commit()
new_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
db.close()
audio_dir = config.data_dir / "chapter_audio" / f"ch{chapter_id}"
audio_dir.mkdir(parents=True, exist_ok=True)
output_path = audio_dir / f"utt_{next_idx:04d}.wav"
def _generate():
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine, QwenTTSUnavailableError
phonetic = _load_phonetic_names(config.data_dir)
spoken_text = _apply_phonetics(text, phonetic)
voices_dir = config.data_dir / "voices"
speakers = _load_speakers(request)
alias_map = {}
for name, info in speakers.items():
alias_map[name.lower()] = name
for alias in info.get("aliases", []):
alias_map[alias.lower()] = name
narrator_ref = None
for vf in voices_dir.glob("*_ref.wav"):
if "Narrator" in vf.stem.lower():
narrator_ref = vf
break
if "kainda" in vf.stem.lower():
narrator_ref = vf
break
try:
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url)
if narrator_ref:
engine.generate(text=spoken_text, output_path=output_path, voice_ref=narrator_ref, ref_text=VOICE_REFERENCE_TEXT)
else:
engine.generate(
text=spoken_text,
output_path=output_path,
instruct="A calm, neutral voice speaking clearly.",
)
except QwenTTSUnavailableError as e:
logger.error("TTS unavailable for new utterance: %s", e)
except Exception as e:
logger.error("New utterance generation failed: %s", e)
import threading
threading.Thread(target=_generate, daemon=True).start()
return JSONResponse({"ok": True, "utt_id": new_id, "index": next_idx})
@router.post("/api/voices/{character_id}/approve")
async def approve_voice(character_id: int, request: Request):
db = _get_db(request)
char = db.execute(
"SELECT name, energy FROM characters WHERE id = ?", (character_id,)
).fetchone()
if not char:
db.close()
return JSONResponse({"error": "character not found"}, status_code=404)
from umbraaudio.voices.voice_designer import (
ATTRIBUTE_PARAM_MAP,
NARRATOR_CFG_WEIGHT,
NARRATOR_EXAGGERATION,
)
name_hash = int(hashlib.md5(char["name"].encode()).hexdigest()[:8], 16)
energy = char["energy"] or "measured"
if char["name"] == "Narrator":
exag, cfg = NARRATOR_EXAGGERATION, NARRATOR_CFG_WEIGHT
elif energy in ATTRIBUTE_PARAM_MAP:
exag_range, cfg_range = ATTRIBUTE_PARAM_MAP[energy]
exag = exag_range[0] + ((name_hash % 100) / 100.0) * (exag_range[1] - exag_range[0])
cfg = cfg_range[0] + (((name_hash >> 8) % 100) / 100.0) * (cfg_range[1] - cfg_range[0])
else:
exag, cfg = 0.35, 0.45
db.execute("UPDATE characters SET status = 'voiced_approved' WHERE id = ?", (character_id,))
db.execute(
"INSERT INTO voice_review_history (character_name, action, resulting_exaggeration, resulting_cfg_weight) VALUES (?, 'approved', ?, ?)",
(char["name"], exag, cfg),
)
db.commit()
db.close()
return {"status": "approved", "character_id": character_id}
@router.post("/api/voices/{character_id}/reject")
async def reject_voice(character_id: int, request: Request):
db = _get_db(request)
char = db.execute("SELECT name FROM characters WHERE id = ?", (character_id,)).fetchone()
if not char:
db.close()
return JSONResponse({"error": "character not found"}, status_code=404)
db.execute("UPDATE characters SET status = 'rejected' WHERE id = ?", (character_id,))
db.execute(
"INSERT INTO voice_review_history (character_name, action) VALUES (?, 'rejected')",
(char["name"],),
)
db.commit()
db.close()
return {"status": "rejected", "character_id": character_id}
@router.post("/api/voices/{character_id}/edit")
async def edit_voice_prompt(character_id: int, request: Request, prompt: str = Form(...)):
db = _get_db(request)
char = db.execute("SELECT * FROM characters WHERE id = ?", (character_id,)).fetchone()
if not char:
db.close()
return JSONResponse({"error": "character not found"}, status_code=404)
db.execute(
"INSERT INTO voice_review_history (character_name, action, prompt_text) VALUES (?, 'edit_requested', ?)",
(char["name"], prompt),
)
db.commit()
db.close()
return {"status": "edit_queued", "character_id": character_id, "prompt": prompt}
@router.post("/api/chapters/{chapter_id}/approve")
async def approve_chapter(chapter_id: int, request: Request):
db = _get_db(request)
project = db.execute("SELECT id FROM project LIMIT 1").fetchone()
if project:
db.execute(
"INSERT OR REPLACE INTO review_decisions (project_id, entity_type, entity_id, decision) VALUES (?, 'chapter', ?, 'approved')",
(project["id"], chapter_id),
)
db.commit()
db.close()
return {"status": "approved", "chapter_id": chapter_id}
@router.post("/api/chapters/{chapter_id}/regenerate")
async def regenerate_chapter(chapter_id: int, request: Request):
return {"status": "queued", "chapter_id": chapter_id}
@router.post("/api/speakers/voice-status")
async def set_voice_status(request: Request):
body = await request.json()
name = body.get("speaker_name", "").strip()
status = body.get("status", "pending")
if not name:
return JSONResponse({"error": "speaker_name required"}, status_code=400)
config = _get_active_config(request)
voice_status_path = config.data_dir / "voice_status.json"
voice_status = {}
if voice_status_path.exists():
with open(voice_status_path) as f:
voice_status = json.load(f)
voice_status[name] = status
with open(voice_status_path, "w") as f:
json.dump(voice_status, f, indent=2)
distortion_effects = body.get("distortion_effects", [])
if distortion_effects:
speakers_path = config.data_dir / "speakers.json"
if speakers_path.exists():
with open(speakers_path) as f:
sp_data = json.load(f)
for sp in sp_data.get("speakers", []):
if sp.get("primary_name") == name:
sp["distortion_effects"] = distortion_effects
break
with open(speakers_path, "w") as f:
json.dump(sp_data, f, indent=2)
if status == "approved":
registry_path = config.voice_registry_path
voices_dir = config.data_dir / "voices"
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=?, 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, vmatrix_path, source_project) VALUES (?, 'voiced', ?, ?, ?)",
(name, str(ref_path), vmatrix_path, config.project_dir.name),
)
rdb.commit()
rdb.close()
except Exception as e:
logger.warning("Failed to save approved voice to registry: %s", e)
return JSONResponse({"ok": True, "speaker_name": name, "status": status})
@router.post("/api/speakers/regenerate-voice")
async def regenerate_speaker_voice(request: Request):
import threading
body = await request.json()
name = body.get("speaker_name", "").strip()
if not name:
return JSONResponse({"error": "speaker_name required"}, status_code=400)
config = _get_active_config(request)
gm = _get_gen_manager(request)
if gm.is_active():
return JSONResponse({"error": "Generation already in progress", "active": True}, status_code=409)
speakers = _load_speakers(request)
speaker_info = None
for s in speakers:
if s["primary_name"] == name:
speaker_info = s
break
if not speaker_info:
return JSONResponse({"error": "speaker not found"}, status_code=404)
def _regenerate():
from umbraaudio.voices.voice_designer import VoiceDesigner
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
try:
with gm.serialize("voices", 1, f"voice:{name}"):
designer = VoiceDesigner(config.tts)
voices_dir = config.data_dir / "voices"
voices_dir.mkdir(parents=True, exist_ok=True)
profile = get_profile_for_speaker(
name, speaker_info, config.data_dir, force_rebuild=True
)
designer.generate_voice_reference(
character=profile,
output_dir=voices_dir,
sample_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.",
)
gm.update_item(name, success=True)
logger.info("Voice generated for %s (%s)", name, profile.energy)
except Exception as e:
gm.update_item(name, success=False, error=str(e))
logger.error("Voice generation failed for %s: %s", name, e)
threading.Thread(target=_regenerate, daemon=True).start()
return JSONResponse({"ok": True, "speaker_name": name})
@router.post("/api/speakers/generate-all-voices")
async def generate_all_speaker_voices(request: Request):
import threading
config = _get_active_config(request)
gm = _get_gen_manager(request)
if gm.is_active():
return JSONResponse({"error": "Generation already in progress", "active": True}, status_code=409)
def _generate_all():
import json as _json
from umbraaudio.voices.voice_designer import VoiceDesigner
voices_dir = config.data_dir / "voices"
voices_dir.mkdir(parents=True, exist_ok=True)
speakers_path = config.data_dir / "speakers.json"
if not speakers_path.exists():
logger.error("No speakers.json found")
return
with open(speakers_path) as f:
speakers = _json.load(f).get("speakers", [])
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
designer = VoiceDesigner(config.tts)
total = len(speakers)
with gm.serialize("voices", total, "voice:bulk"):
generated = 0
for i, s in enumerate(speakers):
name = s["primary_name"]
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
ref_path = voices_dir / ref_name
if ref_path.exists():
generated += 1
gm.update_item(name, success=True)
continue
profile = get_profile_for_speaker(name, s, config.data_dir, force_rebuild=True)
logger.info("[%d/%d] Generating voice for %s", generated + 1, total, name)
try:
designer.generate_voice_reference(
character=profile,
output_dir=voices_dir,
sample_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.",
)
generated += 1
gm.update_item(name, success=True)
except Exception as e:
gm.update_item(name, success=False, error=str(e))
logger.error(" FAILED %s: %s", name, e)
logger.info("Generated %d/%d voice references", generated, total)
threading.Thread(target=_generate_all, daemon=True).start()
return JSONResponse({"ok": True, "detail": "Voice generation started in background"})
@router.post("/api/generate-audiobook")
async def generate_full_audiobook(request: Request):
import threading
import time as _time
config = _get_active_config(request)
gm = _get_gen_manager(request)
if gm.is_active():
return JSONResponse({"error": "Generation already in progress", "active": True}, status_code=409)
def _write_status(step, state, detail="", progress=0, error="", total_chapters=0, chapters_completed=None, current_chapter=0, completed_utterances=0, total_utterances=0):
payload = {
"step": step,
"state": state,
"detail": detail,
"progress": progress,
"error": error,
"total_chapters": total_chapters,
"chapters_completed": chapters_completed or [],
"current_chapter": current_chapter,
"completed_utterances": completed_utterances,
"total_utterances": total_utterances,
}
status_path = config.data_dir / "pipeline_status.json"
with open(status_path, "w") as f:
json.dump(payload, f)
_last_write = {"time": 0.0}
def _status_callback(step, state, detail, progress, total_chapters=0, chapters_completed=None, current_chapter=0, completed_utterances=0, total_utterances=0):
now = _time.time()
if state == "running" and now - _last_write["time"] < 5.0:
return
_last_write["time"] = now
_write_status(step, state, detail, progress, total_chapters=total_chapters, chapters_completed=chapters_completed or [], current_chapter=current_chapter, completed_utterances=completed_utterances, total_utterances=total_utterances)
def _generate():
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from scripts.generate_all_audio import main as gen_main
try:
_write_status("generate", "running", "Starting audio generation...", 0)
gen_main(status_callback=_status_callback, gen_manager=gm)
except Exception as e:
import traceback
logger.error("Audio generation failed: %s\n%s", e, traceback.format_exc())
_write_status("generate", "failed", str(e), 0, error=str(e))
threading.Thread(target=_generate, daemon=True).start()
return JSONResponse({"ok": True, "message": "Full audiobook generation started"})
@router.post("/api/analyze")
async def run_analysis(request: Request):
import threading
config = _get_active_config(request)
speakers_path = config.data_dir / "speakers.json"
status_path = config.data_dir / "analysis_status.json"
def _write_status(status: str, detail: str = "", progress: int = 0, error: str = ""):
with open(status_path, "w") as f:
json.dump({"status": status, "detail": detail, "progress": progress, "error": error}, f)
def _analyze():
import asyncio
import sqlite3 as _sqlite3
from umbraaudio.analysis.character_discovery import CharacterDiscoverer
try:
_write_status("starting", "Connecting to LLM...", 0)
db_path = config.data_dir / "project.db"
db = _sqlite3.connect(str(db_path))
db.row_factory = _sqlite3.Row
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
chapters = db.execute(
"SELECT chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
db.close()
ch_list = [dict(c) for c in chapters]
book_title = project["name"]
total = len(ch_list)
_write_status("discovering", f"Analyzing {total} chapters...", 5)
discoverer = CharacterDiscoverer(config.llm, data_dir=config.data_dir)
original_call = discoverer._call_llm
def _tracked_call(
text, context="", max_chars=40000, known_speakers="", pass_context=""
):
ch_num = 0
m = __import__("re").search(r"Chapter (\d+):", context)
if m:
ch_num = int(m.group(1))
pct = int(5 + (ch_num / max(total, 1)) * 85) if ch_num else 10
_write_status("discovering", f"Analyzing chapter {ch_num}/{total}...", pct)
return original_call(
text, context=context, max_chars=max_chars, known_speakers=known_speakers,
pass_context=pass_context,
)
discoverer._call_llm = _tracked_call
result = asyncio.run(discoverer.discover_speakers(ch_list, book_title=book_title))
_write_status("saving", "Saving results...", 95)
result.save(speakers_path)
ALIAS_MERGE = {
"Hunter": "Deal Hunter",
"The Deal Hunter": "Deal Hunter",
"Weston": "Captain Weston",
"The captain": "Captain Weston", "the captain": "Captain Weston",
"Princess": "Kainda", "the princess": "Kainda",
"Kainda": "Kainda", "Kainda Murphy": "Kainda",
"Princess Kainda": "Kainda",
"Vicky": "Aunt Vicky", "Aunt Victoria": "Aunt Vicky", "Victoria": "Aunt Vicky",
"the king": "Adric",
"Captain Wake": "Captain Willamina Wake",
"Willamina Wake": "Captain Willamina Wake",
"Duchess": "Duchess Victoria Murphy Wake",
}
with open(speakers_path) as f:
sp_data = json.load(f)
spkrs = sp_data.get("speakers", [])
merged = {}
alias_lower = {k.lower(): v.lower() for k, v in ALIAS_MERGE.items()}
remove_names = set()
def _find_speaker(target_name: str) -> dict | None:
tl = target_name.lower()
for s in spkrs:
if s["primary_name"].lower() == tl:
return s
for s in spkrs:
for a in s.get("aliases", []):
if a.lower() == tl:
return s
return None
for s in spkrs:
name = s["primary_name"]
nl = name.lower()
if nl in alias_lower:
cn = alias_lower[nl]
existing = _find_speaker(cn)
if existing and existing["primary_name"] != name:
remove_names.add(name)
existing.setdefault("aliases", [])
if name not in existing["aliases"]:
existing["aliases"].append(name)
for a in s.get("aliases", []):
al = a.lower()
if al != existing["primary_name"].lower() and al not in {x.lower() for x in existing["aliases"]}:
existing["aliases"].append(a)
s_already = _find_speaker(name)
if s_already and s_already is not existing:
remove_names.add(s_already["primary_name"])
for ch in s_already.get("chapters_appeared", []):
if ch not in existing.setdefault("chapters_appeared", []):
existing["chapters_appeared"].append(ch)
for a in s_already.get("aliases", []):
al = a.lower()
if al != existing["primary_name"].lower() and al not in {x.lower() for x in existing["aliases"]}:
existing["aliases"].append(a)
continue
merged[name] = s
sp_data["speakers"] = [s for s in merged.values() if s["primary_name"] not in remove_names]
with open(speakers_path, "w") as f:
json.dump(sp_data, f, indent=2)
pipeline_path = config.data_dir / "pipeline_status.json"
with open(pipeline_path, "w") as f:
json.dump(
{
"step": "discover",
"status": "complete",
"detail": f"Found {len(result.speakers)} speakers",
"progress": 100,
},
f,
)
_write_status("complete", f"Found {len(result.speakers)} speakers", 100)
except Exception as e:
import traceback
logger.error("Analysis failed: %s\n%s", e, traceback.format_exc())
_write_status("error", str(e), 0, error=str(e))
_write_status("queued", "Analysis queued...")
threading.Thread(target=_analyze, daemon=True).start()
return JSONResponse({"ok": True, "message": "Character discovery started"})
@router.get("/api/analysis-status")
async def analysis_status(request: Request):
config = _get_active_config(request)
status_path = config.data_dir / "analysis_status.json"
if not status_path.exists():
return JSONResponse({"status": "idle"})
with open(status_path) as f:
data = json.load(f)
return JSONResponse(data)
@router.get("/api/pipeline/status")
async def pipeline_status(request: Request):
pm = _get_pipeline(request)
progress = pm.load_status()
return JSONResponse(progress.to_dict())
@router.post("/api/pipeline/status")
async def update_pipeline_status(request: Request):
body = await request.json()
pm = _get_pipeline(request)
from umbraaudio.pipeline_manager import PipelineState, PipelineStep
pm.update(
step=PipelineStep(body.get("step", "upload")) if "step" in body else None,
state=PipelineState(body["status"]) if "status" in body else None,
detail=body.get("detail"),
progress=body.get("progress"),
error=body.get("error"),
)
return JSONResponse({"ok": True})
@router.get("/api/generation/status")
async def generation_status(request: Request):
gm = _get_gen_manager(request)
status = gm.get_status()
if gm.was_interrupted():
status["interrupted"] = True
return JSONResponse(status)
@router.post("/api/pipeline/start")
async def start_pipeline(request: Request):
pm = _get_pipeline(request)
if pm.is_running:
return JSONResponse({"error": "Pipeline already running"}, status_code=409)
async def _run(pipeline_manager: Any) -> None:
from umbraaudio.orchestrator import run_pipeline_background
await run_pipeline_background(pipeline_manager, _get_active_config(request))
pm.start(_run)
return JSONResponse({"ok": True, "detail": "Pipeline started"})
@router.post("/api/pipeline/pause")
async def pause_pipeline(request: Request):
pm = _get_pipeline(request)
pm.pause()
return JSONResponse({"ok": True, "detail": "Pipeline paused"})
@router.post("/api/pipeline/resume")
async def resume_pipeline(request: Request):
pm = _get_pipeline(request)
if not pm.is_paused:
return JSONResponse({"error": "Pipeline not paused"}, status_code=409)
async def _run(pipeline_manager: Any) -> None:
from umbraaudio.orchestrator import run_pipeline_background
await run_pipeline_background(pipeline_manager, _get_active_config(request))
pm.resume(_run)
return JSONResponse({"ok": True, "detail": "Pipeline resumed"})
@router.post("/api/pipeline/cancel")
async def cancel_pipeline(request: Request):
pm = _get_pipeline(request)
pm.cancel()
return JSONResponse({"ok": True, "detail": "Pipeline cancelled"})
@router.get("/registry-audio/{registry_id}/{filename}")
async def serve_registry_audio(request: Request, registry_id: int, filename: str):
config = _get_active_config(request)
registry = _get_registry(config)
if not registry:
return JSONResponse({"error": "registry not found"}, status_code=404)
row = registry.execute(
"SELECT voice_ref_path FROM voice_registry WHERE id = ?", (registry_id,)
).fetchone()
registry.close()
if not row or not row["voice_ref_path"]:
return JSONResponse({"error": "not found"}, status_code=404)
audio_path = Path(row["voice_ref_path"])
if not audio_path.exists():
return JSONResponse({"error": "file not found"}, status_code=404)
return FileResponse(str(audio_path), media_type="audio/wav")
@router.post("/api/pipeline/reset")
async def reset_pipeline(request: Request, step: str = "discover"):
import shutil
config = _get_active_config(request)
if step == "discover":
for f in (
config.data_dir / "speakers.json",
config.data_dir / "voice_assignments.json",
config.data_dir / "phonetic_names.json",
config.data_dir / "analysis_status.json",
):
if f.exists():
f.unlink()
voices_dir = config.data_dir / "voices"
if voices_dir.exists():
shutil.rmtree(voices_dir)
elif step == "voices":
voices_dir = config.data_dir / "voices"
if voices_dir.exists():
shutil.rmtree(voices_dir)
elif step == "segment":
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.execute("DELETE FROM generated_audio")
db.execute("DELETE FROM utterances")
db.commit()
db.close()
pipeline_path = config.data_dir / "pipeline_status.json"
if pipeline_path.exists():
pipeline_path.unlink()
return JSONResponse({"ok": True, "step": step, "detail": f"Reset {step} step"})
@router.post("/api/pipeline/run-all")
async def run_full_pipeline(request: Request):
import threading
config = _get_active_config(request)
gm = _get_gen_manager(request)
status_path = config.data_dir / "pipeline_status.json"
def _write_status(step: str, status: str, detail: str = "", progress: int = 0, error: str = ""):
with open(status_path, "w") as f:
json.dump(
{
"step": step,
"status": status,
"detail": detail,
"progress": progress,
"error": error,
},
f,
)
def _run_all():
try:
_write_status("discover", "starting", "Starting character discovery...", 0)
db_path = config.data_dir / "project.db"
import sqlite3 as _sqlite3
db = _sqlite3.connect(str(db_path))
db.row_factory = _sqlite3.Row
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
chapters_db = db.execute(
"SELECT chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
db.close()
ch_list = [dict(c) for c in chapters_db]
book_title = project["name"]
speakers_path = config.data_dir / "speakers.json"
import asyncio
# --- Step 1: Discovery ---
_write_status("discover", "generating", "Analyzing chapters...", 5)
from umbraaudio.analysis.character_discovery import CharacterDiscoverer
discoverer = CharacterDiscoverer(config.llm, data_dir=config.data_dir)
result = asyncio.run(discoverer.discover_speakers(ch_list, book_title=book_title))
_write_status("discover", "saving", "Saving speakers...", 90)
result.save(speakers_path)
_write_status("discover", "complete", f"Found {len(result.speakers)} speakers", 100)
# --- Step 2: Voice Generation ---
_write_status("voices", "starting", "Starting voice generation...", 0)
from umbraaudio.voices.voice_designer import VoiceDesigner
voices_dir = config.data_dir / "voices"
voices_dir.mkdir(parents=True, exist_ok=True)
with open(speakers_path) as f:
speakers = json.load(f).get("speakers", [])
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
designer = VoiceDesigner(config.tts)
total_speakers = len(speakers)
with gm.serialize("voices", total_speakers, "pipeline:voices"):
generated_voices = 0
for i, s in enumerate(speakers):
name = s["primary_name"]
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
ref_path = voices_dir / ref_name
if ref_path.exists():
generated_voices += 1
gm.update_item(name, success=True)
continue
pct = int(10 + (i / max(total_speakers, 1)) * 80)
_write_status(
"voices", "generating",
f"Generating voice {i + 1}/{total_speakers}: {name}", pct,
)
profile = get_profile_for_speaker(name, s, config.data_dir, force_rebuild=True)
try:
designer.generate_voice_reference(
character=profile,
output_dir=voices_dir,
sample_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.",
)
generated_voices += 1
gm.update_item(name, success=True)
except Exception as e:
gm.update_item(name, success=False, error=str(e))
logger.error("Voice gen failed for %s: %s", name, e)
_write_status(
"voices", "complete",
f"Generated {generated_voices}/{total_speakers} voice references", 100,
)
# --- Step 3: Segmentation ---
_write_status("segment", "starting", "Starting segmentation...", 0)
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.mapping.segmenter import Segmenter
db = _sqlite3.connect(str(db_path))
db.row_factory = _sqlite3.Row
chapters_db = db.execute(
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
with open(speakers_path) as f:
character_names = [
s["primary_name"] for s in json.load(f).get("speakers", [])
]
segmenter = Segmenter()
total_ch = len(chapters_db)
for idx, ch in enumerate(chapters_db):
ch_id = ch["id"]
text = ch["source_text"] or ""
title = ch["title"] or f"Chapter {ch['chapter_index']}"
pct = int(5 + (idx / max(total_ch, 1)) * 60)
_write_status(
"segment", "segmenting",
f"Segmenting chapter {idx + 1}/{total_ch}: {title}", pct,
)
db.execute("DELETE FROM utterances WHERE chapter_id=?", (ch_id,))
db.commit()
if not text.strip():
continue
utterances = segmenter.segment(text, ch_id, character_names, chapter_title=ch["title"])
for u in utterances:
speaker = u.get("character_name") or u.get("speaker_name", "narrator")
utt_idx = u.get("index", u.get("utterance_index", 0))
db.execute(
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name) VALUES (?, ?, ?, ?, ?)",
(ch_id, utt_idx, u.get("text", ""), u.get("type", "narration"), speaker),
)
db.commit()
_write_status("segment", "attributing", "Running LLM attribution...", 65)
try:
attributor = LLMAttributor(config.llm, data_dir=config.data_dir)
for idx, ch in enumerate(chapters_db):
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]
attributed = attributor.attribute_speakers(
utts, ch.get("title", ""), ch_id,
)
for u in attributed:
db.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
(u.get("speaker_name", "Narrator"), u["id"]),
)
db.commit()
pct = int(65 + (idx / max(total_ch, 1)) * 30)
_write_status(
"segment", "attributing",
f"Attributing chapter {idx + 1}/{total_ch}...", pct,
)
except Exception as e:
logger.warning("LLM attribution failed: %s", e)
db.close()
_write_status("segment", "complete", "Segmentation complete", 100)
# --- Step 4: Audio Generation ---
_write_status("generate", "starting", "Starting audio generation...", 0)
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from scripts.generate_all_audio import main as gen_main
gen_main(gen_manager=gm)
_write_status("generate", "complete", "Audio generation complete", 100)
# --- Step 5: Export ---
_write_status("export", "starting", "Exporting audiobook...", 0)
from umbraaudio.assembly.export import AudiobookExporter
exporter = AudiobookExporter()
audio_dir = config.data_dir / "chapter_audio"
if not audio_dir.exists():
audio_dir = config.data_dir / "audio_normalized"
if not audio_dir.exists():
audio_dir = config.data_dir / "audio"
_write_status("export", "Running pre-export quality check...", 85)
from umbraaudio.assembly import ChapterQualityChecker
speakers_path = config.data_dir / "speakers.json"
known_speakers = None
if speakers_path.exists():
with open(speakers_path) as f:
sp_data = json.load(f)
known_speakers = [s["primary_name"] for s in sp_data.get("speakers", [])]
checker = ChapterQualityChecker(config.data_dir, known_speakers)
quality_results = checker.check_all_chapters()
total_errors = sum(1 for issues in quality_results.values() for i in issues if i.severity == "error")
total_warnings = sum(1 for issues in quality_results.values() for i in issues if i.severity == "warning")
if total_errors > 0:
_write_status(
"export", "complete",
f"Export skipped: {total_errors} quality errors, {total_warnings} warnings across {len(quality_results)} chapters",
100,
)
logger.warning(
"Export aborted: %d errors, %d warnings", total_errors, total_warnings
)
return
chapter_files = sorted(audio_dir.glob("*/utt_*.wav"))
if chapter_files:
m4b_path = config.export_dir / "audiobook.m4b"
try:
exporter.export_m4b(
chapter_files, m4b_path,
title=book_title, author="",
)
except Exception as e:
logger.error("M4B export failed: %s", e)
mp3_files = exporter.export_mp3(chapter_files, config.export_dir / "mp3")
_write_status(
"export", "complete",
f"Exported M4B + {len(mp3_files)} MP3 chapters", 100,
)
else:
_write_status("export", "complete", "No audio files to export", 100)
except Exception as e:
import traceback
logger.error("Pipeline failed: %s\n%s", e, traceback.format_exc())
_write_status("pipeline", "error", str(e), 0, error=str(e))
_write_status("pipeline", "queued", "Full pipeline queued...")
threading.Thread(target=_run_all, daemon=True).start()
return JSONResponse({"ok": True, "message": "Full pipeline started"})
@router.get("/api/export/files")
async def list_export_files(request: Request):
config = _get_active_config(request)
export_dir = config.export_dir
files = []
if export_dir.exists():
for f in sorted(export_dir.rglob("*")):
if f.is_file():
rel = f.relative_to(export_dir)
size_mb = f.stat().st_size / (1024 * 1024)
files.append({
"name": str(rel),
"size_mb": round(size_mb, 1),
"url": f"/api/export/download/{rel}",
})
return JSONResponse({"files": files})
@router.get("/api/export/download/{path:path}")
async def download_export_file(request: Request, path: str):
config = _get_active_config(request)
file_path = (config.export_dir / path).resolve()
if not file_path.exists() or not file_path.is_file():
return JSONResponse({"error": "File not found"}, status_code=404)
if not str(file_path).startswith(str(config.export_dir.resolve())):
return JSONResponse({"error": "Access denied"}, status_code=403)
media_type = "audio/m4b" if file_path.suffix == ".m4b" else "audio/mpeg" if file_path.suffix == ".mp3" else "application/octet-stream"
return FileResponse(str(file_path), media_type=media_type, filename=file_path.name)
@router.get("/api/quality/check")
async def quality_check_all(request: Request):
config = _get_active_config(request)
speakers_path = config.data_dir / "speakers.json"
known_speakers = None
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
known_speakers = [s["primary_name"] for s in data.get("speakers", [])]
from umbraaudio.assembly import ChapterQualityChecker
checker = ChapterQualityChecker(config.data_dir, known_speakers)
results = checker.check_all_chapters()
serialized = {str(k): [i.to_dict() for i in v] for k, v in results.items()}
total_issues = sum(len(v) for v in results.values())
return JSONResponse({"chapters": serialized, "total_issues": total_issues})
@router.get("/api/quality/check/{chapter_id}")
async def quality_check_chapter(request: Request, chapter_id: int):
config = _get_active_config(request)
speakers_path = config.data_dir / "speakers.json"
known_speakers = None
if speakers_path.exists():
with open(speakers_path) as f:
data = json.load(f)
known_speakers = [s["primary_name"] for s in data.get("speakers", [])]
from umbraaudio.assembly import ChapterQualityChecker
checker = ChapterQualityChecker(config.data_dir, known_speakers)
issues = checker.check_chapter(chapter_id)
return JSONResponse({"chapter_id": chapter_id, "issues": [i.to_dict() for i in issues]})
@router.post("/api/chapter/{chapter_id}/build")
async def build_chapter_audio(request: Request, chapter_id: int):
import threading
from umbraaudio.generation.chapter_builder import ChapterBuilder
config = _get_active_config(request)
def _run_builder():
builder = ChapterBuilder(config, chapter_id)
builder.run()
threading.Thread(target=_run_builder, daemon=True).start()
return JSONResponse({"ok": True, "message": f"Chapter {chapter_id} build started"})
@router.get("/api/chapter/{chapter_id}/build-status")
async def chapter_build_status(request: Request, chapter_id: int):
config = _get_active_config(request)
status_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_build_status.json"
if not status_path.exists():
return JSONResponse({"status": "idle"})
with open(status_path) as f:
data = json.load(f)
return JSONResponse(data)
@router.post("/api/segment-utterances")
async def segment_utterances(request: Request):
import threading
config = _get_active_config(request)
status_path = config.data_dir / "pipeline_status.json"
def _write_status(status: str, detail: str = "", progress: int = 0, error: str = ""):
with open(status_path, "w") as f:
json.dump(
{
"step": "segmenting",
"status": status,
"detail": detail,
"progress": progress,
"error": error,
},
f,
)
def _segment():
import sqlite3 as _sqlite3
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.mapping.segmenter import Segmenter
try:
_write_status("segmenting", "Starting utterance segmentation...", 5)
db_path = config.data_dir / "project.db"
db = _sqlite3.connect(str(db_path))
db.row_factory = _sqlite3.Row
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
chapters = db.execute(
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
speakers_path = config.data_dir / "speakers.json"
character_names = []
if speakers_path.exists():
with open(speakers_path) as f:
speakers_data = json.load(f)
character_names = [s["primary_name"] for s in speakers_data.get("speakers", [])]
segmenter = Segmenter()
total = len(chapters)
for idx, ch in enumerate(chapters):
ch_id = ch["id"]
text = ch["source_text"] or ""
title = ch["title"] or f"Chapter {ch['chapter_index']}"
pct = int(5 + (idx / max(total, 1)) * 60)
_write_status("segmenting", f"Segmenting chapter {idx + 1}/{total}: {title}", pct)
db.execute("DELETE FROM utterances WHERE chapter_id=?", (ch_id,))
db.commit()
if not text.strip():
continue
utterances = segmenter.segment(text, ch_id, character_names, chapter_title=ch["title"])
for u in utterances:
speaker = u.get("character_name") or u.get("speaker_name", "narrator")
utt_idx = u.get("index", u.get("utterance_index", 0))
db.execute(
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name) VALUES (?, ?, ?, ?, ?)",
(ch_id, utt_idx, u.get("text", ""), u.get("type", "narration"), speaker),
)
db.commit()
_write_status("attributing", "Running LLM speaker attribution...", 65)
try:
from umbraaudio.voices.lmstudio_lifecycle import get_loaded_model, is_running
lm_running = is_running()
lm_model = get_loaded_model() if lm_running else None
logger.info("LM Studio check: running=%s, model=%s", lm_running, lm_model)
if not lm_running or not lm_model:
_write_status(
"complete",
"Segmentation complete (LM Studio offline - attribution skipped)",
100,
)
logger.warning(
"LM Studio not running or no model loaded - skipping LLM attribution"
)
else:
attributor = LLMAttributor(config.llm, data_dir=config.data_dir)
db2 = _sqlite3.connect(str(db_path))
db2.row_factory = _sqlite3.Row
all_chapters = db2.execute(
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
for idx, ch in enumerate(all_chapters):
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
pct = int(65 + (idx / max(total, 1)) * 30)
_write_status(
"attributing", f"Attributing chapter {idx + 1}/{total}: {title}", pct
)
utts = db2.execute(
"SELECT id, utterance_index, text, type, speaker_name FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
if not utts:
continue
utt_list = [dict(u) for u in utts]
attributed = attributor.attribute_speakers(utt_list, title, ch_id)
for u in attributed:
db2.execute(
"UPDATE utterances SET speaker_name=? WHERE id=?",
(u.get("speaker_name", "Narrator"), u["id"]),
)
db2.commit()
db2.close()
except Exception as e:
logger.warning("LLM attribution failed, using mechanical results: %s", e)
_write_status("emotions", "Analyzing emotion and tone...", 95)
try:
from umbraaudio.mapping.emotion import EmotionMapper
from umbraaudio.voices.lmstudio_lifecycle import get_loaded_model
from umbraaudio.voices.lmstudio_lifecycle import is_running as _is_running
if _is_running() and get_loaded_model():
from openai import OpenAI
emotion_client = OpenAI(
base_url=config.llm.lmstudio_base_url,
api_key="lm-studio",
)
emotion_mapper = EmotionMapper(
llm_client=emotion_client, model=get_loaded_model()
)
db_em = _sqlite3.connect(str(db_path))
db_em.row_factory = _sqlite3.Row
em_chapters = db_em.execute(
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
(project["id"],),
).fetchall()
for idx, ch in enumerate(em_chapters):
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
_write_status("emotions", f"Analyzing emotions: {title}", 95)
utts = db_em.execute(
"SELECT id, utterance_index, text, type, speaker_name FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
if not utts:
continue
utt_list = [dict(u) for u in utts]
context = ch["source_text"][:500] if ch["source_text"] else ""
emotions = emotion_mapper.analyze_utterances(utt_list, context)
for u, emo in zip(utt_list, emotions):
db_em.execute(
"UPDATE utterances SET emotion=?, pace=?, instruct=? WHERE id=?",
(emo.get("emotion", "neutral"), emo.get("pace", "moderate"), emo.get("instruct", ""), u["id"]),
)
db_em.commit()
db_em.close()
logger.info("Emotion analysis complete for %d chapters", len(em_chapters))
else:
logger.info("LM Studio offline, skipping emotion analysis")
except Exception as e:
logger.warning("Emotion analysis failed: %s", e)
db.close()
pipeline_path = config.data_dir / "pipeline_status.json"
with open(pipeline_path, "w") as f:
json.dump(
{
"step": "segment",
"status": "complete",
"detail": "Utterance segmentation complete",
"progress": 100,
},
f,
)
_write_status("complete", "Segmentation complete", 100)
except Exception as e:
import traceback
tb = traceback.format_exc()
logger.error("Segmentation failed: %s\n%s", e, tb)
_write_status("error", str(e), 0, error=str(e))
pipeline_path = config.data_dir / "pipeline_status.json"
with open(pipeline_path, "w") as f:
json.dump(
{
"step": "segment",
"status": "error",
"detail": str(e),
"progress": 0,
"error": tb,
},
f,
)
_write_status("queued", "Segmentation queued...")
threading.Thread(target=_segment, daemon=True).start()
return JSONResponse({"ok": True, "message": "Utterance segmentation started"})
def _get_registry(config: Any) -> sqlite3.Connection | None:
registry_path: Path = config.voice_registry_path
if not registry_path.exists():
return None
db = sqlite3.connect(str(registry_path))
db.row_factory = sqlite3.Row
return db
@router.get("/registry", response_class=HTMLResponse)
async def registry_index(request: Request):
config = _get_active_config(request)
registry = _get_registry(config)
characters: list[dict[str, Any]] = []
if registry:
rows = registry.execute(
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
).fetchall()
characters = [dict(r) for r in rows]
registry.close()
return templates.TemplateResponse(
request,
"registry.html",
{
"characters": characters,
},
)
@router.get("/registry/search", response_class=JSONResponse)
async def registry_search(request: Request, q: str = ""):
config = _get_active_config(request)
registry = _get_registry(config)
if not registry:
return JSONResponse([])
like = f"%{q}%"
rows = registry.execute(
"""SELECT vr.* FROM voice_registry vr
LEFT JOIN registry_aliases ra ON ra.registry_id = vr.id
WHERE vr.character_name LIKE ? OR ra.alias_name LIKE ?
GROUP BY vr.id ORDER BY vr.character_name""",
(like, like),
).fetchall()
registry.close()
return JSONResponse([dict(r) for r in rows])
@router.get("/registry/partial", response_class=HTMLResponse)
async def registry_list_partial(request: Request):
config = _get_active_config(request)
registry = _get_registry(config)
characters: list[dict[str, Any]] = []
if registry:
rows = registry.execute(
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
).fetchall()
characters = [dict(r) for r in rows]
registry.close()
return templates.TemplateResponse(
request,
"_registry_list.html",
{
"request": request,
"characters": characters,
},
)
# ==============================================================================
# Model Management API
# ==============================================================================
@router.get("/api/models/status")
async def models_status(request: Request):
"""Get status of all GPU models (Qwen TTS + LM Studio)"""
config = _get_active_config(request)
# Check Qwen TTS server
qwen_health = {"status": "unreachable", "voicedesign_loaded": False, "base_loaded": False}
if config.tts.qwen_tts_url:
try:
import httpx
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{config.tts.qwen_tts_url}/health")
if r.status_code < 500:
qwen_health = r.json()
except Exception:
pass
# Check LM Studio
lm_status = {"loaded": False, "model": None, "instance_id": None}
if config.llm.lmstudio_base_url:
try:
import httpx
base = config.llm.lmstudio_base_url.replace("/v1", "")
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{base}/api/v1/models")
if r.status_code < 500:
data = r.json()
for model in data.get("models", []):
for inst in model.get("loaded_instances", []):
lm_status = {
"loaded": True,
"model": model.get("key"),
"instance_id": inst.get("id"),
"context_length": inst.get("config", {}).get("context_length"),
"gpu_memory_gb": inst.get("config", {}).get("gpu_memory_gb"),
}
except Exception:
pass
return JSONResponse({
"qwen_tts": qwen_health,
"lm_studio": lm_status,
})
@router.post("/api/models/qwen/load/{model_type}")
async def load_qwen_model(request: Request, model_type: str):
"""Load a Qwen TTS model (voicedesign or base)"""
config = _get_active_config(request)
if model_type not in ("voicedesign", "base"):
return JSONResponse({"error": "Invalid model type"}, status_code=400)
if not config.tts.qwen_tts_url:
return JSONResponse({"error": "Qwen TTS URL not configured"}, status_code=400)
try:
import httpx
async with httpx.AsyncClient(timeout=120) as client:
# Load requested model (server auto-unloads the other model)
r = await client.post(
f"{config.tts.qwen_tts_url}/load",
json={"model_type": model_type},
)
if r.status_code < 400:
return JSONResponse({"ok": True, "model": model_type})
return JSONResponse({"error": r.text[:200]}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/api/models/qwen/unload")
async def unload_qwen_model(request: Request, model_type: str = "all"):
"""Unload Qwen TTS model(s)"""
config = _get_active_config(request)
if not config.tts.qwen_tts_url:
return JSONResponse({"error": "Qwen TTS URL not configured"}, status_code=400)
try:
import httpx
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{config.tts.qwen_tts_url}/unload",
json={"model_type": model_type},
)
if r.status_code < 400:
return JSONResponse({"ok": True, "unloaded": model_type})
return JSONResponse({"error": r.text[:200]}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/api/models/switch/{stage}")
async def switch_models(request: Request, stage: str):
"""Switch the loaded model set to match a pipeline stage.
Unloads every model the stage does not need and loads the ones it does,
so the model about to do work gets maximum memory.
"""
from umbraaudio.voices.model_coordinator import (
AsyncModelCoordinator,
PipelineStage,
)
try:
parsed = PipelineStage(stage)
except ValueError:
valid = ", ".join(s.value for s in PipelineStage)
return JSONResponse(
{"error": f"Unknown stage '{stage}'. Valid: {valid}"},
status_code=400,
)
config = _get_active_config(request)
coordinator = AsyncModelCoordinator(config)
try:
result = await coordinator.switch_to(parsed)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
status = 200 if result.get("ok") else 502
return JSONResponse(result, status_code=status)
@router.post("/api/models/lm/load")
async def load_lm_model(request: Request):
"""Load an LM Studio model"""
config = _get_active_config(request)
body = await request.json()
model_id = body.get("model", config.llm.lmstudio_model)
context_length = body.get("context_length", 4096)
try:
import httpx
base = config.llm.lmstudio_base_url.replace("/v1", "")
async with httpx.AsyncClient(timeout=180) as client:
# Unload any existing model first
r = await client.get(f"{base}/api/v1/models")
if r.status_code < 500:
data = r.json()
for model in data.get("models", []):
for inst in model.get("loaded_instances", []):
await client.post(
f"{base}/api/v1/models/unload",
json={"instance_id": inst.get("id")}
)
# Load new model
r = await client.post(
f"{base}/api/v1/models/load",
json={"model": model_id, "context_length": context_length},
)
if r.status_code < 400:
data = r.json()
if data.get("status") == "loaded":
return JSONResponse({"ok": True, "model": model_id, "load_time": data.get("load_time_seconds")})
return JSONResponse({"error": r.text[:300]}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/api/models/lm/unload")
async def unload_lm_model(request: Request):
"""Unload LM Studio model"""
config = _get_active_config(request)
try:
import httpx
base = config.llm.lmstudio_base_url.replace("/v1", "")
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{base}/api/v1/models")
if r.status_code < 500:
data = r.json()
unloaded = []
for model in data.get("models", []):
for inst in model.get("loaded_instances", []):
inst_id = inst.get("id")
await client.post(
f"{base}/api/v1/models/unload",
json={"instance_id": inst_id}
)
unloaded.append(inst_id)
return JSONResponse({"ok": True, "unloaded": unloaded})
return JSONResponse({"ok": True, "unloaded": []})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/api/models/lm/available")
async def list_lm_models(request: Request):
"""List available LM Studio models"""
config = _get_active_config(request)
try:
import httpx
base = config.llm.lmstudio_base_url.replace("/v1", "")
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{base}/api/v1/models")
if r.status_code < 500:
data = r.json()
models = []
for model in data.get("models", []):
models.append({
"key": model.get("key"),
"display_name": model.get("display_name"),
"params": model.get("params_string"),
"size_gb": round(model.get("size_bytes", 0) / 1e9, 1),
"loaded": len(model.get("loaded_instances", [])) > 0,
"instance_id": model.get("loaded_instances", [{}])[0].get("id") if model.get("loaded_instances") else None,
})
return JSONResponse({"models": models})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ==================== Exception Review API Routes ====================
@router.get("/exception-review", response_class=HTMLResponse)
async def exception_review_page(request: Request):
"""Exception review page - shows utterances with low attribution confidence."""
config = _get_active_config(request)
db = _get_db(request)
# Get filter parameters
chapter_filter = request.query_params.get("chapter")
type_filter = request.query_params.get("ambiguity_type")
confidence_filter = request.query_params.get("confidence")
status_filter = request.query_params.get("status", "pending")
page = int(request.query_params.get("page", 1))
per_page = 50
# Build query
where_clauses = ["er.status = ?"]
params = [status_filter]
if chapter_filter:
where_clauses.append("er.chapter_id = ?")
params.append(int(chapter_filter))
if type_filter:
where_clauses.append("er.ambiguity_type = ?")
params.append(type_filter)
if confidence_filter == "very_low":
where_clauses.append("er.confidence < 0.3")
elif confidence_filter == "low":
where_clauses.append("er.confidence >= 0.3 AND er.confidence < 0.5")
elif confidence_filter == "medium":
where_clauses.append("er.confidence >= 0.5 AND er.confidence < 0.7")
where_sql = " AND ".join(where_clauses)
# Get total count
count_sql = f"""
SELECT COUNT(*) as cnt
FROM exception_reviews er
JOIN chapters c ON er.chapter_id = c.id
WHERE {where_sql}
"""
total = db.execute(count_sql, params).fetchone()["cnt"]
# Get paginated results
offset = (page - 1) * per_page
sql = f"""
SELECT
er.*,
c.chapter_index,
c.title as chapter_title
FROM exception_reviews er
JOIN chapters c ON er.chapter_id = c.id
WHERE {where_sql}
ORDER BY er.confidence ASC, c.chapter_index ASC, er.utterance_index ASC
LIMIT ? OFFSET ?
"""
params.extend([per_page, offset])
rows = db.execute(sql, params).fetchall()
exceptions = []
for row in rows:
exc = dict(row)
# Parse JSON fields
import json
exc["evidence"] = json.loads(exc["evidence"]) if exc["evidence"] else []
exc["alternatives"] = json.loads(exc["alternatives"]) if exc["alternatives"] else []
exceptions.append(exc)
# Get chapters for filter dropdown
chapters = db.execute(
"SELECT id, chapter_index, title FROM chapters ORDER BY chapter_index"
).fetchall()
# Calculate exception rate
total_utterances = db.execute(
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
).fetchone()["cnt"]
total_exceptions = db.execute(
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
).fetchone()["cnt"]
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
total_pages = (total + per_page - 1) // per_page
return templates.TemplateResponse("exception_review.html", {
"request": request,
"project": {"name": "DealHunter"},
"exceptions": exceptions,
"chapters": chapters,
"selected_chapter": int(chapter_filter) if chapter_filter else None,
"selected_type": type_filter,
"selected_confidence": confidence_filter,
"selected_status": status_filter,
"page": page,
"total_pages": total_pages,
"total_exceptions": total_exceptions,
"total_utterances": total_utterances,
"exception_rate": exception_rate,
})
@router.get("/api/exception-review/stats")
async def exception_review_stats(request: Request):
"""Get exception review statistics for badge updates."""
db = _get_db(request)
total_utterances = db.execute(
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
).fetchone()["cnt"]
total_exceptions = db.execute(
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
).fetchone()["cnt"]
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
by_type = db.execute("""
SELECT ambiguity_type, COUNT(*) as cnt
FROM exception_reviews
WHERE status = 'pending'
GROUP BY ambiguity_type
ORDER BY cnt DESC
""").fetchall()
return JSONResponse({
"total_utterances": total_utterances,
"total_exceptions": total_exceptions,
"exception_rate": exception_rate,
"by_type": [{"type": r["ambiguity_type"], "count": r["cnt"]} for r in by_type],
})
@router.get("/api/exception-review/filter", response_class=HTMLResponse)
async def exception_review_filter(request: Request):
"""HTMX endpoint for filtered exception table."""
return await exception_review_page(request)
@router.get("/api/exception-review/{exc_id}/context")
async def exception_context(request: Request, exc_id: int):
"""Get context for an exception (surrounding utterances)."""
db = _get_db(request)
exc = db.execute(
"SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)
).fetchone()
if not exc:
return JSONResponse({"error": "Not found"}, status_code=404)
# Get surrounding utterances
context = db.execute("""
SELECT u.*, c.title as chapter_title
FROM utterances u
JOIN chapters c ON u.chapter_id = c.id
WHERE u.chapter_id = ? AND u.utterance_index BETWEEN ? AND ?
ORDER BY u.utterance_index
""", (exc["chapter_id"], exc["utterance_index"] - 5, exc["utterance_index"] + 5)).fetchall()
return templates.TemplateResponse("_exception_context.html", {
"request": request,
"exception": dict(exc),
"context": [dict(r) for r in context],
})
@router.get("/api/exception-review/{exc_id}/resolve-form")
async def exception_resolve_form(request: Request, exc_id: int):
"""Get resolve form for an exception."""
db = _get_db(request)
exc = db.execute(
"SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)
).fetchone()
if not exc:
return JSONResponse({"error": "Not found"}, status_code=404)
# Get available speakers for this chapter
speakers = db.execute("""
SELECT DISTINCT speaker_name
FROM utterances
WHERE chapter_id = ? AND type = 'dialogue' AND speaker_name != 'narrator'
ORDER BY speaker_name
""", (exc["chapter_id"],)).fetchall()
# Also get global speakers
global_speakers = db.execute("""
SELECT DISTINCT name FROM characters WHERE project_id = 1 AND is_narrator = 0
""").fetchall()
all_speakers = sorted(set([s["speaker_name"] for s in speakers] + [s["name"] for s in global_speakers]))
return templates.TemplateResponse("_exception_resolve_form.html", {
"request": request,
"exception": dict(exc),
"speakers": all_speakers,
})
@router.post("/api/exception-review/{exc_id}/resolve")
async def exception_resolve(request: Request, exc_id: int):
"""Resolve an exception with a corrected speaker."""
form = await request.form()
resolved_speaker = form.get("resolved_speaker")
notes = form.get("notes", "")
if not resolved_speaker:
return JSONResponse({"error": "No speaker selected"}, status_code=400)
db = _get_db(request)
# Update exception review
db.execute("""
UPDATE exception_reviews
SET status = 'resolved',
resolved_speaker = ?,
resolved_by = 'user',
resolved_at = datetime('now'),
notes = COALESCE(notes, '') || ?
WHERE id = ?
""", (resolved_speaker, f"\nResolution: {notes}" if notes else "", exc_id))
# Update the utterance itself
exc = db.execute(
"SELECT utterance_id FROM exception_reviews WHERE id = ?", (exc_id,)
).fetchone()
if exc:
db.execute("""
UPDATE utterances
SET speaker_name = ?
WHERE id = ?
""", (resolved_speaker, exc["utterance_id"]))
db.commit()
return JSONResponse({"ok": True, "speaker": resolved_speaker})
@router.get("/api/exception-review/{exc_id}/alternatives")
async def exception_alternatives(request: Request, exc_id: int):
"""Get alternative speakers for an exception."""
db = _get_db(request)
exc = db.execute(
"SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)
).fetchone()
if not exc:
return JSONResponse({"error": "Not found"}, status_code=404)
alternatives = json.loads(exc["alternatives"]) if exc["alternatives"] else []
return templates.TemplateResponse("_exception_alternatives.html", {
"request": request,
"exception": dict(exc),
"alternatives": alternatives,
})
@router.post("/api/exception-review/populate")
async def populate_exceptions(request: Request):
"""Populate exception_reviews table from recent attribution confidences."""
import json
config = _get_active_config(request)
db = _get_db(request)
# Get all chapters with their utterances
chapters = db.execute(
"SELECT id, chapter_index, title FROM chapters ORDER BY chapter_index"
).fetchall()
total_added = 0
for ch in chapters:
# Get utterances with low confidence
utts = db.execute("""
SELECT id, utterance_index, speaker_name, attribution_confidence, attribution_reasoning, text, type
FROM utterances
WHERE chapter_id = ? AND type = 'dialogue'
AND (attribution_confidence IS NULL OR attribution_confidence < 0.7)
""", (ch["id"],)).fetchall()
for utt in utts:
# Check if already in exception_reviews
existing = db.execute(
"SELECT id FROM exception_reviews WHERE utterance_id = ?", (utt["id"],)
).fetchone()
if existing:
continue
# Determine ambiguity type
ambiguity_type = "low_confidence"
if utt["attribution_confidence"] is None:
ambiguity_type = "no_confidence"
elif utt["speaker_name"] == "narrator":
ambiguity_type = "narrator_dialogue"
elif "pronoun" in (utt["attribution_reasoning"] or "").lower():
ambiguity_type = "pronoun"
elif "role" in (utt["attribution_reasoning"] or "").lower():
ambiguity_type = "unnamed_role"
db.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, ch["id"], utt["id"], utt["utterance_index"],
utt["speaker_name"], utt["speaker_name"],
utt["attribution_confidence"] or 0.0,
ambiguity_type,
json.dumps([utt["attribution_reasoning"] or "Low confidence attribution"]),
json.dumps([]),
utt["text"][:200],
))
total_added += 1
db.commit()
return JSONResponse({"ok": True, "added": total_added})
@router.get("/api/exception-review/quality-gate")
async def quality_gate_check(request: Request):
"""Check if exception rate exceeds 1% quality gate."""
db = _get_db(request)
total_utterances = db.execute(
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
).fetchone()["cnt"]
total_exceptions = db.execute(
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
).fetchone()["cnt"]
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
passed = exception_rate <= 1.0
return JSONResponse({
"passed": passed,
"exception_rate": exception_rate,
"total_exceptions": total_exceptions,
"total_utterances": total_utterances,
"threshold": 1.0,
"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")