Two groups of changes, committed together as the follow-up to the initial push: 1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub, so a clone actually gets the UmbraAudio branding/integration commit 012e452 rather than vanilla upstream. 2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge, remote_credential_broker (Keychain-backed, secrets read at runtime and generated via read_secret/random_secret — no hardcoded credentials), the enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the discovery/attribution/profiling modules, config, chapter_builder, emotion, pronunciation, qwen_tts and routes. Also adds *.db to .gitignore: SQLite project databases are runtime state and no .db file was tracked before. Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and the committed copy has zero secret-looking values; the broker ships only config.example.json.
6656 lines
234 KiB
Python
6656 lines
234 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, 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 _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
|
|
|
|
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'
|
|
);
|
|
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'
|
|
);
|
|
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",
|
|
):
|
|
name = col.split()[0]
|
|
if name not in cols:
|
|
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
|
|
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):
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
from umbraaudio.config import load_config
|
|
|
|
cfg = load_config(slug)
|
|
if not cfg.data_dir.exists():
|
|
return JSONResponse({"error": f"Project '{slug}' not found"}, status_code=404)
|
|
|
|
response = RedirectResponse(url="/", status_code=303)
|
|
response.set_cookie("active_project", slug, max_age=86400 * 365)
|
|
return response
|
|
|
|
|
|
@router.post("/api/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.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):
|
|
config = _get_active_config(request)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
rows = db.execute("""
|
|
SELECT c.id, c.title, c.chapter_index,
|
|
COUNT(u.id) as utt_count
|
|
FROM chapters c
|
|
LEFT JOIN utterances u ON u.chapter_id = c.id
|
|
GROUP BY c.id
|
|
ORDER BY c.chapter_index
|
|
""").fetchall()
|
|
db.close()
|
|
|
|
chapters = []
|
|
for row in rows:
|
|
ch_id = row["id"]
|
|
chapters.append(
|
|
{
|
|
"id": ch_id,
|
|
"title": row["title"] or f"Chapter {row['chapter_index'] + 1}",
|
|
"chapter_index": row["chapter_index"],
|
|
"audio_count": row["utt_count"],
|
|
"directory": f"ch{ch_id}",
|
|
}
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chapters_audio.html",
|
|
{
|
|
"request": request,
|
|
"chapters": chapters,
|
|
"total_chapters": len(chapters),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
|
|
async def chapter_audio_review(request: Request, chapter_dir: str):
|
|
config = _get_active_config(request)
|
|
audio_base = config.data_dir / "chapter_audio" / chapter_dir
|
|
|
|
assignments_path = config.data_dir / "voice_assignments.json"
|
|
assignments = {}
|
|
if assignments_path.exists():
|
|
with open(assignments_path) as f:
|
|
assignments = json.load(f)
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speakers = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
|
|
try:
|
|
ch_id = int(chapter_dir[2:])
|
|
except (ValueError, IndexError):
|
|
ch_id = 0
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_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 {}
|
|
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)
|
|
text = u["text"] or ""
|
|
wav = existing_wav.get(idx)
|
|
voice = assignments.get(speaker, "unknown")
|
|
is_blank = not text.strip()
|
|
qa_issues = issues_by_index.get(idx, [])
|
|
audio_files.append(
|
|
{
|
|
"filename": wav,
|
|
"index": idx,
|
|
"utt_id": u["id"],
|
|
"speaker": speaker,
|
|
"text": text[:100] + "..." if len(text) > 100 else text,
|
|
"full_text": text,
|
|
"voice": voice,
|
|
"emotion": u["emotion"] or "neutral",
|
|
"pace": u["pace"] or "moderate",
|
|
"instruct": u["instruct"] or "",
|
|
"is_blank": is_blank,
|
|
"qa_issues": qa_issues,
|
|
"qa_accepted": _qa_accepted_for(
|
|
u["qa_accepted"], u["qa_accepted_sig"], speaker, 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()
|
|
|
|
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,
|
|
},
|
|
)
|
|
|
|
|
|
@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):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
utt = db.execute(
|
|
"SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct "
|
|
"FROM utterances WHERE id = ?",
|
|
(utt_id,),
|
|
).fetchone()
|
|
if not utt:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
ch_id = utt["chapter_id"]
|
|
idx = utt["utterance_index"]
|
|
text = utt["text"] or ""
|
|
speaker = (utt["speaker_name"] or "Narrator").strip()
|
|
emotion = utt["emotion"] or "neutral"
|
|
pace = utt["pace"] or "moderate"
|
|
instruct = utt["instruct"] or ""
|
|
|
|
db.close()
|
|
|
|
if not text.strip():
|
|
return JSONResponse({"error": "utterance text is empty"}, status_code=400)
|
|
|
|
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,
|
|
)
|
|
else:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
|
|
)
|
|
if output_path.exists():
|
|
explicit = load_speaker_distortion_effects(config.data_dir / "speakers.json").get(
|
|
speaker, []
|
|
)
|
|
if explicit:
|
|
apply_effects_to_file_by_name(output_path, explicit)
|
|
else:
|
|
traits = load_speaker_traits(config.data_dir / "speakers.json").get(speaker, [])
|
|
if traits:
|
|
apply_effects_to_file(output_path, traits)
|
|
write_marker(
|
|
spoken_text,
|
|
output_path,
|
|
speaker=speaker,
|
|
voice_ref=voice_ref,
|
|
voice_mode="embedding" if voice_ref else None,
|
|
instruct=tts_instruct or None,
|
|
)
|
|
|
|
logger.info("Generated utterance %d audio: %s", utt_id, output_path)
|
|
|
|
except QwenTTSUnavailableError as e:
|
|
logger.error("TTS unavailable for utterance %d: %s", utt_id, e)
|
|
except Exception as e:
|
|
logger.error("Utterance %d generation failed: %s", utt_id, e)
|
|
|
|
threading.Thread(target=_generate, daemon=True).start()
|
|
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"utterance_id": utt_id,
|
|
"filename": f"utt_{idx:04d}.wav",
|
|
"speaker": speaker,
|
|
"emotion": emotion,
|
|
}
|
|
)
|
|
|
|
|
|
@router.delete("/api/utterance/{utt_id}")
|
|
async def delete_utterance(request: Request, utt_id: int):
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
utt = db.execute(
|
|
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
|
|
).fetchone()
|
|
if not utt:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
ch_id = utt["chapter_id"]
|
|
db.execute("DELETE FROM utterances WHERE id = ?", (utt_id,))
|
|
|
|
remaining = db.execute(
|
|
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
for i, row in enumerate(remaining):
|
|
db.execute("UPDATE utterances SET utterance_index = ? WHERE id = ?", (i, row["id"]))
|
|
db.commit()
|
|
db.close()
|
|
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{ch_id}"
|
|
for f in list(audio_dir.glob("utt_*.wav")) + list(audio_dir.glob("utt_*.wav.txt")):
|
|
if f.is_file():
|
|
f.unlink()
|
|
|
|
import sys as _sys
|
|
import threading
|
|
|
|
db2 = sqlite3.connect(str(db_path))
|
|
db2.row_factory = sqlite3.Row
|
|
remaining = db2.execute(
|
|
"SELECT utterance_index, text, 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):
|
|
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):
|
|
import threading
|
|
|
|
body = await request.json()
|
|
name = body.get("speaker_name", "").strip()
|
|
if not name:
|
|
return JSONResponse({"error": "speaker_name required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active():
|
|
return JSONResponse(
|
|
{"error": "Generation already in progress", "active": True}, status_code=409
|
|
)
|
|
|
|
speakers = _load_speakers(request)
|
|
speaker_info = None
|
|
for s in speakers:
|
|
if s["primary_name"] == name:
|
|
speaker_info = s
|
|
break
|
|
if not speaker_info:
|
|
return JSONResponse({"error": "speaker not found"}, status_code=404)
|
|
|
|
def _regenerate():
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
|
|
|
|
try:
|
|
with gm.serialize("voices", 1, f"voice:{name}"):
|
|
designer = VoiceDesigner(config.tts)
|
|
voices_dir = config.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
profile = get_profile_for_speaker(
|
|
name, speaker_info, config.data_dir, force_rebuild=True
|
|
)
|
|
designer.generate_voice_reference(
|
|
character=profile,
|
|
output_dir=voices_dir,
|
|
sample_text="When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.",
|
|
)
|
|
gm.update_item(name, success=True)
|
|
logger.info("Voice generated for %s (%s)", name, profile.energy)
|
|
except Exception as e:
|
|
gm.update_item(name, success=False, error=str(e))
|
|
logger.error("Voice generation failed for %s: %s", name, e)
|
|
|
|
threading.Thread(target=_regenerate, daemon=True).start()
|
|
return JSONResponse({"ok": True, "speaker_name": name})
|
|
|
|
|
|
@router.post("/api/speakers/generate-all-voices")
|
|
async def generate_all_speaker_voices(request: Request):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active():
|
|
return JSONResponse(
|
|
{"error": "Generation already in progress", "active": True}, status_code=409
|
|
)
|
|
|
|
def _generate_all():
|
|
import json as _json
|
|
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
logger.error("No speakers.json found")
|
|
return
|
|
with open(speakers_path) as f:
|
|
speakers = _json.load(f).get("speakers", [])
|
|
|
|
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
|
|
|
|
designer = VoiceDesigner(config.tts)
|
|
total = len(speakers)
|
|
|
|
with gm.serialize("voices", total, "voice:bulk"):
|
|
generated = 0
|
|
for i, s in enumerate(speakers):
|
|
name = s["primary_name"]
|
|
profile = get_profile_for_speaker(name, s, config.data_dir, force_rebuild=True)
|
|
|
|
logger.info("[%d/%d] Generating voice for %s", generated + 1, total, name)
|
|
try:
|
|
designer.generate_voice_reference(
|
|
character=profile,
|
|
output_dir=voices_dir,
|
|
sample_text="When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.",
|
|
)
|
|
generated += 1
|
|
gm.update_item(name, success=True)
|
|
except Exception as e:
|
|
gm.update_item(name, success=False, error=str(e))
|
|
logger.error(" FAILED %s: %s", name, e)
|
|
|
|
logger.info("Generated %d/%d voice references", generated, total)
|
|
|
|
threading.Thread(target=_generate_all, daemon=True).start()
|
|
return JSONResponse({"ok": True, "detail": "Voice generation started in background"})
|
|
|
|
|
|
@router.post("/api/generate-audiobook")
|
|
async def generate_full_audiobook(request: Request):
|
|
import threading
|
|
import time as _time
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active():
|
|
return JSONResponse(
|
|
{"error": "Generation already in progress", "active": True}, status_code=409
|
|
)
|
|
|
|
def _write_status(
|
|
step,
|
|
state,
|
|
detail="",
|
|
progress=0,
|
|
error="",
|
|
total_chapters=0,
|
|
chapters_completed=None,
|
|
current_chapter=0,
|
|
completed_utterances=0,
|
|
total_utterances=0,
|
|
):
|
|
payload = {
|
|
"step": step,
|
|
"state": state,
|
|
"detail": detail,
|
|
"progress": progress,
|
|
"error": error,
|
|
"total_chapters": total_chapters,
|
|
"chapters_completed": chapters_completed or [],
|
|
"current_chapter": current_chapter,
|
|
"completed_utterances": completed_utterances,
|
|
"total_utterances": total_utterances,
|
|
}
|
|
status_path = config.data_dir / "pipeline_status.json"
|
|
with open(status_path, "w") as f:
|
|
json.dump(payload, f)
|
|
|
|
_last_write = {"time": 0.0}
|
|
|
|
def _status_callback(
|
|
step,
|
|
state,
|
|
detail,
|
|
progress,
|
|
total_chapters=0,
|
|
chapters_completed=None,
|
|
current_chapter=0,
|
|
completed_utterances=0,
|
|
total_utterances=0,
|
|
):
|
|
now = _time.time()
|
|
if state == "running" and now - _last_write["time"] < 5.0:
|
|
return
|
|
_last_write["time"] = now
|
|
_write_status(
|
|
step,
|
|
state,
|
|
detail,
|
|
progress,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=chapters_completed or [],
|
|
current_chapter=current_chapter,
|
|
completed_utterances=completed_utterances,
|
|
total_utterances=total_utterances,
|
|
)
|
|
|
|
def _generate():
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from scripts.generate_all_audio import main as gen_main
|
|
|
|
try:
|
|
_write_status("generate", "running", "Starting audio generation...", 0)
|
|
gen_main(status_callback=_status_callback, gen_manager=gm)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("Audio generation failed: %s\n%s", e, traceback.format_exc())
|
|
_write_status("generate", "failed", str(e), 0, error=str(e))
|
|
|
|
threading.Thread(target=_generate, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Full audiobook generation started"})
|
|
|
|
|
|
@router.post("/api/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/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,
|
|
}
|
|
|
|
|
|
@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)
|