Files
UmbraAudio/umbraaudio/web/routes.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
2026-11-06 16:55:49 -08:00

7149 lines
252 KiB
Python

from __future__ import annotations
import contextlib
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, Query, 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"))
class NoActiveProjectError(Exception):
"""Raised when a project-scoped endpoint is hit without an active project.
The active project is a per-browser cookie, so two different systems can
each edit their own project independently. There is deliberately no
fallback to app-level config or to a discovered project: a request either
names its project explicitly or gets redirected to the project picker
(HTML) / a client error (API).
"""
def _active_project_for_template(request: Request) -> dict:
"""Resolve the project currently selected in the UI for the header."""
from umbraaudio.config import list_projects
cookie_slug = request.cookies.get("active_project")
name = "No project"
for p in list_projects():
if p["slug"] == cookie_slug:
name = p["name"] or cookie_slug
break
if cookie_slug and name == "No project":
name = cookie_slug
return {"slug": cookie_slug, "name": name}
templates.env.globals["active_project"] = _active_project_for_template
def _list_projects_for_template() -> list[dict]:
"""Every project, so the speakers page can offer a switcher.
The active project is a per-browser cookie; without a visible switcher a
reviewer comparing two browsers had no way to tell they were looking at
two different books.
"""
from umbraaudio.config import list_projects
try:
return list_projects()
except Exception:
return []
templates.env.globals["list_projects"] = _list_projects_for_template
def _voice_maintenance_for_template() -> dict | None:
"""Expose the active voice-maintenance marker (or None) to templates."""
from umbraaudio.voice_maintenance import active_voice_maintenance
return active_voice_maintenance()
templates.env.globals["voice_maintenance"] = _voice_maintenance_for_template
def _voice_maintenance_block() -> JSONResponse | None:
"""Return a 409 response while voice generation must not run, else None.
The banner already warns generation "will fall back to Edge-TTS", but a
warning is not a lock: without this guard, clicking Generate during an LLM
attribution run silently produces Edge-TTS audio (retries + backoff per
speaker, then a degraded ref written to disk). For a quality-first pipeline
refusing is better than degrading, so generation is hard-blocked.
Deliberately NOT applied to read-only or approval paths: the speakers page
health check and set_voice_status (which captures a vmatrix from an existing
_ref.wav) must keep working so the reviewer can still audit and approve
voices while analysis is paused.
The marker is written and cleared by the analysis scripts in a ``finally:``
block, so it cannot survive a crashed run and strand generation permanently.
"""
from umbraaudio.voice_maintenance import active_voice_maintenance
marker = active_voice_maintenance()
if not marker:
return None
return JSONResponse(
{
"error": "Voice generation is paused while a CPU/GPU-heavy analysis is running.",
"reason": marker.get("reason", "analysis in progress"),
"detail": (
"Qwen3-TTS cannot run alongside the LLM on this machine, so generating "
"now would silently fall back to Edge-TTS and lose raspy/texture. "
"Wait for the analysis to finish, then generate."
),
"blocked_by": "voice_maintenance",
},
status_code=409,
)
from umbraaudio.generation.audio_effects import (
apply_effects_to_file_by_name,
load_speaker_distortion_effects,
)
from umbraaudio.generation.scene_break import (
is_scene_break,
write_scene_break_audio,
)
from umbraaudio.pronunciation import (
PRONUNCIATION_STATUS_FILE,
generate_pronunciation_audio,
lookup_pronunciation,
pronunciation_audio_path,
)
from umbraaudio.pronunciation import (
load_phonetic_names as _load_phonetic_names,
)
from umbraaudio.pronunciation import (
load_proper_names as _load_proper_names,
)
from umbraaudio.pronunciation import (
prepare_tts_text as _prepare_tts_text,
)
from umbraaudio.pronunciation import (
load_records as _load_pronunciation_records,
)
from umbraaudio.pronunciation import (
save_records as _save_pronunciation_records,
)
from umbraaudio.analysis.attribution_qa import (
check_attribution as _qa_check_attribution,
fix_high_confidence as _qa_fix_high_confidence,
verify_conversation_flow as _qa_verify_conversation_flow,
)
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',
source_language TEXT NOT NULL DEFAULT 'en'
);
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,
attribution_reasoning TEXT,
attribution_evidence_kind TEXT,
attribution_tag_name TEXT,
attribution_uncertain INTEGER NOT NULL DEFAULT 0,
needs_review INTEGER NOT NULL DEFAULT 0,
review_reason TEXT,
qa_accepted INTEGER NOT NULL DEFAULT 0,
qa_accepted_at TEXT,
qa_accepted_sig TEXT,
emotion_confidence REAL DEFAULT 1.0,
user_edited INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- spec 31: the language this utterance's text is written in. NULL means the
-- project's source language.
language_code TEXT,
language_origin TEXT
);
CREATE TABLE IF NOT EXISTS utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL,
language_name TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
origin TEXT NOT NULL DEFAULT 'umbratrans',
source_ref TEXT,
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
voiced INTEGER NOT NULL DEFAULT 1,
review_reason TEXT,
UNIQUE(utterance_id, language_code)
);
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 TABLE IF NOT EXISTS name_tokens (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
token TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'name_tag',
status TEXT NOT NULL DEFAULT 'unresolved',
target_speaker TEXT,
occurrences INTEGER NOT NULL DEFAULT 0,
chapters_json TEXT NOT NULL DEFAULT '[]',
evidence_json TEXT NOT NULL DEFAULT '[]',
suggestion TEXT,
suggestion_score REAL DEFAULT 0.0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, token)
);
CREATE INDEX IF NOT EXISTS idx_name_tokens_status ON name_tokens(status);
CREATE INDEX IF NOT EXISTS idx_name_tokens_project ON name_tokens(project_id);
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 _ensure_utterance_columns(db: sqlite3.Connection) -> None:
"""Add columns introduced after a project DB was first created (idempotent)."""
with contextlib.suppress(Exception):
cols = {r[1] for r in db.execute("PRAGMA table_info(utterances)")}
for col in (
"qa_accepted INTEGER NOT NULL DEFAULT 0",
"qa_accepted_at TEXT",
"qa_accepted_sig TEXT",
):
name = col.split()[0]
if name not in cols:
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
if "user_edited" not in cols:
db.execute("ALTER TABLE utterances ADD COLUMN user_edited INTEGER NOT NULL DEFAULT 0")
for col in (
"attribution_evidence_kind TEXT",
"attribution_tag_name TEXT",
"attribution_uncertain INTEGER NOT NULL DEFAULT 0",
"needs_review INTEGER NOT NULL DEFAULT 0",
"review_reason TEXT",
# spec 31: utterance source-language tag
"language_code TEXT",
"language_origin TEXT",
):
name = col.split()[0]
if name not in cols:
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
# spec 31: translations table. Created here as well as in SCHEMA_SQL
# because an existing project DB is migrated through this path.
db.execute(
"""
CREATE TABLE IF NOT EXISTS utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL,
language_name TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
origin TEXT NOT NULL DEFAULT 'umbratrans',
source_ref TEXT,
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
voiced INTEGER NOT NULL DEFAULT 1,
review_reason TEXT,
UNIQUE(utterance_id, language_code)
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)"
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
"ON utterance_translations(utterance_id)"
)
# spec 31 section 13.1: the project's source language. An utterance
# with no language tag is written in this language.
project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")}
if "source_language" not in project_cols:
db.execute(
"ALTER TABLE project ADD COLUMN source_language "
"TEXT NOT NULL DEFAULT 'en'"
)
db.commit()
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
try:
db.execute("PRAGMA journal_mode=WAL")
except Exception:
pass
db.executescript(SCHEMA_SQL)
_ensure_utterance_columns(db)
return db
def _qa_accepted_sig(speaker: str, text: str) -> str:
"""Fingerprint a speaker+text pair at accept time.
Acceptance is only honored while the pair still matches, so a later
speaker/text edit re-surfaces the utterance's QA box instead of letting a
stale acceptance silently hide a new issue.
"""
return hashlib.sha256(f"{speaker.strip()}\x00{text.strip()}".encode()).hexdigest()
def _qa_accepted_for(
qa_accepted: int | None, qa_accepted_sig: str | None, speaker: str, text: str
) -> bool:
"""True when an accept record exists and its fingerprint still matches."""
return bool(qa_accepted) and (qa_accepted_sig or "") == _qa_accepted_sig(speaker, text)
def _load_speaker_alias_map(config: Any) -> dict[str, str]:
"""Lowercase alias -> canonical ``primary_name`` for speaker QA checks."""
lower_to_canonical: dict[str, str] = {}
speakers_path = config.data_dir / "speakers.json"
if not speakers_path.exists():
return lower_to_canonical
with open(speakers_path) as f:
data = json.load(f)
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
for name in speakers:
lower_to_canonical[name.lower()] = name
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
return lower_to_canonical
def _qa_issues_for_chapter(
db: sqlite3.Connection, ch_id: int, alias_map: dict[str, str]
) -> dict[int, list[dict[str, Any]]]:
"""Run the mechanical QA cross-checks for a chapter, keyed by utterance_index."""
rows = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
(ch_id,),
).fetchall()
utts = [dict(r) for r in rows]
result = _qa_check_attribution(utts, ch_id, alias_map)
result.issues.extend(_qa_verify_conversation_flow(utts, ch_id, alias_map).issues)
by_index: dict[int, list[dict[str, Any]]] = {}
for iss in result.issues:
by_index.setdefault(iss.utterance_index, []).append(
{
"type": iss.issue_type,
"speaker": iss.speaker,
"expected": iss.expected,
"confidence": iss.confidence,
"evidence": iss.evidence,
"text": iss.text,
}
)
return by_index
def _recheck_utterance_qa(db: sqlite3.Connection, config: Any, utt_id: int) -> dict[str, Any]:
"""Run the check+fix loop for ``utt_id``'s chapter, then report its issues.
High-confidence resolved name-tag mismatches across the chapter are
auto-fixed (same semantics as ``qa_attribution.py --fix``); the returned
payload lists which utterances changed and the issues still attached to
``utt_id``. Returns ``{"error": ...}`` when the utterance is missing.
"""
utt = db.execute(
"SELECT id, chapter_id, utterance_index FROM utterances WHERE id = ?",
(utt_id,),
).fetchone()
if not utt:
return {"error": "utterance not found"}
alias_map = _load_speaker_alias_map(config)
rows = db.execute(
"SELECT id, utterance_index, text, type, speaker_name "
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
(utt["chapter_id"],),
).fetchall()
utts = [dict(r) for r in rows]
fixed: list[dict[str, Any]] = []
patched = _qa_fix_high_confidence(utts, alias_map)
for orig, new in zip(utts, patched, strict=True):
if orig.get("speaker_name") != new.get("speaker_name"):
db.execute(
"UPDATE utterances SET speaker_name = ? WHERE id = ?",
(new["speaker_name"], orig["id"]),
)
fixed.append(
{
"utt_id": orig["id"],
"utt_index": new["utterance_index"],
"from": orig.get("speaker_name"),
"to": new["speaker_name"],
}
)
db.commit()
result = _qa_check_attribution(patched, utt["chapter_id"], alias_map)
result.issues.extend(_qa_verify_conversation_flow(patched, utt["chapter_id"], alias_map).issues)
issues = [
{
"type": iss.issue_type,
"speaker": iss.speaker,
"expected": iss.expected,
"confidence": iss.confidence,
"evidence": iss.evidence,
"text": iss.text,
}
for iss in result.issues
if iss.utterance_index == utt["utterance_index"]
]
return {
"utterance_id": utt_id,
"utterance_index": utt["utterance_index"],
"issues": issues,
"fixed": fixed,
"clean": not issues,
}
def _qa_accept_utterance(db: sqlite3.Connection, utt_id: int) -> dict[str, Any]:
"""Record that a human accepted the utterance's current speaker+text."""
utt = db.execute("SELECT speaker_name, text FROM utterances WHERE id = ?", (utt_id,)).fetchone()
if not utt:
return {"error": "utterance not found"}
sig = _qa_accepted_sig(utt["speaker_name"] or "narrator", utt["text"] or "")
db.execute(
"UPDATE utterances SET qa_accepted = 1, qa_accepted_at = datetime('now'), "
"qa_accepted_sig = ? WHERE id = ?",
(sig, utt_id),
)
db.commit()
return {"utterance_id": utt_id, "accepted": True}
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 not active_slug:
raise NoActiveProjectError("No active project selected.")
config = load_config(active_slug)
if not (config.data_dir / "project.db").exists():
raise NoActiveProjectError(f"Project '{active_slug}' no longer exists.")
return 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.get("/projects/new", response_class=HTMLResponse)
async def project_new_form(request: Request):
"""Form to create a brand-new, isolated project from an uploaded manuscript."""
from umbraaudio.config import list_projects
return templates.TemplateResponse(
request,
"project_new.html",
{"projects": list_projects()},
)
@router.post("/api/projects/activate/{slug}")
async def activate_project(
request: Request,
slug: str,
next_url: str = Query("/", alias="next"),
):
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)
# Only same-site relative paths, so this cannot be turned into an open
# redirect. Lets the project switcher stay on the page the reviewer is on
# instead of dumping them back at the dashboard.
if not next_url.startswith("/") or next_url.startswith("//"):
next_url = "/"
response = RedirectResponse(url=next_url, status_code=303)
response.set_cookie("active_project", slug, max_age=86400 * 365)
return response
@router.post("/api/projects")
async def create_project(request: Request):
"""Create a brand-new, isolated project from an uploaded manuscript.
Unlike ``/api/upload-manuscript`` (which writes into the *active* project's
existing DB), this bootstraps a fresh project directory + DB so a second
book never contaminates the current one. On success it activates the new
project (sets the ``active_project`` cookie) and returns its slug.
"""
import shutil
import tempfile
from umbraaudio.project_bootstrap import create_project_from_manuscript_async
form = await request.form()
upload = form.get("file")
project_name = (form.get("name") or "").strip() or None
if not upload or not hasattr(upload, "filename"):
return JSONResponse({"error": "No file uploaded"}, status_code=400)
suffix = Path(upload.filename).suffix.lower()
if suffix not in (".docx", ".epub", ".txt", ".md", ".pdf"):
return JSONResponse({"error": f"Unsupported format: {suffix}"}, status_code=400)
tmp_dir = Path(tempfile.mkdtemp(prefix="umbra_newproj_"))
tmp_path = tmp_dir / upload.filename
with open(tmp_path, "wb") as f:
content = await upload.read()
f.write(content)
try:
result = await create_project_from_manuscript_async(tmp_path, project_name=project_name)
# Persist a copy of the manuscript inside the new project dir and point
# the DB's source_path at it (bootstrap's recorded path was the temp file).
permanent_path = None
try:
permanent = Path(result["data_dir"]) / "uploads"
permanent.mkdir(exist_ok=True)
permanent_path = permanent / upload.filename
shutil.copy2(tmp_path, permanent_path)
conn = sqlite3.connect(str(Path(result["data_dir"]) / "project.db"))
conn.execute(
"UPDATE project SET source_path = ? WHERE id = ?",
(str(permanent_path), result["project_id"]),
)
conn.commit()
conn.close()
except Exception:
# The project itself is valid; only source-path persistence failed.
pass
# Lift publishing metadata + cover from an EPUB and auto-fill empty
# m4b-meta fields so the new book ships with real metadata.
if suffix == ".epub" and permanent_path is not None and permanent_path.exists():
try:
from umbraaudio.audiobook_meta import merge_epub_metadata
merged = merge_epub_metadata(Path(result["data_dir"]), permanent_path)
if merged.get("ok") and merged.get("filled"):
logger.info(
"EPUB metadata imported for new project '%s': %s",
result["slug"],
", ".join(sorted(merged["filled"])),
)
except Exception as e:
logger.warning("EPUB metadata import failed for new project: %s", e)
except Exception as e:
return JSONResponse({"error": f"Failed to create project: {e}"}, status_code=500)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
slug = result["slug"]
response = JSONResponse(
{
"ok": True,
"slug": slug,
"name": result["name"],
"project_id": result["project_id"],
"chapter_count": result["chapter_count"],
"message": f"Created and activated project '{slug}' ({result['chapter_count']} chapters)",
}
)
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",
"voice_engines.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",
config.data_dir / "voice_engines.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()
from umbraaudio.mapping.segmenter import SENTENCE_END_RE
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)
else:
parts = SENTENCE_END_RE.split(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
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()
if fmt == "epub" and permanent_path.exists():
from umbraaudio.audiobook_meta import merge_epub_metadata
try:
merged = merge_epub_metadata(config.data_dir, permanent_path)
if merged.get("ok"):
result["m4b_import"] = {
"filled": sorted(merged.get("filled", {})),
"cover": bool(merged.get("cover")),
}
logger.info(
"EPUB metadata imported: %s", ", ".join(result["m4b_import"]["filled"])
)
else:
logger.warning("EPUB metadata import skipped: %s", merged.get("error"))
except Exception as e:
logger.warning("EPUB metadata import failed: %s", e)
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)})
m4b_download = next(
(f["name"] for f in export_files if f["name"].lower().endswith(".m4b")), None
)
voices_dir = config.data_dir / "voices"
voice_count = len(list(voices_dir.glob("*_ref.wav"))) if voices_dir.exists() else 0
ar_manifest = None
ar_manifest_path = config.export_dir / "ar_package.json"
if ar_manifest_path.exists():
with contextlib.suppress(Exception):
ar_manifest = json.loads(ar_manifest_path.read_text())
ar_status = {"state": "idle", "detail": "", "progress": 0, "error": ""}
ar_status_path = config.export_dir / "ar_build_status.json"
if ar_status_path.exists():
with contextlib.suppress(Exception):
ar_status = json.loads(ar_status_path.read_text())
# 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,
"pipeline_activity": _collect_pipeline_activity(config),
"export_files": export_files,
"m4b_download": m4b_download,
"ar_manifest": ar_manifest,
"ar_status": ar_status,
"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)
voice_engines_path = config.data_dir / "voice_engines.json"
voice_engines = {}
if voice_engines_path.exists():
try:
with open(voice_engines_path) as f:
voice_engines = json.load(f)
except (json.JSONDecodeError, OSError):
voice_engines = {}
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")
engine_meta = voice_engines.get(name) or {}
s["voice_engine"] = engine_meta.get("engine")
s["voice_engine_reason"] = engine_meta.get("reason", "")
try:
ref_mtime = (voices_dir / ref_name).stat().st_mtime
except OSError:
ref_mtime = 0
# Only flag the fallback if it corresponds to the CURRENT ref file.
s["engine_fallback"] = bool(
s["has_voice_ref"]
and engine_meta.get("engine") == "edge"
and engine_meta.get("ref_mtime") == ref_mtime
)
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)
def _import_speaker_record(
config: Any,
*,
name: str,
aliases: list[str] | None = None,
gender: str | None = None,
accent: str | None = None,
description: str = "",
voice_description: str = "",
vocal_traits: list[str] | None = None,
distortion_effects: list[str] | None = None,
is_narrator: bool = False,
is_group: bool = False,
ref_path: str | None = None,
) -> dict:
"""Import a global library/registry voice into the active project."""
speakers_path = config.data_dir / "speakers.json"
if not speakers_path.exists():
return {"status": "error", "reason": "no speakers.json for active project"}
name = (name or "").strip()
if not name:
return {"status": "error", "reason": "empty character name"}
with open(speakers_path) as f:
data = json.load(f)
speakers = data.setdefault("speakers", [])
if any(s.get("primary_name", "").lower() == name.lower() for s in speakers):
return {"status": "already", "name": name}
voices_dir = config.data_dir / "voices"
voices_dir.mkdir(parents=True, exist_ok=True)
dest_name = "narrator_ref.wav" if is_narrator else name.lower().replace(" ", "_") + "_ref.wav"
copied = None
if ref_path:
src = Path(ref_path)
if not src.is_absolute():
# Legacy registry rows store relative ".umbra/voices/..." paths.
src = next(
(
c / ref_path
for c in (Path.cwd(), config.project_dir, config.project_dir.parent)
if (c / ref_path).exists()
),
src,
)
if src.exists():
try:
shutil.copyfile(str(src), str(voices_dir / dest_name))
copied = True
except Exception:
copied = False
speaker = {
"primary_name": name,
"aliases": aliases or [],
"demographics": {"type": "Human", "gender": gender or "Unknown"},
"vocal_traits": vocal_traits or [],
"communication_style": {"primary_register": "", "style_shifts_or_notes": ""},
"is_narrator": bool(is_narrator),
"is_group": bool(is_group),
"description": description or "",
"sample_dialogue": "",
"chapters_appeared": [],
"unidentified": False,
"unidentified_reason": "",
"alias_evidence": [],
"has_voice_ref": bool(copied),
"flagged": False,
"voice_status": "pending",
}
if accent:
speaker["accent"] = accent
if voice_description:
speaker["voice_description"] = voice_description
if distortion_effects:
speaker["distortion_effects"] = distortion_effects
speakers.append(speaker)
speakers.sort(key=lambda s: s.get("primary_name", "").lower())
with open(speakers_path, "w") as f:
json.dump(data, f, indent=2)
return {"status": "added", "name": name, "ref_copied": bool(copied)}
@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):
import asyncio
from umbraaudio.voices.qwen_tts import QwenTTSEngine
speakers = _load_speakers(request)
config = _get_active_config(request)
def _health() -> bool:
if not config.tts.qwen_tts_url:
return False
from umbraaudio.voices.qwen_tts import QwenTTSEngine
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url)
try:
import httpx
r = httpx.get(f"{config.tts.qwen_tts_url}/health", timeout=5)
if r.status_code >= 500:
logger.warning("qwen health HTTP %s", r.status_code)
return False
return True
except Exception as e: # noqa: BLE001
logger.warning("qwen _health failed: %r", e)
return False
qwen_up = await asyncio.to_thread(_health)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"speakers": speakers,
"qwen_up": qwen_up,
"qwen_url": config.tts.qwen_tts_url,
},
)
@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"),
voice_description: str = Form(""),
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 [],
}
if voice_description.strip():
updated_data["voice_description"] = voice_description.strip()
else:
speakers[index].pop("voice_description", None)
# 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.delete("/api/speakers/{index}")
async def delete_speaker(request: Request, index: int, reassign_to: str = ""):
"""Delete a speaker. The UI has offered this since before the endpoint existed.
Deleting a speaker who still has dialogue would leave every one of their
utterances pointing at a name that no longer resolves - the orphaned-
utterance condition this project already has a review page for. So a
speaker with dialogue is refused unless the caller says where that
dialogue should go; ``reassign_to`` re-points it, exactly as a merge does.
A speaker with no dialogue is simply removed.
"""
speakers = _load_speakers(request)
if index < 0 or index >= len(speakers):
return JSONResponse({"error": "invalid index"}, status_code=400)
speaker = speakers[index]
name = speaker.get("primary_name", "")
old_names = [n for n in ([name] + speaker.get("aliases", [])) if n]
db = _get_db(request)
placeholders = ",".join("?" for _ in old_names) or "''"
count = db.execute(
f"SELECT COUNT(*) FROM utterances WHERE speaker_name IN ({placeholders})",
old_names,
).fetchone()[0]
if count and not reassign_to:
others = [
s.get("primary_name", "")
for i, s in enumerate(speakers)
if i != index and s.get("primary_name")
]
return JSONResponse(
{
"error": (
f"{name} has {count} utterance(s). Choose which speaker "
"should speak them instead."
),
"utterance_count": count,
"speaker_name": name,
"options": others,
},
status_code=409,
)
if reassign_to:
target = next(
(
s
for s in speakers
if s.get("primary_name", "").strip().casefold()
== reassign_to.strip().casefold()
),
None,
)
if target is None:
return JSONResponse(
{"error": f"no speaker named {reassign_to!r} to reassign to"},
status_code=400,
)
target_name = target.get("primary_name", "")
if name and name not in target.get("aliases", []):
target.setdefault("aliases", []).append(name)
moved = 0
for old_name in old_names:
cur = db.execute(
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
(target_name, old_name),
)
moved += cur.rowcount or 0
db.commit()
else:
target_name = ""
moved = 0
# Remove this speaker's own reference audio, but never a shared one.
removed_audio: list[str] = []
config = _get_active_config(request)
voices_dir = config.data_dir / "voices"
slug = name.strip().lower().replace(" ", "_")
if slug and slug not in ("narrator", "general"):
for candidate in (slug + "_ref.wav", slug + ".wav"):
ref = voices_dir / candidate
if ref.exists():
try:
ref.unlink()
removed_audio.append(candidate)
except OSError as e: # never fail the delete over a stale file
logger.warning("Could not remove %s: %s", ref, e)
del speakers[index]
_save_speakers(request, speakers)
# Drop the approval entry too. Leaving a stale "approved" behind would let a
# speaker re-created later inherit an approval nobody gave for it.
if name:
ledger = config.data_dir / "voice_status.json"
try:
if ledger.exists():
data = json.loads(ledger.read_text())
if isinstance(data, dict) and data.pop(name, None) is not None:
ledger.write_text(json.dumps(data, indent=2))
except (OSError, ValueError) as e:
logger.warning("Could not update voice_status ledger: %s", e)
speakers = _load_speakers(request)
return templates.TemplateResponse(
request,
"_speakers_list.html",
{
"request": request,
"speakers": speakers,
"deleted": name,
"reassigned_to": target_name,
"utterances_moved": moved,
"removed_audio": removed_audio,
},
)
@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/{slug}/{chapter_id}/{filename}")
async def serve_audio(request: Request, slug: str, chapter_id: int, filename: str):
config = _get_active_config(request)
if config.project_dir.name != slug:
return JSONResponse({"error": "project mismatch"}, status_code=403)
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",
headers={"Cache-Control": "no-store"},
)
@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, lang: str | None = None):
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}",
}
)
languages = _languages_for_template()
active_language = lang if lang in dict(languages) else _active_language_for_template(request)
return templates.TemplateResponse(
request,
"chapters_audio.html",
{
"request": request,
"chapters": chapters,
"total_chapters": len(chapters),
"languages": languages,
"active_language": active_language,
"active_language_name": dict(languages).get(active_language, active_language),
},
)
ACTIVE_LANGUAGE_COOKIE = "active_language"
def _active_language_for_template(request: Request) -> str:
"""Resolve the active content language from the cookie.
Defaults to English, which is the source language of every project that
exists today. An unsupported cookie value is ignored rather than honoured:
the registry is the authority on what can be shown.
"""
from umbraaudio.web.language_routes import SUPPORTED
cookie_lang = (request.cookies.get(ACTIVE_LANGUAGE_COOKIE) or "").strip()
supported_codes = {code for code, _ in SUPPORTED}
return cookie_lang if cookie_lang in supported_codes else "en"
def _languages_for_template() -> list[tuple[str, str]]:
"""Return the supported languages for the selector dropdown."""
from umbraaudio.web.language_routes import SUPPORTED
return SUPPORTED
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | None = None):
config = _get_active_config(request)
# Handle language selection: read from query param, set cookie, fall back to cookie
supported_codes = {code for code, _ in _languages_for_template()}
if lang and lang in supported_codes:
active_language = lang
# Will set cookie in response
else:
active_language = _active_language_for_template(request)
languages = _languages_for_template()
languages_json = [{"code": code, "name": name} for code, name in languages]
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
_ensure_utterance_columns(db)
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, u.status, u.type,
u.qa_accepted, u.qa_accepted_sig
FROM utterances u
WHERE u.chapter_id = ?
ORDER BY u.utterance_index
""",
(ch_id,),
).fetchall()
# Flag utterance rows with no text as requiring review so they surface
# in the UI instead of silently corrupting the audiobook. Their spoken
# audio is skipped at build time, but any stray wav on disk must not be
# stitched in.
blank_ids = [u["id"] for u in utterances if not (u["text"] or "").strip()]
if blank_ids:
db.execute(
"UPDATE utterances SET status = 'review' WHERE id IN (%s)"
% ",".join("?" * len(blank_ids)),
blank_ids,
)
db.commit()
alias_map = _load_speaker_alias_map(config)
issues_by_index = _qa_issues_for_chapter(db, ch_id, alias_map) if ch_id else {}
# Fetch translations for the active language (if not English).
#
# Two stores hold translations: the pipeline's ``utterance_translations``
# table and the on-disk sample files under ``samples/translations``. The
# resolver merges them, keyed by (chapter_id, utterance_index), so a
# language with a valid on-disk translation renders it instead of English.
translations_map = {}
if ch_id and active_language != "en":
from umbraaudio.translations import resolved_translations
translations_map = resolved_translations(db, config.project_dir, active_language)
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)
alias_map = _load_speaker_alias_map(config)
audio_files = []
for u in utterances:
idx = u["utterance_index"]
speaker = (u["speaker_name"] or "Narrator").strip()
speaker = alias_map.get(speaker.lower(), speaker)
source_text = u["text"] or ""
wav = existing_wav.get(idx)
voice = assignments.get(speaker, "unknown")
is_blank = not source_text.strip()
qa_issues = issues_by_index.get(idx, [])
# Get translated text if available for active language
translated = translations_map.get((ch_id, idx))
has_translation = translated is not None and translated.text.strip()
display_text = translated.text if has_translation else source_text
translation_status = translated.status if translated else None
audio_files.append(
{
"filename": wav,
"index": idx,
"utt_id": u["id"],
"speaker": speaker,
"text": display_text[:100] + "..." if len(display_text) > 100 else display_text,
"full_text": source_text,
"display_text": display_text,
"source_text": source_text,
"translated_text": translated.text if has_translation else None,
"translation_status": translation_status,
"has_translation": has_translation,
"voice": voice,
"emotion": u["emotion"] or "neutral",
"pace": u["pace"] or "moderate",
"instruct": u["instruct"] or "",
"is_blank": is_blank,
"qa_issues": qa_issues,
"qa_accepted": _qa_accepted_for(
u["qa_accepted"], u["qa_accepted_sig"], speaker, source_text
),
}
)
last_utt_id = audio_files[-1]["utt_id"] if audio_files else None
blank_count = sum(1 for a in audio_files if a["is_blank"])
chapter_full_path = config.data_dir / "chapter_audio" / f"ch{ch_id}_full.wav"
has_chapter_audio = chapter_full_path.exists()
# The active_language cookie is set by the language middleware in
# web/__init__.py, so the choice survives on every page.
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,
"blank_count": blank_count,
"total_utterances": len(audio_files),
"speaker_names": speaker_names,
"last_utt_id": last_utt_id,
"has_chapter_audio": has_chapter_audio,
"active_language": active_language,
"active_language_name": dict(languages).get(active_language, active_language),
"languages": languages,
"languages_json": languages_json,
},
)
@router.get("/chapter-audio-file/{slug}/{chapter_dir}/{filename}")
async def serve_chapter_audio(request: Request, slug: str, chapter_dir: str, filename: str):
config = _get_active_config(request)
if config.project_dir.name != slug:
return JSONResponse({"error": "project mismatch"}, status_code=403)
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",
headers={"Cache-Control": "no-store"},
)
@router.put("/api/chapter/{chapter_id}/title")
async def update_chapter_title(request: Request, chapter_id: int):
body = await request.json()
title = (body.get("title") or "").strip()
config = _get_active_config(request)
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.execute(
"UPDATE chapters SET title = ? WHERE id = ?",
(title or None, chapter_id),
)
db.commit()
db.close()
return JSONResponse({"ok": True, "chapter_id": chapter_id, "title": title})
@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 tempfile
from umbraaudio.assembly.ar_export import export_chapter_mp3
mp3_path = Path(tempfile.mktemp(suffix=".mp3"))
try:
export_chapter_mp3(config.data_dir, full_wav, mp3_path)
except Exception as e:
mp3_path.unlink(missing_ok=True)
err_msg = str(e)[:300] if e 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
_ensure_utterance_columns(db)
db.execute(
"UPDATE utterances SET speaker_name = ?, user_edited = 1 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
_ensure_utterance_columns(db)
utt = db.execute(
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
).fetchone()
db.execute(
"UPDATE utterances SET text = ?, status = 'pending', user_edited = 1 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
_ensure_utterance_columns(db)
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 = ?, user_edited = 1 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}/recheck")
async def recheck_utterance_qa(request: Request, utt_id: int):
"""Re-run the mechanical QA check (+auto-fix) for the utterance's chapter."""
db = _get_db(request)
try:
payload = _recheck_utterance_qa(db, _get_active_config(request), utt_id)
finally:
db.close()
if payload.get("error"):
return JSONResponse(payload, status_code=404)
return JSONResponse({"ok": True, **payload})
@router.post("/api/utterance/{utt_id}/qa-accept")
async def accept_utterance_qa(request: Request, utt_id: int):
"""Mark the utterance as human-accepted as-is (reverts on speaker/text edit)."""
db = _get_db(request)
try:
payload = _qa_accept_utterance(db, utt_id)
finally:
db.close()
if payload.get("error"):
return JSONResponse(payload, status_code=404)
return JSONResponse({"ok": True, **payload})
@router.post("/api/utterance/{utt_id}/generate")
async def generate_utterance_audio(request: Request, utt_id: int):
if (blocked := _voice_maintenance_block()) is not None:
return blocked
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, "
"language_code "
"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 ""
# The language the line is written in (spec 31 source axis). NULL on the
# utterance means the project's source language.
from umbraaudio.translations import source_language
line_language = utt["language_code"] or source_language(db)
db.close()
if not text.strip():
return JSONResponse({"error": "utterance text is empty"}, 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 is_scene_break(text):
write_scene_break_audio(output_path)
logger.info("Wrote scene-break silence: %s", output_path)
return JSONResponse(
{
"ok": True,
"utterance_id": utt_id,
"filename": output_path.name,
"speaker": "scene_break",
"emotion": "neutral",
}
)
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 compose_utterance_instruct
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)
proper_names = _load_proper_names(config.data_dir)
spoken_text = _prepare_tts_text(text, phonetic, proper_names)
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, helper_url=config.tts.qwen_tts_helper_url)
tts_instruct = compose_utterance_instruct(instruct, emotion, pace)
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,
language=line_language,
)
else:
engine.generate(
text=spoken_text,
output_path=output_path,
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
language=line_language,
)
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,
voice_mode="embedding" if voice_ref else None,
instruct=tts_instruct or None,
language=line_language,
)
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):
if (blocked := _voice_maintenance_block()) is not None:
return blocked
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, type 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)
proper_names = _load_proper_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, helper_url=config.tts.qwen_tts_helper_url)
for u in remaining:
out = audio_dir / f"utt_{u['utterance_index']:04d}.wav"
if is_scene_break(u["text"], u["type"]):
write_scene_break_audio(out)
continue
if out.exists():
continue
spoken_text = _prepare_tts_text(u["text"], phonetic, proper_names)
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})
def _reindex_chapter_audio(config, chapter_id: int, mapping: dict[int, int]) -> None:
"""Apply an index mapping to a chapter's utterances.
Updates ``utterance_index`` in the DB and renames the ``utt_*.wav``
(and sidecar ``.txt``) files so audio follows the utterance. Renames
are staged through temp names to stay safe under swaps/shifts.
"""
if not mapping:
return
audio_dir = config.data_dir / "chapter_audio" / f"ch{chapter_id}"
staged: list[tuple[Path, int]] = []
if audio_dir.exists():
for file in sorted(audio_dir.glob("utt_*.wav")):
try:
old = int(file.stem.split("_")[1])
except (IndexError, ValueError):
continue
new = mapping.get(old)
if new is not None and new != old:
staged.append((file, new))
tmp_names: list[tuple[Path, int, Path]] = []
for file, new in staged:
tmp = audio_dir / f"._move_{file.stem}.wav.tmp"
file.rename(tmp)
tmp_names.append((tmp, new, file))
for tmp, new, orig in tmp_names:
tmp.rename(audio_dir / f"utt_{new:04d}.wav")
txt = audio_dir / f"{orig.stem}.wav.txt"
if txt.exists():
txt.rename(audio_dir / f"utt_{new:04d}.wav.txt")
db = sqlite3.connect(str(config.data_dir / "project.db"))
db.row_factory = sqlite3.Row
rows = db.execute(
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ?",
(chapter_id,),
).fetchall()
# Update by row id (unique) — updating by index mid-operation would
# cascade once two rows briefly share an index.
for rid, old in rows:
new = mapping.get(old)
if new is not None and new != old:
db.execute("UPDATE utterances SET utterance_index = ? WHERE id = ?", (new, rid))
db.commit()
db.close()
@router.put("/api/utterance/{utt_id}/move")
async def move_utterance(request: Request, utt_id: int):
body = await request.json()
direction = body.get("direction", "up").lower()
if direction not in ("up", "down"):
return JSONResponse({"error": "direction must be up or down"}, 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
row = db.execute("SELECT chapter_id FROM utterances WHERE id = ?", (utt_id,)).fetchone()
if not row:
db.close()
return JSONResponse({"error": "not found"}, status_code=404)
ch_id = row["chapter_id"]
order = db.execute(
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
(ch_id,),
).fetchall()
pos = next((i for i, r in enumerate(order) if r["id"] == utt_id), None)
if pos is None:
db.close()
return JSONResponse({"error": "not found"}, status_code=404)
target = pos - 1 if direction == "up" else pos + 1
if target < 0 or target >= len(order):
db.close()
return JSONResponse({"error": "already at edge"}, status_code=400)
a, b = order[pos], order[target]
mapping = {
a["utterance_index"]: b["utterance_index"],
b["utterance_index"]: a["utterance_index"],
}
db.close()
_reindex_chapter_audio(config, ch_id, mapping)
return JSONResponse({"ok": True})
@router.post("/api/chapter/{chapter_id}/utterance")
async def create_utterance(request: Request, chapter_id: int):
if (blocked := _voice_maintenance_block()) is not None:
return blocked
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
_ensure_utterance_columns(db)
max_idx = db.execute(
"SELECT MAX(utterance_index) as mx FROM utterances WHERE chapter_id = ?",
(chapter_id,),
).fetchone()["mx"]
end_idx = (max_idx or 0) + 1
# Optional position: insert before "index" instead of appending at the end.
target = body.get("index")
if isinstance(target, int) and 0 <= target <= end_idx:
next_idx = target
if target < end_idx:
mapping = {old: old + 1 for old in range(target, end_idx)}
_reindex_chapter_audio(config, chapter_id, mapping)
else:
next_idx = end_idx
db.execute(
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index, user_edited) VALUES (?, ?, ?, ?, 1)",
(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)
proper_names = _load_proper_names(config.data_dir)
spoken_text = _prepare_tts_text(text, phonetic, proper_names)
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, helper_url=config.tts.qwen_tts_helper_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, helper_url=config.tts.qwen_tts_helper_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):
"""Deprecated: use POST /api/voices/queue.
Regeneration moved to a queue so a second speaker can be enqueued while one
is generating, and so the UI can tag each row instead of showing a modal.
This endpoint ran its own thread and refused a second request with a 409,
which is exactly what the queue replaces. Kept as a thin shim so any
bookmarked call keeps working.
"""
if (blocked := _voice_maintenance_block()) is not None:
return blocked
body = await request.json()
name = (body.get("speaker_name") or "").strip()
if not name:
return JSONResponse({"error": "speaker_name required"}, status_code=400)
from umbraaudio.web.voice_queue_routes import get_voice_queue
item = get_voice_queue(request).enqueue(
name, row_index=body.get("row_index")
)
return JSONResponse({
"ok": True,
"speaker_name": name,
"deprecated": True,
"queued": True,
"item": item,
})
@router.post("/api/speakers/generate-all-voices")
async def generate_all_speaker_voices(request: Request):
if (blocked := _voice_maintenance_block()) is not None:
return blocked
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"]
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):
if (blocked := _voice_maintenance_block()) is not None:
return blocked
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/audiobook/export-m4b")
async def export_full_m4b(request: Request):
"""Build the final full-book .m4b from the per-chapter ``chN_full.wav``
files, embedding the publishing metadata from ``m4b_meta.json`` and the
cover art. Runs a pre-export quality check and reports status, warnings,
and errors to the pipeline status so the Step 6 UI can display them."""
import threading
import time as _time
config = _get_active_config(request)
pm = _get_pipeline(request)
gm = _get_gen_manager(request)
if gm.is_active() or pm.is_running:
return JSONResponse({"error": "A generation/pipeline is already running"}, status_code=409)
from umbraaudio.audiobook_meta import load_meta
from umbraaudio.assembly import ChapterQualityChecker
from umbraaudio.assembly.export import AudiobookExporter
from umbraaudio.pipeline_manager import PipelineState, PipelineStep
def _run() -> None:
try:
pm.update(
step=PipelineStep.EXPORT,
state=PipelineState.RUNNING,
progress=5,
detail="Preparing full-book export...",
error="",
warnings=[],
errors=[],
)
# --- Load publishing metadata -------------------------------------
meta = load_meta(config.data_dir)
# --- Gather chapter wavs (chN_full.wav) in book order ------------
audio_dir = config.data_dir / "chapter_audio"
if not audio_dir.exists():
raise RuntimeError("chapter_audio directory missing")
chapter_files = []
for p in audio_dir.glob("ch*_full.wav"):
num = p.stem[len("ch") :].split("_")[0]
if not num.isdigit():
continue
chapter_files.append((int(num), p))
if not chapter_files:
raise RuntimeError("No chN_full.wav chapter files found")
chapter_files = [p for _, p in sorted(chapter_files)]
# --- Chapter titles from DB ---------------------------------------
pm.update(
progress=15, detail=f"Found {len(chapter_files)} chapters; running quality check..."
)
import sqlite3 as _sqlite3
db = _sqlite3.connect(str(config.data_dir / "project.db"))
rows = db.execute("SELECT id, title FROM chapters").fetchall()
db.close()
title_by_id = {r[0]: (r[1] or "") for r in rows}
chapter_titles = {}
for cf in chapter_files:
ch_num = int(cf.stem[len("ch") :].split("_")[0])
chapter_titles[cf.name] = title_by_id.get(ch_num) or f"Chapter {ch_num}"
# --- Quality check: collect errors/warnings -----------------------
speakers_path = config.data_dir / "speakers.json"
known = None
if speakers_path.exists():
import json as _json
with open(speakers_path) as f:
known = [s["primary_name"] for s in _json.load(f).get("speakers", [])]
checker = ChapterQualityChecker(config.data_dir, known)
results = checker.check_all_chapters()
flat = [
(ch, issue.severity, issue.message)
for ch, issues in results.items()
for issue in issues
]
errors = [f"ch{ch}: {msg}" for ch, sev, msg in flat if sev == "error"]
warnings = [f"ch{ch}: {msg}" for ch, sev, msg in flat if sev == "warning"]
if errors:
pm.update(
step=PipelineStep.EXPORT,
state=PipelineState.FAILED,
detail=f"Export blocked by {len(errors)} quality error(s)",
progress=100,
error=f"{len(errors)} quality error(s); fix before export",
warnings=warnings,
errors=errors,
)
return
# --- Cover ----------------------------------------------------------
cover_path = None
raw_cover = meta.get("cover_path", "")
if raw_cover:
p = Path(config.data_dir).parent / raw_cover
if not p.exists():
p = config.data_dir / raw_cover
if p.exists():
cover_path = p
out_name = meta.get("output_name") or "audiobook.m4b"
out_path = config.export_dir / out_name
meta["chapter_titles"] = chapter_titles
meta["chapter_gap_ms"] = 3000
pm.update(progress=40, detail="Assembling and encoding full-book M4B...")
exporter = AudiobookExporter()
exporter.export_full_book_m4b(chapter_files, out_path, meta=meta, cover_path=cover_path)
pm.update(
step=PipelineStep.EXPORT,
state=PipelineState.COMPLETED,
progress=100,
detail=f"Exported {out_name} ({len(chapter_files)} chapters)"
+ (f", {len(warnings)} warning(s)" if warnings else ""),
error="",
warnings=warnings,
errors=errors,
)
except Exception as e:
import traceback
logger.error("M4B export failed: %s\n%s", e, traceback.format_exc())
pm.update(
step=PipelineStep.EXPORT,
state=PipelineState.FAILED,
progress=0,
detail=f"Export failed: {e}",
error=str(e),
)
threading.Thread(target=_run, daemon=True).start()
return JSONResponse({"ok": True, "message": "Full-book M4B export started"})
@router.post("/api/audiobook/export-mp3")
async def export_ar_mp3_package(request: Request):
"""Build the Author's Republic MP3 submission package (sample track, one
track per chapter, closing track) into ``export/ar_package/``.
Mirrors the M4B export UX: runs in a background thread and reports progress
via ``export/ar_build_status.json``, polled by the dashboard. Every track
comes from the chapters' own audio — no narration is synthesized.
"""
import threading
config = _get_active_config(request)
gm = _get_gen_manager(request)
pm = _get_pipeline(request)
if gm.is_active() or pm.is_running:
return JSONResponse({"error": "A generation/pipeline is already running"}, status_code=409)
from umbraaudio.assembly.ar_export import ARPackageExporter, discover_chapters
from umbraaudio.audiobook_meta import load_meta
status_path = config.export_dir / "ar_build_status.json"
config.export_dir.mkdir(parents=True, exist_ok=True)
def _write_status(state: str, detail: str, progress: int = 0, error: str = ""):
payload = {"state": state, "detail": detail, "progress": progress, "error": error}
status_path.write_text(json.dumps(payload))
def _run() -> None:
try:
_write_status("running", "Preparing AR package...", 5)
meta = load_meta(config.data_dir)
missing = [
f for f in ("title", "author", "narrator") if not str(meta.get(f, "")).strip()
]
if missing:
_write_status(
"failed",
f"m4b-meta is missing required fields: {', '.join(missing)}",
0,
error="Fill title/author/narrator on the m4b Meta page first.",
)
return
chapters = discover_chapters(config.data_dir)
with_audio = [c for c in chapters if c.audio_path is not None]
if not with_audio:
raise RuntimeError("No chapter audio found — run audio generation first")
exporter = ARPackageExporter(config.data_dir)
manifest = exporter.build(
chapters,
meta,
progress_cb=lambda p, d: _write_status("running", d, p),
)
warnings = manifest.get("warnings", [])
_write_status(
"completed",
"Built {} tracks ({:.1f} h)".format(
manifest["track_count"], manifest["total_duration_s"] / 3600
)
+ (f", {len(warnings)} warning(s)" if warnings else ""),
100,
)
except Exception as e:
import traceback
logger.error("AR MP3 export failed: %s\n%s", e, traceback.format_exc())
_write_status("failed", f"AR package build failed: {e}", 0, error=str(e))
threading.Thread(target=_run, daemon=True).start()
return JSONResponse({"ok": True, "message": "Author's Republic MP3 package build started"})
@router.get("/api/audiobook/ar-status")
async def ar_package_status(request: Request):
config = _get_active_config(request)
status_path = config.export_dir / "ar_build_status.json"
if status_path.exists():
with contextlib.suppress(Exception):
return JSONResponse(json.loads(status_path.read_text()))
return JSONResponse(
{"state": "idle", "detail": "No AR package build yet", "progress": 0, "error": ""}
)
@router.get("/api/audiobook/ar-files")
async def ar_package_files(request: Request):
config = _get_active_config(request)
manifest_path = config.export_dir / "ar_package.json"
manifest = None
if manifest_path.exists():
with contextlib.suppress(Exception):
manifest = json.loads(manifest_path.read_text())
files = []
pkg = config.export_dir / "ar_package"
if pkg.exists():
for f in sorted(pkg.glob("*.mp3")):
rel = f.relative_to(config.export_dir)
files.append(
{
"name": str(rel),
"url": f"/api/export/download/{rel}",
"size_mb": round(f.stat().st_size / (1024 * 1024), 1),
}
)
return JSONResponse({"manifest": manifest, "files": files})
@router.get("/api/batch/status")
async def batch_status(request: Request):
"""Live status of a background stale-voice rebuild batch.
Follows a ``rebuild_stale_voices_batch*_stdout.log`` under the repo ``logs/``
dir and reports which chapter is being regenerated, utterances generated so
far in it, completed chapters, and last-activity time so the UI can show
that a batch is actively working (not just idle errors).
"""
import os as _os
import re as _re
import time as _time
repo = Path(__file__).resolve().parent.parent.parent
logs_dir = repo / "logs"
payload: dict = {
"active": False,
"log": None,
"state": "idle",
"current_chapter": None,
"generated_current": 0,
"completed_chapters": [],
"attempted_total": 0,
"last_activity_ago": None,
"last_line": None,
"updated_at": _time.time(),
"detail": "No active rebuild batch observed",
}
if not logs_dir.exists():
return JSONResponse(payload)
candidates = sorted(
logs_dir.glob("rebuild_stale_voices_batch*_stdout.log"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not candidates:
return JSONResponse(payload)
log = candidates[0]
payload["log"] = log.name
payload["active"] = True
try:
text = log.read_text(errors="replace")
except Exception as e:
payload["detail"] = f"Could not read {log.name}: {e}"
return JSONResponse(payload)
lines = text.splitlines()
# bookkeeping order
building = None
generated = 0
completed: list[int] = []
for line in lines:
m = _re.search(r"===== Building chapter (\d+) =====", line)
if m:
building = int(m.group(1))
generated = 0
continue
m = _re.search(r"Chapter (\d+) done:", line)
if m:
ch = int(m.group(1))
completed.append(ch)
if building == ch:
building = None
continue
if (
building is not None
and "Generated Qwen3-TTS audio" in line
and _re.search(r"/ch%d/utt_" % building, line)
):
generated += 1
# last activity = age of most recent non-health-poll meaningful write
mtime = log.stat().st_mtime
last_activity_ago = _time.time() - mtime
last_line = lines[-1][:160] if lines else ""
finished = "=== Batch complete ===" in text or building is None and completed and generated == 0
payload.update(
{
"state": "running" if not ("=== Batch complete ===" in text) else "complete",
"current_chapter": building,
"generated_current": generated,
"completed_chapters": completed,
"last_activity_ago": round(last_activity_ago, 1) if last_activity_ago > 0 else 0.0,
"last_line": last_line,
"detail": (
f"Rebuilding chapter {building}"
+ (f" ({generated} utterances regenerated in this chapter)" if building else "")
if not ("=== Batch complete ===" in text)
else "Rebuild batch complete"
),
}
)
return JSONResponse(payload)
@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="",
on_retry=None,
):
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)
def _retry_status(attempt: int):
_write_status(
"discovering",
f"Analyzing chapter {ch_num}/{total} — LLM retry {attempt}/6...",
pct,
)
return original_call(
text,
context=context,
max_chars=max_chars,
known_speakers=known_speakers,
pass_context=pass_context,
on_retry=_retry_status,
)
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,
)
from umbraaudio.analysis.clarity import (
assess_utterance_clarity,
)
try:
alias_map: dict[str, str] = {}
with open(speakers_path) as f:
for s in json.load(f).get("speakers", []):
primary = s.get("primary_name", "")
if primary:
alias_map[primary.lower()] = primary
for a in s.get("aliases", []):
if a:
alias_map[a.lower()] = primary
attributed = assess_utterance_clarity(
attributed, ch_id, alias_map
)
except Exception:
pass
for u in attributed:
db.execute(
"UPDATE utterances SET speaker_name=?, "
"attribution_confidence=?, attribution_reasoning=?, "
"attribution_evidence_kind=?, attribution_tag_name=?, "
"attribution_uncertain=?, needs_review=?, review_reason=? "
"WHERE id=?",
(
u.get("speaker_name", "Narrator"),
u.get("attribution_confidence", 0.5),
u.get("attribution_reasoning", "") or "",
u.get("attribution_evidence_kind"),
u.get("attribution_tag_name"),
1 if u.get("attribution_uncertain") else 0,
1 if u.get("needs_review") else 0,
u.get("review_reason") or "",
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/export/download-package.zip")
async def download_mp3_package(request: Request):
"""Download the whole AR MP3 package as one ZIP.
The per-track MP3 links exist, but a distributor wants every track plus the
manifest in a single artifact. Streamed rather than buffered: this package
is ~628 MB for one book, so building it in memory (or in a temp file) would
be wasteful. zipfile writes sequentially, so a generator works.
"""
import io
import zipfile
from starlette.responses import StreamingResponse
config = _get_active_config(request)
package_dir = config.export_dir / "ar_package"
manifest_path = config.export_dir / "ar_package.json"
if not package_dir.is_dir():
return JSONResponse(
{"error": "No MP3 package found. Build the AR package first."},
status_code=404,
)
tracks = sorted(package_dir.glob("*.mp3"))
if not tracks:
return JSONResponse(
{"error": "MP3 package is empty. Rebuild it first."}, status_code=404
)
# AppConfig has no project_name; the directory name is the project slug.
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", config.project_dir.name).strip("_")
def _iter_zip() -> object:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as zf:
for track in tracks:
zf.write(track, arcname=f"{safe}/{track.name}")
# The manifest travels with the audio so the ZIP describes itself:
# track order, roles, durations and the measured RMS/peak per file.
if manifest_path.is_file():
zf.write(manifest_path, arcname=f"{safe}/ar_package.json")
# Rewind AFTER the ZipFile closes. Inside the block the central
# directory has not been written yet, so seeking there truncated every
# download 1,763 bytes short and produced an unreadable archive.
buffer.seek(0)
while chunk := buffer.read(1 << 18):
yield chunk
return StreamingResponse(
_iter_zip(),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{safe}_mp3_package.zip"'
},
)
@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)
try:
from umbraaudio.analysis.clarity import (
assess_utterance_clarity,
)
alias_map: dict[str, str] = {}
if speakers_path.exists():
with open(speakers_path) as f:
for s in json.load(f).get("speakers", []):
primary = s.get("primary_name", "")
if primary:
alias_map[primary.lower()] = primary
for a in s.get("aliases", []):
if a:
alias_map[a.lower()] = primary
attributed = assess_utterance_clarity(
attributed, ch_id, alias_map
)
except Exception:
pass
for u in attributed:
db2.execute(
"UPDATE utterances SET speaker_name=?, "
"attribution_confidence=?, attribution_reasoning=?, "
"attribution_evidence_kind=?, attribution_tag_name=?, "
"attribution_uncertain=?, needs_review=?, review_reason=? "
"WHERE id=?",
(
u.get("speaker_name", "Narrator"),
u.get("attribution_confidence", 0.5),
u.get("attribution_reasoning", "") or "",
u.get("attribution_evidence_kind"),
u.get("attribution_tag_name"),
1 if u.get("attribution_uncertain") else 0,
1 if u.get("needs_review") else 0,
u.get("review_reason") or "",
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,
},
)
# ==============================================================================
# Pipeline Activity API
# ==============================================================================
def _collect_pipeline_activity(config: Any) -> dict[str, Any]:
"""Aggregate live status of every background task type into one structure.
Returns ``{"idle": bool, "tasks": [ {id, name, state, detail, progress}, ...]}``
where ``state`` is one of "idle", "active", "complete", "error".
"""
import re as _re
from umbraaudio.pipeline_manager import PipelineManager
from umbraaudio.voices.generation_manager import GenerationManager
tasks: list[dict[str, Any]] = []
def _norm_state(active: bool, raw: str) -> str:
if active:
return "active"
if raw in ("complete", "completed", "success"):
return "complete"
if raw in ("error", "failed", "failure"):
return "error"
return "idle"
def _add(name, active, raw_state, detail, progress, task_id=""):
tasks.append(
{
"id": task_id or name.lower().replace(" ", "_"),
"name": name,
"state": _norm_state(active, raw_state),
"detail": detail,
"progress": progress,
}
)
# --- Full Pipeline ---
pm = PipelineManager(config.data_dir)
p = pm.load_status()
p_state = p.state.value
_add(
"Full Pipeline",
p_state in ("running", "queued", "paused"),
p_state,
f"{p.step.value}: {p.detail}" if p.detail else p.step.value,
p.progress,
)
# --- Character Discovery (creating speakers) ---
analysis_status = {"status": "idle"}
analysis_path = config.data_dir / "analysis_status.json"
if analysis_path.exists():
with contextlib.suppress(Exception):
analysis_status = dict(json.loads(analysis_path.read_text()))
a_raw = analysis_status.get("status", "idle")
_add(
"Character Discovery",
a_raw in ("starting", "discovering", "saving", "queued", "running"),
a_raw,
analysis_status.get("detail", ""),
analysis_status.get("progress", 0),
)
# --- Voice / Audio Generation (create speaker refs, chapter audio) ---
gm = GenerationManager(config.data_dir)
g = gm.get_status()
g_active = g.get("active", False)
g_type = g.get("type") or "audio"
_add(
"Voice Generation" if g_type == "voices" else "Audio Generation",
g_active,
"active" if g_active else ("complete" if g.get("completed_at") else "idle"),
g.get("current") or f"{g.get('done', 0)}/{g.get('total', 0)}",
round((g.get("done", 0) / g.get("total", 1)) * 100) if g.get("total") else 0,
)
# --- Chapter Builds ---
chapter_status_dir = config.data_dir / "chapter_audio"
active_chapters: list[dict[str, Any]] = []
if chapter_status_dir.exists():
for sf in sorted(chapter_status_dir.glob("ch*_build_status.json")):
try:
cstatus = dict(json.loads(sf.read_text()))
except Exception:
continue
m = _re.search(r"ch(\d+)_build_status\.json", sf.name)
ch_id = int(m.group(1)) if m else 0
phase = cstatus.get("phase", "idle")
if phase in ("idle", "complete", "error"):
continue
active_chapters.append(
{
"chapter": ch_id,
"phase": phase,
"detail": cstatus.get("detail", ""),
"progress": cstatus.get("progress", 0),
"generated": cstatus.get("generated", 0),
"total": cstatus.get("total_utterances", 0),
}
)
if active_chapters:
_add(
"Chapter Builds",
True,
"active",
"; ".join(f"ch{c['chapter']} {c['phase']} ({c['detail']})" for c in active_chapters),
round(sum(c["progress"] for c in active_chapters) / len(active_chapters)),
)
# --- AR Package Build ---
ar_status = {"state": "idle"}
ar_path = config.export_dir / "ar_build_status.json"
if ar_path.exists():
with contextlib.suppress(Exception):
ar_status = dict(json.loads(ar_path.read_text()))
ar_raw = ar_status.get("state", "idle")
_add(
"AR Package Build",
ar_raw == "running",
ar_raw,
ar_status.get("detail", ""),
ar_status.get("progress", 0),
)
# --- Voice Analysis ---
va_status = load_analysis_status(config.data_dir / "analysis")
va_raw = va_status.get("status", "idle")
_add(
"Voice Analysis",
va_raw == "running",
va_raw,
va_status.get("detail", ""),
va_status.get("progress", 0),
)
# --- Background Rebuild Batch ---
repo = Path(__file__).resolve().parent.parent.parent
logs_dir = repo / "logs"
batch_active = False
batch_detail = "No active rebuild batch"
if logs_dir.exists():
candidates = sorted(
logs_dir.glob("rebuild_stale_voices_batch*_stdout.log"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
log = candidates[0]
try:
text = log.read_text(errors="replace")
except Exception:
text = ""
batch_active = "=== Batch complete ===" not in text
last_line = next((ln for ln in reversed(text.splitlines()) if ln.strip()), "")[:160]
if batch_active and last_line:
batch_detail = f"Rebuild batch ({log.name}): {last_line}"
elif text:
batch_detail = "Rebuild batch complete"
else:
batch_detail = "Rebuild batch (empty log)"
_add(
"Background Rebuild Batch",
batch_active,
"active" if batch_active else "idle",
batch_detail,
0,
)
return {
"idle": all(t["state"] != "active" for t in tasks),
"tasks": tasks,
}
@router.get("/api/pipeline/activity")
async def pipeline_activity(request: Request):
"""One-stop status of every background task (pipeline, discovery, generation,
chapter builds, AR export, voice analysis, rebuild batches)."""
import time as _time
config = _get_active_config(request)
data = _collect_pipeline_activity(config)
data["updated_at"] = _time.strftime("%Y-%m-%dT%H:%M:%S")
return JSONResponse(data)
# ==============================================================================
# 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(
request,
"exception_review.html",
{
"request": request,
"project": {"name": config.project_dir.name},
"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
proj_row = db.execute("SELECT id FROM project ORDER BY id LIMIT 1").fetchone()
proj_id = proj_row["id"] if proj_row else 1
global_speakers = db.execute(
"""
SELECT DISTINCT name FROM characters WHERE project_id = ? AND is_narrator = 0
""",
(proj_id,),
).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()
by_name: dict[str, dict] = {}
for s in speakers:
s["source_kind"] = "lib"
by_name[s.get("primary_name", "").lower()] = s
config = _get_active_config(request)
registry = _get_registry(config)
merged = list(speakers)
if registry:
rows = registry.execute(
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
).fetchall()
registry.close()
for r in rows:
d = dict(r)
key = (d.get("character_name") or "").lower()
if not key or key in by_name:
continue
merged.append(
{
"id": d.get("id"),
"primary_name": d["character_name"],
"aliases": d.get("aliases") or "[]",
"gender": d.get("gender"),
"accent": d.get("accent_marker"),
"vocal_traits": "[]",
"distortion_effects": "[]",
"is_narrator": d.get("is_narrator") or 0,
"is_group": d.get("is_general") or 0,
"description": "",
"source_project": d.get("source_project") or "",
"voice_ref_path": d.get("voice_ref_path"),
"source_kind": "registry",
}
)
merged.sort(
key=lambda s: (
-(s.get("is_narrator") or 0),
(s.get("primary_name") or "").lower(),
)
)
return templates.TemplateResponse(request, "_library_speakers.html", {"speakers": merged})
@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.post("/api/library/speakers/{speaker_id}/import")
async def library_import_to_project(request: Request, speaker_id: int):
"""Import a global library speaker into the active project."""
config = _get_active_config(request)
registry = _get_registry(config)
if not registry:
return JSONResponse({"error": "registry db not found"}, status_code=404)
row = registry.execute("SELECT * FROM library_speakers WHERE id = ?", (speaker_id,)).fetchone()
registry.close()
if not row:
return JSONResponse({"error": "library speaker not found"}, status_code=404)
r = dict(row)
try:
aliases = json.loads(r.get("aliases") or "[]")
except json.JSONDecodeError:
aliases = []
try:
vocal = json.loads(r.get("vocal_traits") or "[]")
except json.JSONDecodeError:
vocal = []
try:
dist = json.loads(r.get("distortion_effects") or "[]")
except json.JSONDecodeError:
dist = []
result = _import_speaker_record(
config,
name=r.get("primary_name") or "",
aliases=aliases,
gender=r.get("gender"),
accent=r.get("accent"),
description=r.get("description") or "",
vocal_traits=vocal,
distortion_effects=dist,
is_narrator=bool(r.get("is_narrator")),
is_group=bool(r.get("is_group")),
ref_path=r.get("voice_ref_path"),
)
return JSONResponse(result)
@router.post("/api/registry/{registry_id}/import")
async def registry_import_to_project(request: Request, registry_id: int):
"""Import a global registry voice into the active project."""
config = _get_active_config(request)
registry = _get_registry(config)
if not registry:
return JSONResponse({"error": "registry db not found"}, status_code=404)
row = registry.execute("SELECT * FROM voice_registry WHERE id = ?", (registry_id,)).fetchone()
registry.close()
if not row:
return JSONResponse({"error": "registry voice not found"}, status_code=404)
r = dict(row)
try:
aliases = json.loads(r.get("aliases") or "[]")
except json.JSONDecodeError:
aliases = []
result = _import_speaker_record(
config,
name=r.get("character_name") or "",
aliases=aliases,
gender=r.get("gender"),
accent=r.get("accent_marker"),
description="",
voice_description=r.get("voice_description") or "",
is_narrator=bool(r.get("is_narrator")),
is_group=bool(r.get("is_general")),
ref_path=r.get("voice_ref_path"),
)
return JSONResponse(result)
@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")
# ---------------------------------------------------------------------------
# m4b Meta
# ---------------------------------------------------------------------------
def _m4b_meta_view(request: Request) -> dict[str, Any]:
from umbraaudio.audiobook_meta import (
META_FIELDS,
completeness,
load_meta,
)
config = _get_active_config(request)
meta = load_meta(config.data_dir)
ready = completeness(meta)
return {
"fields": META_FIELDS,
"meta": meta,
"ready": ready,
"project_name": config.project_dir.name,
}
#: Fixed language order for the sample players, English first.
LANG_SAMPLE_ORDER = ["EN", "ZH", "JA", "KO", "DE", "ES", "FR", "RU", "IT", "PT"]
def _lang_samples_dir(request: Request) -> Path:
"""Where render_lang_test.py writes: ``<project>/Lang_test``.
Deliberately project_dir, not data_dir - .umbra is gitignored state and
Lang_test is render output that lives alongside the rest of the project.
"""
return _get_active_config(request).project_dir / "Lang_test"
def _discover_lang_samples(request: Request) -> list[dict]:
"""Per-language full-sample chapters found on disk, in the fixed order.
Discovered rather than hardcoded so a language that has not been rendered
simply does not appear, instead of showing a player that 404s on click.
"""
import soundfile as sf
root = _lang_samples_dir(request)
out: list[dict] = []
if not root.is_dir():
return out
seen = set()
for code in LANG_SAMPLE_ORDER:
f = root / code / f"_full_sample_{code}.wav"
seen.add(code)
if not f.exists():
continue
try:
info = sf.info(str(f))
dur = round(info.duration, 1)
except Exception:
dur = None
out.append(
{
"code": code,
"path": f,
"duration": dur,
"size_mb": round(f.stat().st_size / 1048576, 1),
}
)
# Anything rendered that is not in the fixed order still gets listed.
for d in sorted(p for p in root.iterdir() if p.is_dir() and p.name not in seen):
f = d / f"_full_sample_{d.name}.wav"
if not f.exists():
continue
out.append(
{
"code": d.name,
"path": f,
"duration": None,
"size_mb": round(f.stat().st_size / 1048576, 1),
}
)
combined = root / "_ALL_LANGUAGES_full_sample.wav"
if combined.exists():
out.append(
{
"code": "ALL",
"path": combined,
"duration": None,
"size_mb": round(combined.stat().st_size / 1048576, 1),
}
)
return out
@router.get("/lang-samples", response_class=HTMLResponse)
async def lang_samples_page(request: Request):
config = _get_active_config(request)
return templates.TemplateResponse(
request,
"lang_samples.html",
{
"request": request,
"samples": _discover_lang_samples(request),
"project_name": config.project_dir.name,
"project_slug": config.project_dir.name,
},
)
@router.get("/lang-samples/audio/{code}")
async def lang_sample_audio(request: Request, code: str):
"""Serve one rendered language chapter.
The filename is built server-side from a validated two-letter code rather
than taken from the URL, so this cannot be walked out of the sample
directory.
"""
if not re.fullmatch(r"[A-Za-z]{2,3}", code):
return JSONResponse({"error": "invalid language code"}, status_code=400)
root = _lang_samples_dir(request)
upper = code.upper()
if upper == "ALL":
candidates = [root / "_ALL_LANGUAGES_full_sample.wav"]
else:
# A directory that does not exist is a miss, not a reason to fall back
# to some other file - otherwise an unrendered language silently plays
# the wrong audio instead of reporting that it has none.
candidates = [root / upper / f"_full_sample_{upper}.wav"]
for path in candidates:
if path.exists() and path.resolve().parent.is_relative_to(root.resolve()):
return FileResponse(str(path), media_type="audio/wav")
return JSONResponse({"error": f"no sample for {code}"}, status_code=404)
@router.get("/m4b-meta", response_class=HTMLResponse)
async def m4b_meta_page(request: Request):
return templates.TemplateResponse(request, "m4b_meta.html", _m4b_meta_view(request))
@router.get("/api/m4b-meta", response_class=JSONResponse)
async def m4b_meta_get(request: Request):
view = _m4b_meta_view(request)
return JSONResponse({"meta": view["meta"], "ready": view["ready"]})
@router.post("/api/m4b-meta", response_class=JSONResponse)
async def m4b_meta_save(request: Request):
from umbraaudio.audiobook_meta import completeness, load_meta, save_meta
config = _get_active_config(request)
body = await request.json()
values = body.get("meta") or body
path = save_meta(config.data_dir, values)
meta = load_meta(config.data_dir)
return JSONResponse({"ok": True, "path": str(path), "ready": completeness(meta)})
@router.post("/api/m4b-meta/import-epub", response_class=JSONResponse)
async def m4b_meta_import_epub(request: Request):
"""Lift publishing metadata + cover from the project's uploaded EPUB and
fill any still-empty m4b-meta fields (existing values are left untouched)."""
from umbraaudio.audiobook_meta import (
completeness,
find_project_epub,
load_meta,
merge_epub_metadata,
)
config = _get_active_config(request)
source_path = ""
try:
conn = sqlite3.connect(str(config.data_dir / "project.db"))
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT source_path FROM project LIMIT 1").fetchone()
if row is not None:
source_path = str(row["source_path"] or "")
conn.close()
except Exception:
pass
epub_path = find_project_epub(config.data_dir, source_path)
if epub_path is None:
return JSONResponse(
{
"error": "No uploaded EPUB found for this project (checked uploads/*.epub and project source_path)."
},
status_code=404,
)
try:
result = merge_epub_metadata(config.data_dir, epub_path)
except Exception as e:
logger.warning("EPUB metadata import failed: %s", e)
return JSONResponse({"error": f"Failed to import EPUB metadata: {e}"}, status_code=500)
if not result.get("ok"):
return JSONResponse({"error": result.get("error", "Import failed.")}, status_code=400)
saved_cover = str(result.get("cover") or "")
return JSONResponse(
{
"ok": True,
"epub": result["epub"],
"filled": result.get("filled", {}),
"cover": saved_cover.rsplit("/", 1)[-1] if saved_cover else "",
"ready": completeness(load_meta(config.data_dir)),
}
)
@router.post("/api/m4b-meta/cover", response_class=JSONResponse)
async def m4b_meta_upload_cover(request: Request):
"""Accept a cover image file and write it into the project's .umbra dir."""
import shutil as _shutil
from umbraaudio.audiobook_meta import load_meta, meta_path, save_meta
config = _get_active_config(request)
meta = load_meta(config.data_dir)
form = await request.form()
cover = form.get("cover")
if cover is None or not getattr(cover, "filename", None):
return JSONResponse({"error": "cover file required"}, status_code=400)
uploads = config.data_dir / "m4b_assets"
uploads.mkdir(parents=True, exist_ok=True)
dest = uploads / Path(str(getattr(cover, "filename"))).name
with open(dest, "wb") as fh:
_shutil.copyfileobj(cover.file, fh)
rel = dest.relative_to(config.project_dir)
meta["cover_path"] = str(rel)
save_meta(config.data_dir, meta)
return JSONResponse({"ok": True, "path": str(rel), "stored": str(dest)})
@router.get("/m4b-assets/{filename}")
async def serve_m4b_asset(request: Request, filename: str):
from umbraaudio.audiobook_meta import load_meta
config = _get_active_config(request)
meta = load_meta(config.data_dir)
cover_field = str(meta.get("cover_path") or "").strip()
if not cover_field:
return JSONResponse({"error": "no cover set"}, status_code=404)
cover_field = cover_field.replace("\\", "/")
if cover_field.rsplit("/", 1)[-1] != filename:
return JSONResponse({"error": "not found"}, status_code=404)
path = config.data_dir / "m4b_assets" / filename
if not path.exists():
return JSONResponse({"error": "not found"}, status_code=404)
import mimetypes
media = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
return FileResponse(str(path), media_type=media)