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