A delivered gap is not a missing translation. The delivery arrives and says this target language does not voice this utterance, with a reason - the translation renders a different paragraph, say. That is a different problem with a different fix, and the reviewer needs to be able to tell them apart. The reason was being thrown away. The row is stored with an empty text and a review_reason; the resolver skipped every row with empty text, so the gap was dropped and the review lane said "Chinese has not been translated yet". That is a false statement about a translation that was delivered and rejected. So the resolver now keeps a not-voiced row as a gap with its reason, the chapter page renders it as a gap rather than as missing text, and the language page lists the delivered gaps under "Needs attention" with their reasons. A gap row is also no longer counted as translated text, so the coverage the reviewer sees is the coverage the book has. 4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures). Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any target-language audio lane is whether Qwen3-TTS can speak Chinese with an English voice reference. The probe answers it by transcription rather than by listening - Whisper transcribes each clip and reports what language it heard, and the English-reference clip reading Chinese is compared against the same clip reading Chinese with English phonology, so the only variable is the language parameter.
7163 lines
253 KiB
Python
7163 lines
253 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from fastapi import APIRouter, Form, Query, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from umbraaudio.analysis import VALID_PITCH_REGISTERS
|
|
from umbraaudio.analysis.voice_analysis import load_analysis_status
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
|
|
|
|
|
class NoActiveProjectError(Exception):
|
|
"""Raised when a project-scoped endpoint is hit without an active project.
|
|
|
|
The active project is a per-browser cookie, so two different systems can
|
|
each edit their own project independently. There is deliberately no
|
|
fallback to app-level config or to a discovered project: a request either
|
|
names its project explicitly or gets redirected to the project picker
|
|
(HTML) / a client error (API).
|
|
"""
|
|
|
|
|
|
def _active_project_for_template(request: Request) -> dict:
|
|
"""Resolve the project currently selected in the UI for the header."""
|
|
|
|
from umbraaudio.config import list_projects
|
|
|
|
cookie_slug = request.cookies.get("active_project")
|
|
name = "No project"
|
|
for p in list_projects():
|
|
if p["slug"] == cookie_slug:
|
|
name = p["name"] or cookie_slug
|
|
break
|
|
if cookie_slug and name == "No project":
|
|
name = cookie_slug
|
|
return {"slug": cookie_slug, "name": name}
|
|
|
|
|
|
templates.env.globals["active_project"] = _active_project_for_template
|
|
|
|
|
|
def _list_projects_for_template() -> list[dict]:
|
|
"""Every project, so the speakers page can offer a switcher.
|
|
|
|
The active project is a per-browser cookie; without a visible switcher a
|
|
reviewer comparing two browsers had no way to tell they were looking at
|
|
two different books.
|
|
"""
|
|
|
|
from umbraaudio.config import list_projects
|
|
|
|
try:
|
|
return list_projects()
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
templates.env.globals["list_projects"] = _list_projects_for_template
|
|
|
|
|
|
def _voice_maintenance_for_template() -> dict | None:
|
|
"""Expose the active voice-maintenance marker (or None) to templates."""
|
|
|
|
from umbraaudio.voice_maintenance import active_voice_maintenance
|
|
|
|
return active_voice_maintenance()
|
|
|
|
|
|
templates.env.globals["voice_maintenance"] = _voice_maintenance_for_template
|
|
|
|
|
|
def _voice_maintenance_block() -> JSONResponse | None:
|
|
"""Return a 409 response while voice generation must not run, else None.
|
|
|
|
The banner already warns generation "will fall back to Edge-TTS", but a
|
|
warning is not a lock: without this guard, clicking Generate during an LLM
|
|
attribution run silently produces Edge-TTS audio (retries + backoff per
|
|
speaker, then a degraded ref written to disk). For a quality-first pipeline
|
|
refusing is better than degrading, so generation is hard-blocked.
|
|
|
|
Deliberately NOT applied to read-only or approval paths: the speakers page
|
|
health check and set_voice_status (which captures a vmatrix from an existing
|
|
_ref.wav) must keep working so the reviewer can still audit and approve
|
|
voices while analysis is paused.
|
|
|
|
The marker is written and cleared by the analysis scripts in a ``finally:``
|
|
block, so it cannot survive a crashed run and strand generation permanently.
|
|
"""
|
|
from umbraaudio.voice_maintenance import active_voice_maintenance
|
|
|
|
marker = active_voice_maintenance()
|
|
if not marker:
|
|
return None
|
|
return JSONResponse(
|
|
{
|
|
"error": "Voice generation is paused while a CPU/GPU-heavy analysis is running.",
|
|
"reason": marker.get("reason", "analysis in progress"),
|
|
"detail": (
|
|
"Qwen3-TTS cannot run alongside the LLM on this machine, so generating "
|
|
"now would silently fall back to Edge-TTS and lose raspy/texture. "
|
|
"Wait for the analysis to finish, then generate."
|
|
),
|
|
"blocked_by": "voice_maintenance",
|
|
},
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
from umbraaudio.generation.audio_effects import (
|
|
apply_effects_to_file_by_name,
|
|
load_speaker_distortion_effects,
|
|
)
|
|
from umbraaudio.generation.scene_break import (
|
|
is_scene_break,
|
|
write_scene_break_audio,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
PRONUNCIATION_STATUS_FILE,
|
|
generate_pronunciation_audio,
|
|
lookup_pronunciation,
|
|
pronunciation_audio_path,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
load_phonetic_names as _load_phonetic_names,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
load_proper_names as _load_proper_names,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
prepare_tts_text as _prepare_tts_text,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
load_records as _load_pronunciation_records,
|
|
)
|
|
from umbraaudio.pronunciation import (
|
|
save_records as _save_pronunciation_records,
|
|
)
|
|
from umbraaudio.analysis.attribution_qa import (
|
|
check_attribution as _qa_check_attribution,
|
|
fix_high_confidence as _qa_fix_high_confidence,
|
|
verify_conversation_flow as _qa_verify_conversation_flow,
|
|
)
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS project (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
source_path TEXT NOT NULL,
|
|
source_format TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
status TEXT NOT NULL DEFAULT 'initialized',
|
|
source_language TEXT NOT NULL DEFAULT 'en'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS chapters (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
chapter_index INTEGER NOT NULL,
|
|
title TEXT,
|
|
source_text TEXT NOT NULL,
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'parsed',
|
|
audio_path TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS characters (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
name TEXT NOT NULL,
|
|
aliases TEXT NOT NULL DEFAULT '[]',
|
|
gender TEXT,
|
|
age_range TEXT,
|
|
is_narrator INTEGER NOT NULL DEFAULT 0,
|
|
registry_id INTEGER,
|
|
profile_json TEXT,
|
|
voice_description TEXT,
|
|
voice_ref_path TEXT,
|
|
speaker_embedding BLOB,
|
|
pitch_register TEXT DEFAULT 'mid',
|
|
pace_baseline TEXT DEFAULT 'moderate',
|
|
energy TEXT DEFAULT 'measured',
|
|
timbre TEXT DEFAULT 'clear',
|
|
texture TEXT DEFAULT 'smooth',
|
|
formality TEXT DEFAULT 'conversational',
|
|
emotional_baseline TEXT DEFAULT 'neutral_calm',
|
|
distinguishing_trait TEXT,
|
|
accent_marker TEXT DEFAULT 'none',
|
|
status TEXT NOT NULL DEFAULT 'identified',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS voice_review_history (
|
|
id INTEGER PRIMARY KEY,
|
|
character_name TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
prompt_text TEXT,
|
|
resulting_exaggeration REAL,
|
|
resulting_cfg_weight REAL,
|
|
taxonomy_snapshot_json TEXT,
|
|
reviewed_by TEXT,
|
|
reviewed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS pronunciation_guide (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
term TEXT NOT NULL,
|
|
phonetic TEXT NOT NULL,
|
|
notes TEXT,
|
|
UNIQUE(project_id, term)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS utterances (
|
|
id INTEGER PRIMARY KEY,
|
|
chapter_id INTEGER NOT NULL REFERENCES chapters(id),
|
|
character_id INTEGER REFERENCES characters(id),
|
|
utterance_index INTEGER NOT NULL,
|
|
text TEXT NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'narration',
|
|
context_before TEXT,
|
|
context_after TEXT,
|
|
emotion TEXT,
|
|
pace TEXT,
|
|
emphasis_words TEXT,
|
|
pause_before_ms INTEGER,
|
|
pause_after_ms INTEGER,
|
|
instruct TEXT,
|
|
speaker_name TEXT,
|
|
attribution_confidence REAL,
|
|
attribution_reasoning TEXT,
|
|
attribution_evidence_kind TEXT,
|
|
attribution_tag_name TEXT,
|
|
attribution_uncertain INTEGER NOT NULL DEFAULT 0,
|
|
needs_review INTEGER NOT NULL DEFAULT 0,
|
|
review_reason TEXT,
|
|
qa_accepted INTEGER NOT NULL DEFAULT 0,
|
|
qa_accepted_at TEXT,
|
|
qa_accepted_sig TEXT,
|
|
emotion_confidence REAL DEFAULT 1.0,
|
|
user_edited INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
-- spec 31: the language this utterance's text is written in. NULL means the
|
|
-- project's source language.
|
|
language_code TEXT,
|
|
language_origin TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS utterance_translations (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
|
language_code TEXT NOT NULL,
|
|
language_name TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'draft',
|
|
origin TEXT NOT NULL DEFAULT 'umbratrans',
|
|
source_ref TEXT,
|
|
delivered_at TEXT,
|
|
confirmed_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
voiced INTEGER NOT NULL DEFAULT 1,
|
|
review_reason TEXT,
|
|
UNIQUE(utterance_id, language_code)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS generated_audio (
|
|
id INTEGER PRIMARY KEY,
|
|
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
|
character_id INTEGER REFERENCES characters(id),
|
|
audio_path TEXT NOT NULL,
|
|
duration_ms INTEGER,
|
|
mos_score REAL,
|
|
wer_score REAL,
|
|
speaker_similarity REAL,
|
|
artifact_score REAL,
|
|
generation_params TEXT,
|
|
status TEXT NOT NULL DEFAULT 'generated',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS quality_reports (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
chapter_id INTEGER REFERENCES chapters(id),
|
|
report_type TEXT NOT NULL,
|
|
metrics_json TEXT NOT NULL,
|
|
passed INTEGER NOT NULL DEFAULT 0,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS review_decisions (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
entity_type TEXT NOT NULL,
|
|
entity_id INTEGER NOT NULL,
|
|
decision TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS exception_reviews (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
chapter_id INTEGER NOT NULL REFERENCES chapters(id),
|
|
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
|
utterance_index INTEGER NOT NULL,
|
|
current_speaker TEXT,
|
|
attributed_speaker TEXT,
|
|
confidence REAL,
|
|
ambiguity_type TEXT,
|
|
evidence TEXT,
|
|
reasoning TEXT,
|
|
alternatives TEXT,
|
|
text_preview TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
resolved_speaker TEXT,
|
|
resolved_by TEXT,
|
|
resolved_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_exception_reviews_chapter ON exception_reviews(chapter_id);
|
|
CREATE INDEX IF NOT EXISTS idx_exception_reviews_utterance ON exception_reviews(utterance_id);
|
|
CREATE INDEX IF NOT EXISTS idx_exception_reviews_status ON exception_reviews(status);
|
|
CREATE TABLE IF NOT EXISTS name_tokens (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
token TEXT NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'name_tag',
|
|
status TEXT NOT NULL DEFAULT 'unresolved',
|
|
target_speaker TEXT,
|
|
occurrences INTEGER NOT NULL DEFAULT 0,
|
|
chapters_json TEXT NOT NULL DEFAULT '[]',
|
|
evidence_json TEXT NOT NULL DEFAULT '[]',
|
|
suggestion TEXT,
|
|
suggestion_score REAL DEFAULT 0.0,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(project_id, token)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_name_tokens_status ON name_tokens(status);
|
|
CREATE INDEX IF NOT EXISTS idx_name_tokens_project ON name_tokens(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utterances_chapter ON utterances(chapter_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utterances_character ON utterances(character_id);
|
|
CREATE INDEX IF NOT EXISTS idx_generated_audio_utterance ON generated_audio(utterance_id);
|
|
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_characters_project ON characters(project_id);
|
|
"""
|
|
|
|
|
|
def _ensure_utterance_columns(db: sqlite3.Connection) -> None:
|
|
"""Add columns introduced after a project DB was first created (idempotent)."""
|
|
with contextlib.suppress(Exception):
|
|
cols = {r[1] for r in db.execute("PRAGMA table_info(utterances)")}
|
|
for col in (
|
|
"qa_accepted INTEGER NOT NULL DEFAULT 0",
|
|
"qa_accepted_at TEXT",
|
|
"qa_accepted_sig TEXT",
|
|
):
|
|
name = col.split()[0]
|
|
if name not in cols:
|
|
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
|
|
if "user_edited" not in cols:
|
|
db.execute("ALTER TABLE utterances ADD COLUMN user_edited INTEGER NOT NULL DEFAULT 0")
|
|
for col in (
|
|
"attribution_evidence_kind TEXT",
|
|
"attribution_tag_name TEXT",
|
|
"attribution_uncertain INTEGER NOT NULL DEFAULT 0",
|
|
"needs_review INTEGER NOT NULL DEFAULT 0",
|
|
"review_reason TEXT",
|
|
# spec 31: utterance source-language tag
|
|
"language_code TEXT",
|
|
"language_origin TEXT",
|
|
):
|
|
name = col.split()[0]
|
|
if name not in cols:
|
|
db.execute(f"ALTER TABLE utterances ADD COLUMN {col}")
|
|
# spec 31: translations table. Created here as well as in SCHEMA_SQL
|
|
# because an existing project DB is migrated through this path.
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS utterance_translations (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES project(id),
|
|
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
|
language_code TEXT NOT NULL,
|
|
language_name TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'draft',
|
|
origin TEXT NOT NULL DEFAULT 'umbratrans',
|
|
source_ref TEXT,
|
|
delivered_at TEXT,
|
|
confirmed_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
voiced INTEGER NOT NULL DEFAULT 1,
|
|
review_reason TEXT,
|
|
UNIQUE(utterance_id, language_code)
|
|
)
|
|
"""
|
|
)
|
|
db.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)"
|
|
)
|
|
db.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
|
|
"ON utterance_translations(utterance_id)"
|
|
)
|
|
# spec 31 section 13.1: the project's source language. An utterance
|
|
# with no language tag is written in this language.
|
|
project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")}
|
|
if "source_language" not in project_cols:
|
|
db.execute("ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'")
|
|
db.commit()
|
|
|
|
|
|
def _get_db(request: Request):
|
|
config = _get_active_config(request)
|
|
import sqlite3
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
if not db_path.exists():
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
try:
|
|
db.execute("PRAGMA journal_mode=WAL")
|
|
except Exception:
|
|
pass
|
|
db.executescript(SCHEMA_SQL)
|
|
_ensure_utterance_columns(db)
|
|
return db
|
|
|
|
|
|
def _qa_accepted_sig(speaker: str, text: str) -> str:
|
|
"""Fingerprint a speaker+text pair at accept time.
|
|
|
|
Acceptance is only honored while the pair still matches, so a later
|
|
speaker/text edit re-surfaces the utterance's QA box instead of letting a
|
|
stale acceptance silently hide a new issue.
|
|
"""
|
|
return hashlib.sha256(f"{speaker.strip()}\x00{text.strip()}".encode()).hexdigest()
|
|
|
|
|
|
def _qa_accepted_for(
|
|
qa_accepted: int | None, qa_accepted_sig: str | None, speaker: str, text: str
|
|
) -> bool:
|
|
"""True when an accept record exists and its fingerprint still matches."""
|
|
return bool(qa_accepted) and (qa_accepted_sig or "") == _qa_accepted_sig(speaker, text)
|
|
|
|
|
|
def _load_speaker_alias_map(config: Any) -> dict[str, str]:
|
|
"""Lowercase alias -> canonical ``primary_name`` for speaker QA checks."""
|
|
lower_to_canonical: dict[str, str] = {}
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
return lower_to_canonical
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
for name in speakers:
|
|
lower_to_canonical[name.lower()] = name
|
|
for spk in speakers.values():
|
|
for alias in spk.get("aliases", []):
|
|
if alias and alias.lower() not in lower_to_canonical:
|
|
lower_to_canonical[alias.lower()] = spk["primary_name"]
|
|
for spk in speakers.values():
|
|
primary = spk.get("primary_name", "")
|
|
if primary and primary.split()[0].lower() not in lower_to_canonical:
|
|
lower_to_canonical[primary.split()[0].lower()] = primary
|
|
return lower_to_canonical
|
|
|
|
|
|
def _qa_issues_for_chapter(
|
|
db: sqlite3.Connection, ch_id: int, alias_map: dict[str, str]
|
|
) -> dict[int, list[dict[str, Any]]]:
|
|
"""Run the mechanical QA cross-checks for a chapter, keyed by utterance_index."""
|
|
rows = db.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name "
|
|
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
utts = [dict(r) for r in rows]
|
|
result = _qa_check_attribution(utts, ch_id, alias_map)
|
|
result.issues.extend(_qa_verify_conversation_flow(utts, ch_id, alias_map).issues)
|
|
by_index: dict[int, list[dict[str, Any]]] = {}
|
|
for iss in result.issues:
|
|
by_index.setdefault(iss.utterance_index, []).append(
|
|
{
|
|
"type": iss.issue_type,
|
|
"speaker": iss.speaker,
|
|
"expected": iss.expected,
|
|
"confidence": iss.confidence,
|
|
"evidence": iss.evidence,
|
|
"text": iss.text,
|
|
}
|
|
)
|
|
return by_index
|
|
|
|
|
|
def _recheck_utterance_qa(db: sqlite3.Connection, config: Any, utt_id: int) -> dict[str, Any]:
|
|
"""Run the check+fix loop for ``utt_id``'s chapter, then report its issues.
|
|
|
|
High-confidence resolved name-tag mismatches across the chapter are
|
|
auto-fixed (same semantics as ``qa_attribution.py --fix``); the returned
|
|
payload lists which utterances changed and the issues still attached to
|
|
``utt_id``. Returns ``{"error": ...}`` when the utterance is missing.
|
|
"""
|
|
utt = db.execute(
|
|
"SELECT id, chapter_id, utterance_index FROM utterances WHERE id = ?",
|
|
(utt_id,),
|
|
).fetchone()
|
|
if not utt:
|
|
return {"error": "utterance not found"}
|
|
|
|
alias_map = _load_speaker_alias_map(config)
|
|
rows = db.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name "
|
|
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(utt["chapter_id"],),
|
|
).fetchall()
|
|
utts = [dict(r) for r in rows]
|
|
|
|
fixed: list[dict[str, Any]] = []
|
|
patched = _qa_fix_high_confidence(utts, alias_map)
|
|
for orig, new in zip(utts, patched, strict=True):
|
|
if orig.get("speaker_name") != new.get("speaker_name"):
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name = ? WHERE id = ?",
|
|
(new["speaker_name"], orig["id"]),
|
|
)
|
|
fixed.append(
|
|
{
|
|
"utt_id": orig["id"],
|
|
"utt_index": new["utterance_index"],
|
|
"from": orig.get("speaker_name"),
|
|
"to": new["speaker_name"],
|
|
}
|
|
)
|
|
db.commit()
|
|
|
|
result = _qa_check_attribution(patched, utt["chapter_id"], alias_map)
|
|
result.issues.extend(_qa_verify_conversation_flow(patched, utt["chapter_id"], alias_map).issues)
|
|
issues = [
|
|
{
|
|
"type": iss.issue_type,
|
|
"speaker": iss.speaker,
|
|
"expected": iss.expected,
|
|
"confidence": iss.confidence,
|
|
"evidence": iss.evidence,
|
|
"text": iss.text,
|
|
}
|
|
for iss in result.issues
|
|
if iss.utterance_index == utt["utterance_index"]
|
|
]
|
|
return {
|
|
"utterance_id": utt_id,
|
|
"utterance_index": utt["utterance_index"],
|
|
"issues": issues,
|
|
"fixed": fixed,
|
|
"clean": not issues,
|
|
}
|
|
|
|
|
|
def _qa_accept_utterance(db: sqlite3.Connection, utt_id: int) -> dict[str, Any]:
|
|
"""Record that a human accepted the utterance's current speaker+text."""
|
|
utt = db.execute("SELECT speaker_name, text FROM utterances WHERE id = ?", (utt_id,)).fetchone()
|
|
if not utt:
|
|
return {"error": "utterance not found"}
|
|
sig = _qa_accepted_sig(utt["speaker_name"] or "narrator", utt["text"] or "")
|
|
db.execute(
|
|
"UPDATE utterances SET qa_accepted = 1, qa_accepted_at = datetime('now'), "
|
|
"qa_accepted_sig = ? WHERE id = ?",
|
|
(sig, utt_id),
|
|
)
|
|
db.commit()
|
|
return {"utterance_id": utt_id, "accepted": True}
|
|
|
|
|
|
def _get_pipeline(request: Request):
|
|
from umbraaudio.pipeline_manager import PipelineManager
|
|
|
|
config = _get_active_config(request)
|
|
return PipelineManager(config.data_dir)
|
|
|
|
|
|
def _get_gen_manager(request: Request):
|
|
from umbraaudio.voices.generation_manager import GenerationManager
|
|
|
|
config = _get_active_config(request)
|
|
return GenerationManager(config.data_dir)
|
|
|
|
|
|
def _get_active_config(request: Request):
|
|
from umbraaudio.config import load_config
|
|
|
|
active_slug = request.cookies.get("active_project")
|
|
if not active_slug:
|
|
raise NoActiveProjectError("No active project selected.")
|
|
config = load_config(active_slug)
|
|
if not (config.data_dir / "project.db").exists():
|
|
raise NoActiveProjectError(f"Project '{active_slug}' no longer exists.")
|
|
return config
|
|
|
|
|
|
@router.get("/projects", response_class=HTMLResponse)
|
|
async def project_list(request: Request):
|
|
from umbraaudio.config import list_projects
|
|
|
|
projects = list_projects()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"project_list.html",
|
|
{"projects": projects},
|
|
)
|
|
|
|
|
|
@router.get("/projects/new", response_class=HTMLResponse)
|
|
async def project_new_form(request: Request):
|
|
"""Form to create a brand-new, isolated project from an uploaded manuscript."""
|
|
from umbraaudio.config import list_projects
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"project_new.html",
|
|
{"projects": list_projects()},
|
|
)
|
|
|
|
|
|
@router.post("/api/projects/activate/{slug}")
|
|
async def activate_project(
|
|
request: Request,
|
|
slug: str,
|
|
next_url: str = Query("/", alias="next"),
|
|
):
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
from umbraaudio.config import load_config
|
|
|
|
cfg = load_config(slug)
|
|
if not cfg.data_dir.exists():
|
|
return JSONResponse({"error": f"Project '{slug}' not found"}, status_code=404)
|
|
|
|
# Only same-site relative paths, so this cannot be turned into an open
|
|
# redirect. Lets the project switcher stay on the page the reviewer is on
|
|
# instead of dumping them back at the dashboard.
|
|
if not next_url.startswith("/") or next_url.startswith("//"):
|
|
next_url = "/"
|
|
response = RedirectResponse(url=next_url, status_code=303)
|
|
response.set_cookie("active_project", slug, max_age=86400 * 365)
|
|
return response
|
|
|
|
|
|
@router.post("/api/projects")
|
|
async def create_project(request: Request):
|
|
"""Create a brand-new, isolated project from an uploaded manuscript.
|
|
|
|
Unlike ``/api/upload-manuscript`` (which writes into the *active* project's
|
|
existing DB), this bootstraps a fresh project directory + DB so a second
|
|
book never contaminates the current one. On success it activates the new
|
|
project (sets the ``active_project`` cookie) and returns its slug.
|
|
"""
|
|
import shutil
|
|
import tempfile
|
|
|
|
from umbraaudio.project_bootstrap import create_project_from_manuscript_async
|
|
|
|
form = await request.form()
|
|
upload = form.get("file")
|
|
project_name = (form.get("name") or "").strip() or None
|
|
if not upload or not hasattr(upload, "filename"):
|
|
return JSONResponse({"error": "No file uploaded"}, status_code=400)
|
|
|
|
suffix = Path(upload.filename).suffix.lower()
|
|
if suffix not in (".docx", ".epub", ".txt", ".md", ".pdf"):
|
|
return JSONResponse({"error": f"Unsupported format: {suffix}"}, status_code=400)
|
|
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="umbra_newproj_"))
|
|
tmp_path = tmp_dir / upload.filename
|
|
with open(tmp_path, "wb") as f:
|
|
content = await upload.read()
|
|
f.write(content)
|
|
|
|
try:
|
|
result = await create_project_from_manuscript_async(tmp_path, project_name=project_name)
|
|
|
|
# Persist a copy of the manuscript inside the new project dir and point
|
|
# the DB's source_path at it (bootstrap's recorded path was the temp file).
|
|
permanent_path = None
|
|
try:
|
|
permanent = Path(result["data_dir"]) / "uploads"
|
|
permanent.mkdir(exist_ok=True)
|
|
permanent_path = permanent / upload.filename
|
|
shutil.copy2(tmp_path, permanent_path)
|
|
conn = sqlite3.connect(str(Path(result["data_dir"]) / "project.db"))
|
|
conn.execute(
|
|
"UPDATE project SET source_path = ? WHERE id = ?",
|
|
(str(permanent_path), result["project_id"]),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception:
|
|
# The project itself is valid; only source-path persistence failed.
|
|
pass
|
|
|
|
# Lift publishing metadata + cover from an EPUB and auto-fill empty
|
|
# m4b-meta fields so the new book ships with real metadata.
|
|
if suffix == ".epub" and permanent_path is not None and permanent_path.exists():
|
|
try:
|
|
from umbraaudio.audiobook_meta import merge_epub_metadata
|
|
|
|
merged = merge_epub_metadata(Path(result["data_dir"]), permanent_path)
|
|
if merged.get("ok") and merged.get("filled"):
|
|
logger.info(
|
|
"EPUB metadata imported for new project '%s': %s",
|
|
result["slug"],
|
|
", ".join(sorted(merged["filled"])),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("EPUB metadata import failed for new project: %s", e)
|
|
except Exception as e:
|
|
return JSONResponse({"error": f"Failed to create project: {e}"}, status_code=500)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
slug = result["slug"]
|
|
response = JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"slug": slug,
|
|
"name": result["name"],
|
|
"project_id": result["project_id"],
|
|
"chapter_count": result["chapter_count"],
|
|
"message": f"Created and activated project '{slug}' ({result['chapter_count']} chapters)",
|
|
}
|
|
)
|
|
response.set_cookie("active_project", slug, max_age=86400 * 365)
|
|
return response
|
|
|
|
|
|
@router.post("/api/upload-manuscript")
|
|
async def upload_manuscript(request: Request):
|
|
import shutil
|
|
import tempfile
|
|
|
|
from umbraaudio.parsers import check_markers, detect_format, parse_manuscript, strip_accent_tag
|
|
|
|
form = await request.form()
|
|
upload = form.get("file")
|
|
mode = form.get("mode", "new")
|
|
if not upload or not hasattr(upload, "filename"):
|
|
return JSONResponse({"error": "No file uploaded"}, status_code=400)
|
|
|
|
filename = upload.filename
|
|
suffix = Path(filename).suffix.lower()
|
|
if suffix not in (".docx", ".epub", ".txt", ".md", ".pdf"):
|
|
return JSONResponse({"error": f"Unsupported format: {suffix}"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
tmp_dir = Path(tempfile.mkdtemp())
|
|
tmp_path = tmp_dir / filename
|
|
with open(tmp_path, "wb") as f:
|
|
content = await upload.read()
|
|
f.write(content)
|
|
|
|
permanent_dir = config.data_dir / "uploads"
|
|
permanent_dir.mkdir(exist_ok=True)
|
|
permanent_path = permanent_dir / filename
|
|
shutil.copy2(tmp_path, permanent_path)
|
|
|
|
debug_dir = config.data_dir / "last_upload"
|
|
debug_dir.mkdir(exist_ok=True)
|
|
shutil.copy2(tmp_path, debug_dir / filename)
|
|
|
|
try:
|
|
fmt = detect_format(tmp_path)
|
|
chapters = parse_manuscript(tmp_path, fmt)
|
|
missing = check_markers(chapters)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
|
|
if project:
|
|
db.execute(
|
|
"UPDATE project SET name=?, source_path=?, source_format=?, status='parsed' WHERE id=?",
|
|
(filename, str(permanent_path), fmt, project["id"]),
|
|
)
|
|
project_id = project["id"]
|
|
else:
|
|
cur = db.execute(
|
|
"INSERT INTO project (name, source_path, source_format, status) VALUES (?, ?, ?, 'parsed')",
|
|
(filename, str(permanent_path), fmt),
|
|
)
|
|
project_id = cur.lastrowid
|
|
|
|
db.execute(
|
|
"DELETE FROM generated_audio WHERE utterance_id IN (SELECT id FROM utterances WHERE chapter_id IN (SELECT id FROM chapters WHERE project_id=?))",
|
|
(project_id,),
|
|
)
|
|
db.execute(
|
|
"DELETE FROM utterances WHERE chapter_id IN (SELECT id FROM chapters WHERE project_id=?)",
|
|
(project_id,),
|
|
)
|
|
db.execute("DELETE FROM quality_reports WHERE project_id=?", (project_id,))
|
|
db.execute("DELETE FROM review_decisions WHERE project_id=?", (project_id,))
|
|
db.execute("DELETE FROM pronunciation_guide WHERE project_id=?", (project_id,))
|
|
db.execute("DELETE FROM chapters WHERE project_id=?", (project_id,))
|
|
db.execute("DELETE FROM characters WHERE project_id=?", (project_id,))
|
|
|
|
if mode == "new":
|
|
import datetime
|
|
|
|
archive_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
archive_dir = config.data_dir / "archives" / archive_ts
|
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
for fname in (
|
|
"speakers.json",
|
|
"voice_assignments.json",
|
|
"phonetic_names.json",
|
|
"voice_status.json",
|
|
"voice_engines.json",
|
|
"pipeline_status.json",
|
|
"pipeline_checkpoint.json",
|
|
"analysis_status.json",
|
|
"defects.json",
|
|
"scan_results.json",
|
|
):
|
|
src = config.data_dir / fname
|
|
if src.exists():
|
|
shutil.copy2(src, archive_dir / fname)
|
|
for dname in ("voices", "voice_tests", "chapter_audio", "analysis"):
|
|
src = config.data_dir / dname
|
|
if src.exists():
|
|
shutil.copytree(src, archive_dir / dname)
|
|
|
|
always_delete = (
|
|
config.data_dir / "pipeline_status.json",
|
|
config.data_dir / "pipeline_checkpoint.json",
|
|
config.data_dir / "analysis_status.json",
|
|
config.data_dir / "defects.json",
|
|
config.data_dir / "scan_results.json",
|
|
)
|
|
for f in always_delete:
|
|
if f.exists():
|
|
f.unlink()
|
|
|
|
if mode == "new":
|
|
new_delete = (
|
|
config.data_dir / "speakers.json",
|
|
config.data_dir / "voice_assignments.json",
|
|
config.data_dir / "phonetic_names.json",
|
|
config.data_dir / "voice_status.json",
|
|
config.data_dir / "voice_engines.json",
|
|
)
|
|
for f in new_delete:
|
|
if f.exists():
|
|
f.unlink()
|
|
for dname in ("voices", "voice_tests", "chapter_audio", "analysis"):
|
|
dpath = config.data_dir / dname
|
|
if dpath.exists():
|
|
shutil.rmtree(dpath)
|
|
else:
|
|
for dname in ("chapter_audio", "voice_tests", "analysis"):
|
|
dpath = config.data_dir / dname
|
|
if dpath.exists():
|
|
shutil.rmtree(dpath)
|
|
|
|
db.commit()
|
|
from umbraaudio.mapping.segmenter import SENTENCE_END_RE
|
|
|
|
for i, ch in enumerate(chapters):
|
|
text = strip_accent_tag(ch.get("text", ""))
|
|
title = ch.get("title")
|
|
cur = db.execute(
|
|
"INSERT INTO chapters (project_id, chapter_index, title, source_text, word_count) VALUES (?, ?, ?, ?, ?)",
|
|
(project_id, i, title, text, len(text.split())),
|
|
)
|
|
ch_id = cur.lastrowid
|
|
if text.strip():
|
|
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
|
|
utt_idx = 0
|
|
for para in paragraphs:
|
|
has_quote = any(qc in para for qc in ('"', "'", "\u201c", "\u2018"))
|
|
if has_quote:
|
|
parts = re.split(r"(?<=[.!?])\s+", para)
|
|
else:
|
|
parts = SENTENCE_END_RE.split(para)
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
db.execute(
|
|
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index) VALUES (?, 'narrator', ?, ?)",
|
|
(ch_id, part, utt_idx),
|
|
)
|
|
utt_idx += 1
|
|
db.commit()
|
|
|
|
utt_count = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()["cnt"]
|
|
db.close()
|
|
|
|
result = {
|
|
"ok": True,
|
|
"filename": filename,
|
|
"format": fmt,
|
|
"chapters": len(chapters),
|
|
"mode": mode,
|
|
}
|
|
if missing:
|
|
result["missing_markers"] = missing
|
|
result["warning"] = (
|
|
f"Missing marker tags: {', '.join(missing)}. Analysis will continue but may be less accurate."
|
|
)
|
|
|
|
scan_path = config.data_dir / "scan_results.json"
|
|
all_markers = ["[book start]", "[author note]", "[toc]", "[chapter]", "[back matter]"]
|
|
found_markers = [m for m in all_markers if m not in missing]
|
|
with open(scan_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"filename": filename,
|
|
"format": fmt,
|
|
"chapters": len(chapters),
|
|
"utterances": utt_count,
|
|
"found_markers": found_markers,
|
|
"missing_markers": missing,
|
|
"all_markers": all_markers,
|
|
},
|
|
f,
|
|
)
|
|
|
|
pipeline_path = config.data_dir / "pipeline_status.json"
|
|
with open(pipeline_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": "scan",
|
|
"status": "complete",
|
|
"detail": f"Loaded {len(chapters)} chapters",
|
|
"progress": 100,
|
|
},
|
|
f,
|
|
)
|
|
|
|
import threading
|
|
|
|
threading.Thread(target=_run_pronunciation_batch, args=(config,), daemon=True).start()
|
|
|
|
if fmt == "epub" and permanent_path.exists():
|
|
from umbraaudio.audiobook_meta import merge_epub_metadata
|
|
|
|
try:
|
|
merged = merge_epub_metadata(config.data_dir, permanent_path)
|
|
if merged.get("ok"):
|
|
result["m4b_import"] = {
|
|
"filled": sorted(merged.get("filled", {})),
|
|
"cover": bool(merged.get("cover")),
|
|
}
|
|
logger.info(
|
|
"EPUB metadata imported: %s", ", ".join(result["m4b_import"]["filled"])
|
|
)
|
|
else:
|
|
logger.warning("EPUB metadata import skipped: %s", merged.get("error"))
|
|
except Exception as e:
|
|
logger.warning("EPUB metadata import failed: %s", e)
|
|
|
|
return JSONResponse(result)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def _load_scan_results(data_dir: Path) -> dict:
|
|
scan_path = data_dir / "scan_results.json"
|
|
if scan_path.exists():
|
|
with open(scan_path) as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def _run_pronunciation_batch(config: Any) -> None:
|
|
"""Background lookup over all proper nouns.
|
|
|
|
Common-word hits (dictionaryapi) are well understood and auto-confirm;
|
|
Wiktionary hits for proper names are stored as unconfirmed candidates and
|
|
get a generated preview audio so the reviewer can confirm after listening.
|
|
Existing records are left untouched. Progress is mirrored to
|
|
pronunciation_status.json so the UI can poll.
|
|
"""
|
|
status_path = config.data_dir / PRONUNCIATION_STATUS_FILE
|
|
|
|
def _status(status: str, detail: str, done: int = 0, total: int = 0) -> None:
|
|
try:
|
|
status_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(status_path, "w") as f:
|
|
json.dump({"status": status, "detail": detail, "done": done, "total": total}, f)
|
|
except OSError:
|
|
pass
|
|
|
|
try:
|
|
_status("running", "Scanning chapters", 0, 0)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
rows = db.execute("SELECT source_text FROM chapters").fetchall()
|
|
db.close()
|
|
all_text = " ".join(r["source_text"] or "" for r in rows)
|
|
if not all_text.strip():
|
|
_status("complete", "No chapters to scan", 0, 0)
|
|
return
|
|
|
|
proper_nouns = _extract_proper_nouns(all_text)
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
proper_nouns = _proper_name_candidates(
|
|
proper_nouns, all_text, _load_speakers_from_disk(config.data_dir), records
|
|
)
|
|
total = len(proper_nouns)
|
|
looked_up = 0
|
|
skipped = 0
|
|
changed = False
|
|
|
|
for i, entry in enumerate(proper_nouns, start=1):
|
|
name = entry["name"]
|
|
existing = records.get(name)
|
|
if existing and existing.get("source") != "legacy":
|
|
skipped += 1
|
|
else:
|
|
result = lookup_pronunciation(name)
|
|
if result.get("ok"):
|
|
rec = {
|
|
"phonetic": result["simple"],
|
|
"source": result["source"],
|
|
"confirmed": bool(result.get("common")),
|
|
"common": bool(result.get("common")),
|
|
"audio_error": "",
|
|
}
|
|
if not rec["confirmed"]:
|
|
audio = generate_pronunciation_audio(config, name, result["simple"])
|
|
if not audio.get("ok"):
|
|
rec["audio_error"] = audio.get("error")
|
|
records[name] = rec
|
|
changed = True
|
|
looked_up += 1
|
|
else:
|
|
records[name] = {
|
|
"phonetic": "",
|
|
"source": "",
|
|
"confirmed": False,
|
|
"common": False,
|
|
}
|
|
if i % 10 == 0:
|
|
_status("running", f"Looked up {looked_up} names so far", i, total)
|
|
if i % 25 == 0 and changed:
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
|
|
if changed:
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
_status("complete", f"Looked up {looked_up}, skipped {skipped} of {total}", total, total)
|
|
logger.info(
|
|
"Auto-pronunciation: %d looked up, %d skipped, %d total proper nouns",
|
|
looked_up,
|
|
skipped,
|
|
total,
|
|
)
|
|
except Exception:
|
|
logger.exception("Pronunciation batch failed")
|
|
_status("error", "Lookup batch failed")
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
async def index(request: Request):
|
|
config = _get_active_config(request)
|
|
active_slug = request.cookies.get("active_project")
|
|
if not active_slug:
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
return RedirectResponse(url="/projects", status_code=303)
|
|
|
|
db = _get_db(request)
|
|
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
|
|
if not project:
|
|
db.close()
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
return RedirectResponse(url="/projects", status_code=303)
|
|
|
|
chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()
|
|
audio = db.execute("SELECT COUNT(*) as cnt FROM generated_audio").fetchone()
|
|
characters = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM characters WHERE is_narrator = 0"
|
|
).fetchone()
|
|
utterances = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()
|
|
db.close()
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speaker_count = 0
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speaker_count = len(data.get("speakers", []))
|
|
|
|
scene_count = 0
|
|
db2 = _get_db(request)
|
|
rows = db2.execute("SELECT source_text FROM chapters").fetchall()
|
|
db2.close()
|
|
for row in rows:
|
|
text = row["source_text"] or ""
|
|
scene_count += text.count("<<<>>>")
|
|
|
|
pm = _get_pipeline(request)
|
|
pipeline_status = pm.load_status()
|
|
|
|
export_dir = config.export_dir
|
|
export_files = []
|
|
if export_dir.exists():
|
|
for f in sorted(export_dir.rglob("*")):
|
|
if f.is_file():
|
|
rel = f.relative_to(export_dir)
|
|
size_mb = f.stat().st_size / (1024 * 1024)
|
|
export_files.append({"name": str(rel), "size_mb": round(size_mb, 1)})
|
|
m4b_download = next(
|
|
(f["name"] for f in export_files if f["name"].lower().endswith(".m4b")), None
|
|
)
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voice_count = len(list(voices_dir.glob("*_ref.wav"))) if voices_dir.exists() else 0
|
|
|
|
ar_manifest = None
|
|
ar_manifest_path = config.export_dir / "ar_package.json"
|
|
if ar_manifest_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
ar_manifest = json.loads(ar_manifest_path.read_text())
|
|
ar_status = {"state": "idle", "detail": "", "progress": 0, "error": ""}
|
|
ar_status_path = config.export_dir / "ar_build_status.json"
|
|
if ar_status_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
ar_status = json.loads(ar_status_path.read_text())
|
|
|
|
# Fetch model status
|
|
qwen_health = {
|
|
"status": "unreachable",
|
|
"voicedesign_loaded": False,
|
|
"base_loaded": False,
|
|
"cuda_available": False,
|
|
}
|
|
lm_status = {"loaded": False, "model": None, "instance_id": None}
|
|
lm_models = []
|
|
|
|
if config.tts.qwen_tts_url:
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
r = await client.get(f"{config.tts.qwen_tts_url}/health")
|
|
if r.status_code < 500:
|
|
qwen_health = r.json()
|
|
except Exception:
|
|
pass
|
|
|
|
if config.llm.lmstudio_base_url:
|
|
try:
|
|
import httpx
|
|
|
|
base = config.llm.lmstudio_base_url.replace("/v1", "")
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
r = await client.get(f"{base}/api/v1/models")
|
|
if r.status_code < 500:
|
|
data = r.json()
|
|
for model in data.get("models", []):
|
|
lm_models.append(
|
|
{
|
|
"key": model.get("key"),
|
|
"display_name": model.get("display_name"),
|
|
"params": model.get("params_string"),
|
|
"size_gb": round(model.get("size_bytes", 0) / 1e9, 1),
|
|
"loaded": len(model.get("loaded_instances", [])) > 0,
|
|
}
|
|
)
|
|
for inst in model.get("loaded_instances", []):
|
|
lm_status = {
|
|
"loaded": True,
|
|
"model": model.get("key"),
|
|
"instance_id": inst.get("id"),
|
|
"context_length": inst.get("config", {}).get("context_length"),
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"index.html",
|
|
{
|
|
"project": dict(project) if project else {},
|
|
"chapter_count": chapters["cnt"] if chapters else 0,
|
|
"character_count": characters["cnt"] if characters else 0,
|
|
"audio_count": audio["cnt"] if audio else 0,
|
|
"utterance_count": utterances["cnt"] if utterances else 0,
|
|
"scene_count": scene_count,
|
|
"speaker_count": speaker_count,
|
|
"voice_count": voice_count,
|
|
"speakers_json_exists": speakers_path.exists(),
|
|
"scan_results": _load_scan_results(config.data_dir),
|
|
"pipeline_status": pipeline_status,
|
|
"pipeline_activity": _collect_pipeline_activity(config),
|
|
"export_files": export_files,
|
|
"m4b_download": m4b_download,
|
|
"ar_manifest": ar_manifest,
|
|
"ar_status": ar_status,
|
|
"qwen_health": qwen_health,
|
|
"lm_status": lm_status,
|
|
"lm_models": lm_models,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/voices", response_class=HTMLResponse)
|
|
async def voice_review(request: Request):
|
|
db = _get_db(request)
|
|
characters = db.execute("SELECT * FROM characters ORDER BY is_narrator DESC, name").fetchall()
|
|
voices_dir = _get_active_config(request).data_dir / "voices"
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"voice_review.html",
|
|
{
|
|
"characters": [dict(c) for c in characters],
|
|
"voices_dir": str(voices_dir),
|
|
},
|
|
)
|
|
|
|
|
|
def _is_unknown_speaker(s: dict) -> bool:
|
|
traits = s.get("vocal_traits", [])
|
|
gender = s.get("demographics", {}).get("gender", "Unknown")
|
|
desc = s.get("description", "")
|
|
register = s.get("communication_style", {}).get("primary_register", "")
|
|
return len(traits) == 0 and gender == "Unknown" and not desc and register in ("", "Casual")
|
|
|
|
|
|
def _all_speaker_names_and_aliases(speakers: list[dict]) -> set[str]:
|
|
names: set[str] = set()
|
|
for s in speakers:
|
|
names.add(s["primary_name"].lower())
|
|
for alias in s.get("aliases", []):
|
|
names.add(alias.lower())
|
|
return names
|
|
|
|
|
|
def _speaker_matches_name(speaker_name: str, names_set: set[str]) -> bool:
|
|
return speaker_name.lower() in names_set
|
|
|
|
|
|
def _load_speakers_from_disk(data_dir: Path) -> list[dict]:
|
|
"""Load speaker entries without a request object (background threads)."""
|
|
speakers_path = data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
return []
|
|
with open(speakers_path) as f:
|
|
return json.load(f).get("speakers", [])
|
|
|
|
|
|
def _load_speakers(request: Request) -> list[dict]:
|
|
config = _get_active_config(request)
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
return []
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = data.get("speakers", [])
|
|
voices_dir = config.data_dir / "voices"
|
|
|
|
voice_status_path = config.data_dir / "voice_status.json"
|
|
voice_status = {}
|
|
if voice_status_path.exists():
|
|
with open(voice_status_path) as f:
|
|
voice_status = json.load(f)
|
|
|
|
voice_engines_path = config.data_dir / "voice_engines.json"
|
|
voice_engines = {}
|
|
if voice_engines_path.exists():
|
|
try:
|
|
with open(voice_engines_path) as f:
|
|
voice_engines = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
voice_engines = {}
|
|
|
|
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
|
|
|
|
speakers.sort(key=lambda s: s.get("primary_name", "").lower())
|
|
|
|
for s in speakers:
|
|
name = s["primary_name"]
|
|
if s.get("is_narrator"):
|
|
ref_name = "narrator_ref.wav"
|
|
elif name.lower() in GENERAL_SPEAKER_NAMES:
|
|
ref_name = "general_ref.wav"
|
|
else:
|
|
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
|
|
s["has_voice_ref"] = (voices_dir / ref_name).exists()
|
|
s["flagged"] = _is_unknown_speaker(s)
|
|
s["voice_status"] = voice_status.get(name, "pending")
|
|
engine_meta = voice_engines.get(name) or {}
|
|
s["voice_engine"] = engine_meta.get("engine")
|
|
s["voice_engine_reason"] = engine_meta.get("reason", "")
|
|
try:
|
|
ref_mtime = (voices_dir / ref_name).stat().st_mtime
|
|
except OSError:
|
|
ref_mtime = 0
|
|
# Only flag the fallback if it corresponds to the CURRENT ref file.
|
|
s["engine_fallback"] = bool(
|
|
s["has_voice_ref"]
|
|
and engine_meta.get("engine") == "edge"
|
|
and engine_meta.get("ref_mtime") == ref_mtime
|
|
)
|
|
return speakers
|
|
|
|
|
|
def _save_speakers(request: Request, speakers: list[dict]) -> None:
|
|
config = _get_active_config(request)
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
existing = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
existing = json.load(f)
|
|
existing["speakers"] = speakers
|
|
with open(speakers_path, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
|
|
|
|
def _import_speaker_record(
|
|
config: Any,
|
|
*,
|
|
name: str,
|
|
aliases: list[str] | None = None,
|
|
gender: str | None = None,
|
|
accent: str | None = None,
|
|
description: str = "",
|
|
voice_description: str = "",
|
|
vocal_traits: list[str] | None = None,
|
|
distortion_effects: list[str] | None = None,
|
|
is_narrator: bool = False,
|
|
is_group: bool = False,
|
|
ref_path: str | None = None,
|
|
) -> dict:
|
|
"""Import a global library/registry voice into the active project."""
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
return {"status": "error", "reason": "no speakers.json for active project"}
|
|
name = (name or "").strip()
|
|
if not name:
|
|
return {"status": "error", "reason": "empty character name"}
|
|
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = data.setdefault("speakers", [])
|
|
if any(s.get("primary_name", "").lower() == name.lower() for s in speakers):
|
|
return {"status": "already", "name": name}
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
dest_name = "narrator_ref.wav" if is_narrator else name.lower().replace(" ", "_") + "_ref.wav"
|
|
copied = None
|
|
if ref_path:
|
|
src = Path(ref_path)
|
|
if not src.is_absolute():
|
|
# Legacy registry rows store relative ".umbra/voices/..." paths.
|
|
src = next(
|
|
(
|
|
c / ref_path
|
|
for c in (Path.cwd(), config.project_dir, config.project_dir.parent)
|
|
if (c / ref_path).exists()
|
|
),
|
|
src,
|
|
)
|
|
if src.exists():
|
|
try:
|
|
shutil.copyfile(str(src), str(voices_dir / dest_name))
|
|
copied = True
|
|
except Exception:
|
|
copied = False
|
|
|
|
speaker = {
|
|
"primary_name": name,
|
|
"aliases": aliases or [],
|
|
"demographics": {"type": "Human", "gender": gender or "Unknown"},
|
|
"vocal_traits": vocal_traits or [],
|
|
"communication_style": {"primary_register": "", "style_shifts_or_notes": ""},
|
|
"is_narrator": bool(is_narrator),
|
|
"is_group": bool(is_group),
|
|
"description": description or "",
|
|
"sample_dialogue": "",
|
|
"chapters_appeared": [],
|
|
"unidentified": False,
|
|
"unidentified_reason": "",
|
|
"alias_evidence": [],
|
|
"has_voice_ref": bool(copied),
|
|
"flagged": False,
|
|
"voice_status": "pending",
|
|
}
|
|
if accent:
|
|
speaker["accent"] = accent
|
|
if voice_description:
|
|
speaker["voice_description"] = voice_description
|
|
if distortion_effects:
|
|
speaker["distortion_effects"] = distortion_effects
|
|
speakers.append(speaker)
|
|
speakers.sort(key=lambda s: s.get("primary_name", "").lower())
|
|
with open(speakers_path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
return {"status": "added", "name": name, "ref_copied": bool(copied)}
|
|
|
|
|
|
@router.get("/speakers", response_class=HTMLResponse)
|
|
async def speakers_review(request: Request):
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"speakers.html",
|
|
{
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
_COMMON_ENGLISH_WORDS = {
|
|
"the",
|
|
"a",
|
|
"an",
|
|
"and",
|
|
"but",
|
|
"or",
|
|
"for",
|
|
"nor",
|
|
"yet",
|
|
"so",
|
|
"of",
|
|
"in",
|
|
"on",
|
|
"at",
|
|
"to",
|
|
"from",
|
|
"by",
|
|
"with",
|
|
"without",
|
|
"about",
|
|
"after",
|
|
"before",
|
|
"until",
|
|
"while",
|
|
"against",
|
|
"among",
|
|
"between",
|
|
"during",
|
|
"into",
|
|
"through",
|
|
"under",
|
|
"over",
|
|
"out",
|
|
"up",
|
|
"down",
|
|
"off",
|
|
"near",
|
|
"beyond",
|
|
"across",
|
|
"along",
|
|
"behind",
|
|
"below",
|
|
"beneath",
|
|
"beside",
|
|
"besides",
|
|
"inside",
|
|
"outside",
|
|
"upon",
|
|
"within",
|
|
"i",
|
|
"you",
|
|
"he",
|
|
"she",
|
|
"it",
|
|
"we",
|
|
"they",
|
|
"them",
|
|
"his",
|
|
"her",
|
|
"their",
|
|
"our",
|
|
"your",
|
|
"my",
|
|
"me",
|
|
"him",
|
|
"us",
|
|
"its",
|
|
"this",
|
|
"that",
|
|
"these",
|
|
"those",
|
|
"there",
|
|
"then",
|
|
"when",
|
|
"where",
|
|
"here",
|
|
"why",
|
|
"how",
|
|
"what",
|
|
"which",
|
|
"who",
|
|
"whom",
|
|
"whose",
|
|
"not",
|
|
"no",
|
|
"yes",
|
|
"very",
|
|
"too",
|
|
"also",
|
|
"just",
|
|
"only",
|
|
"even",
|
|
"still",
|
|
"again",
|
|
"ever",
|
|
"never",
|
|
"always",
|
|
"often",
|
|
"once",
|
|
"sometimes",
|
|
"really",
|
|
"quite",
|
|
"almost",
|
|
"already",
|
|
"away",
|
|
"instead",
|
|
"however",
|
|
"therefore",
|
|
"although",
|
|
"though",
|
|
"because",
|
|
"anyway",
|
|
"all",
|
|
"some",
|
|
"any",
|
|
"many",
|
|
"much",
|
|
"more",
|
|
"most",
|
|
"few",
|
|
"less",
|
|
"both",
|
|
"each",
|
|
"every",
|
|
"other",
|
|
"another",
|
|
"such",
|
|
"same",
|
|
"own",
|
|
"several",
|
|
"do",
|
|
"does",
|
|
"did",
|
|
"done",
|
|
"doing",
|
|
"have",
|
|
"has",
|
|
"had",
|
|
"having",
|
|
"be",
|
|
"been",
|
|
"being",
|
|
"was",
|
|
"were",
|
|
"is",
|
|
"are",
|
|
"am",
|
|
"will",
|
|
"would",
|
|
"can",
|
|
"could",
|
|
"shall",
|
|
"should",
|
|
"may",
|
|
"might",
|
|
"must",
|
|
"need",
|
|
"get",
|
|
"let",
|
|
"say",
|
|
"see",
|
|
"know",
|
|
"think",
|
|
"want",
|
|
"look",
|
|
"take",
|
|
"go",
|
|
"make",
|
|
"come",
|
|
"tell",
|
|
"ask",
|
|
"give",
|
|
"find",
|
|
"one",
|
|
"two",
|
|
"three",
|
|
"four",
|
|
"five",
|
|
"six",
|
|
"seven",
|
|
"eight",
|
|
"nine",
|
|
"ten",
|
|
"first",
|
|
"last",
|
|
"next",
|
|
"new",
|
|
"old",
|
|
"big",
|
|
"small",
|
|
"high",
|
|
"low",
|
|
"long",
|
|
"short",
|
|
"day",
|
|
"year",
|
|
"week",
|
|
"month",
|
|
"time",
|
|
"thing",
|
|
"way",
|
|
"life",
|
|
"world",
|
|
"home",
|
|
"house",
|
|
"room",
|
|
"place",
|
|
"point",
|
|
"hand",
|
|
"head",
|
|
"eye",
|
|
"face",
|
|
"sound",
|
|
"voice",
|
|
"man",
|
|
"men",
|
|
"woman",
|
|
"women",
|
|
"boy",
|
|
"girl",
|
|
"child",
|
|
"hour",
|
|
"minute",
|
|
"second",
|
|
"morning",
|
|
"afternoon",
|
|
"evening",
|
|
"night",
|
|
"today",
|
|
"yesterday",
|
|
"tomorrow",
|
|
"tonight",
|
|
"everyone",
|
|
"everything",
|
|
"everybody",
|
|
"everywhere",
|
|
"someone",
|
|
"something",
|
|
"somebody",
|
|
"somewhere",
|
|
"anyone",
|
|
"anything",
|
|
"anybody",
|
|
"anywhere",
|
|
"nobody",
|
|
"nothing",
|
|
"nowhere",
|
|
"wow",
|
|
"ahh",
|
|
"cheers",
|
|
"apologies",
|
|
"greetings",
|
|
"congratulations",
|
|
"additionally",
|
|
"moreover",
|
|
"furthermore",
|
|
"meanwhile",
|
|
"australian",
|
|
"canadian",
|
|
"british",
|
|
"european",
|
|
"american",
|
|
"celsius",
|
|
"fahrenheit",
|
|
"nanite",
|
|
"keywords",
|
|
"titles",
|
|
"acknowledgments",
|
|
"acknowledgements",
|
|
"ltd",
|
|
"inc",
|
|
"corp",
|
|
"llc",
|
|
"dail",
|
|
"pwr",
|
|
"goodreads",
|
|
"lucasfilm",
|
|
"gemini",
|
|
}
|
|
|
|
|
|
def _extract_proper_nouns(text: str) -> list[dict]:
|
|
"""Extract single-word proper-noun candidates from capitalized text.
|
|
|
|
Multi-word phrases ("Princess Kainda Murphy") are flattened into their
|
|
component words so each entry is one confirmable name; component words that
|
|
are titles or stopwords ("Princess", "The") are dropped. Occurrence counts
|
|
aggregate across standalone and multi-word usage.
|
|
"""
|
|
titles = {
|
|
"Mr",
|
|
"Mrs",
|
|
"Ms",
|
|
"Dr",
|
|
"Prof",
|
|
"Sir",
|
|
"Dame",
|
|
"Lord",
|
|
"Lady",
|
|
"Captain",
|
|
"Colonel",
|
|
"Major",
|
|
"General",
|
|
"Sergeant",
|
|
"Private",
|
|
"Admiral",
|
|
"Commander",
|
|
"Lieutenant",
|
|
"Corporal",
|
|
"President",
|
|
"Minister",
|
|
"Senator",
|
|
"Governor",
|
|
"Mayor",
|
|
"King",
|
|
"Queen",
|
|
"Prince",
|
|
"Princess",
|
|
"Duke",
|
|
"Duchess",
|
|
"Count",
|
|
"Countess",
|
|
"Baron",
|
|
"Baroness",
|
|
}
|
|
stopwords = {
|
|
"The",
|
|
"And",
|
|
"But",
|
|
"For",
|
|
"Not",
|
|
"You",
|
|
"All",
|
|
"Can",
|
|
"Had",
|
|
"Her",
|
|
"Was",
|
|
"One",
|
|
"Our",
|
|
"Out",
|
|
"Day",
|
|
"Get",
|
|
"Has",
|
|
"Him",
|
|
"His",
|
|
"How",
|
|
"Its",
|
|
"May",
|
|
"New",
|
|
"Now",
|
|
"Old",
|
|
"See",
|
|
"Way",
|
|
"Who",
|
|
"Boy",
|
|
"Did",
|
|
"Let",
|
|
"Put",
|
|
"Say",
|
|
"She",
|
|
"Too",
|
|
"Use",
|
|
"Man",
|
|
"Big",
|
|
"End",
|
|
"Why",
|
|
"Try",
|
|
"Ask",
|
|
"Men",
|
|
"Set",
|
|
"Two",
|
|
"Top",
|
|
"Six",
|
|
"Ten",
|
|
}
|
|
freq: dict[str, int] = {}
|
|
for noun in re.findall(r"\b([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\b", text):
|
|
for word in noun.split():
|
|
if word in titles or word in stopwords or len(word) <= 2:
|
|
continue
|
|
freq[word] = freq.get(word, 0) + 1
|
|
return [
|
|
{"name": word, "count": count} for word, count in sorted(freq.items(), key=lambda x: -x[1])
|
|
]
|
|
|
|
|
|
def _lowercase_words(text: str) -> set[str]:
|
|
"""Tokens that appear literally all-lowercase in the manuscript.
|
|
|
|
Common words ("they", "after") are written in lowercase while proper names
|
|
("Kainda") are not, so their lowercase forms never appear.
|
|
"""
|
|
return {token.lower() for token in re.findall(r"[A-Za-z]+", text) if token == token.lower()}
|
|
|
|
|
|
def _speaker_name_words(speakers: list[dict], lowercase_words: set[str]) -> set[str]:
|
|
"""Component words of speaker names/aliases that read as real names.
|
|
|
|
Words that also appear lowercase in the manuscript ("captain", "the",
|
|
"robot") are generic descriptors rather than name words, so they are
|
|
excluded.
|
|
"""
|
|
out: set[str] = set()
|
|
for speaker in speakers:
|
|
names = [speaker.get("primary_name", "")]
|
|
names.extend(speaker.get("aliases", []))
|
|
for full in names:
|
|
for word in str(full).split():
|
|
wl = word.lower()
|
|
if len(word) <= 2 or wl in _COMMON_ENGLISH_WORDS or wl in lowercase_words:
|
|
continue
|
|
out.add(wl)
|
|
return out
|
|
|
|
|
|
def _mid_sentence_counts(text: str, candidates: set[str]) -> dict[str, int]:
|
|
"""Count each candidate's appearances directly after a lowercase word.
|
|
|
|
Real names routinely appear mid-sentence (after a lowercase word), while
|
|
sentence-initial common words ("Additionally", "Wow") never do.
|
|
"""
|
|
counts: dict[str, int] = {}
|
|
for match in re.finditer(r"[A-Z][a-z]+", text):
|
|
word = match.group(0)
|
|
if word not in candidates:
|
|
continue
|
|
prev = text[max(0, match.start() - 8) : match.start()]
|
|
if re.search(r"[a-z]\s+$", prev):
|
|
counts[word] = counts.get(word, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _proper_name_candidates(
|
|
entries: list[dict], text: str, speakers: list[dict], records: dict[str, Any]
|
|
) -> list[dict]:
|
|
"""Keep single-word candidates that read as names rather than prose.
|
|
|
|
A candidate is dropped when it is a known common word, appears lowercase in
|
|
the manuscript, or only ever appears at the start of a sentence -- unless it
|
|
is part of a speaker's name or already confirmed by the reviewer. Each kept
|
|
entry gains an ``is_speaker`` flag.
|
|
"""
|
|
lowercase_words = _lowercase_words(text)
|
|
speaker_words = _speaker_name_words(speakers, lowercase_words)
|
|
candidates = {entry["name"] for entry in entries}
|
|
mid_counts = _mid_sentence_counts(text, candidates)
|
|
kept = []
|
|
for entry in entries:
|
|
name = entry["name"]
|
|
wl = name.lower()
|
|
rec = records.get(name, {})
|
|
is_speaker = wl in speaker_words
|
|
if wl in _COMMON_ENGLISH_WORDS or rec.get("common"):
|
|
continue
|
|
if wl in lowercase_words and not is_speaker and not rec.get("confirmed"):
|
|
continue
|
|
if mid_counts.get(name, 0) == 0 and not is_speaker and not rec.get("confirmed"):
|
|
continue
|
|
kept.append({**entry, "is_speaker": is_speaker})
|
|
return kept
|
|
|
|
|
|
@router.get("/proper-names", response_class=HTMLResponse)
|
|
@router.get("/character-names", response_class=HTMLResponse)
|
|
async def proper_names_review(request: Request):
|
|
config = _get_active_config(request)
|
|
speakers = _load_speakers(request)
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
|
|
db = _get_db(request)
|
|
rows = db.execute("SELECT source_text FROM chapters").fetchall()
|
|
total_chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()["cnt"]
|
|
db.close()
|
|
all_text = " ".join(r["source_text"] or "" for r in rows)
|
|
|
|
proper_nouns = _proper_name_candidates(
|
|
_extract_proper_nouns(all_text), all_text, speakers, records
|
|
)
|
|
|
|
names_data = []
|
|
for noun_info in proper_nouns:
|
|
name = noun_info["name"]
|
|
count = noun_info["count"]
|
|
is_speaker = noun_info["is_speaker"]
|
|
rec = records.get(name, {})
|
|
names_data.append(
|
|
{
|
|
"name": name,
|
|
"count": count,
|
|
"is_speaker": is_speaker,
|
|
"phonetic": rec.get("phonetic", ""),
|
|
"confirmed": bool(rec.get("confirmed")),
|
|
"common": bool(rec.get("common")),
|
|
"source": rec.get("source", ""),
|
|
"audio_error": rec.get("audio_error", ""),
|
|
"has_audio": pronunciation_audio_path(config.data_dir, name).exists(),
|
|
}
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"proper_names.html",
|
|
{
|
|
"names": names_data,
|
|
"total_chapters": total_chapters,
|
|
},
|
|
)
|
|
|
|
|
|
@router.put("/api/phonetic/{speaker_name}")
|
|
async def update_phonetic_name(request: Request, speaker_name: str):
|
|
"""User edits a spelling: store it as an unconfirmed candidate and regenerate audio."""
|
|
body = await request.json()
|
|
phonetic_text = body.get("phonetic", "").strip()
|
|
config = _get_active_config(request)
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
if not phonetic_text:
|
|
records.pop(speaker_name, None)
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
audio_path = pronunciation_audio_path(config.data_dir, speaker_name)
|
|
if audio_path.exists():
|
|
audio_path.unlink()
|
|
return JSONResponse({"ok": True, "record": None})
|
|
|
|
rec = records.get(speaker_name, {})
|
|
rec["phonetic"] = phonetic_text
|
|
rec["source"] = "user"
|
|
rec["confirmed"] = False
|
|
rec["common"] = bool(rec.get("common"))
|
|
audio = generate_pronunciation_audio(config, speaker_name, phonetic_text)
|
|
rec["audio_error"] = None if audio.get("ok") else audio.get("error")
|
|
records[speaker_name] = rec
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
return JSONResponse({"ok": True, "record": rec, "audio": audio})
|
|
|
|
|
|
@router.get("/api/pronunciation/{name}")
|
|
async def get_pronunciation_lookup(request: Request, name: str):
|
|
result = lookup_pronunciation(name)
|
|
return JSONResponse(result)
|
|
|
|
|
|
@router.post("/api/pronunciations/{name}/lookup")
|
|
async def pronunciation_lookup(request: Request, name: str):
|
|
"""Internet lookup -> candidate phonetic + generated preview audio.
|
|
|
|
Common-word hits (dictionaryapi) are well understood and auto-confirm;
|
|
Wiktionary hits for proper names require the user to confirm after listening.
|
|
"""
|
|
config = _get_active_config(request)
|
|
result = lookup_pronunciation(name)
|
|
if not result.get("ok"):
|
|
return JSONResponse(result)
|
|
|
|
confirmed = bool(result.get("common"))
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
rec = {
|
|
"phonetic": result["simple"],
|
|
"source": result["source"],
|
|
"confirmed": confirmed,
|
|
"common": confirmed,
|
|
}
|
|
audio = {"ok": True, "audio": None}
|
|
if not confirmed:
|
|
audio = generate_pronunciation_audio(config, name, result["simple"])
|
|
rec["audio_error"] = None if audio.get("ok") else audio.get("error")
|
|
records[name] = rec
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
return JSONResponse({"ok": True, "record": rec, "audio": audio})
|
|
|
|
|
|
@router.post("/api/pronunciations/{name}/confirm")
|
|
async def confirm_pronunciation(request: Request, name: str):
|
|
config = _get_active_config(request)
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
rec = records.get(name)
|
|
if not rec or not rec.get("phonetic"):
|
|
return JSONResponse({"ok": False, "error": "No phonetic spelling to confirm"})
|
|
rec["confirmed"] = True
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
return JSONResponse({"ok": True, "record": rec})
|
|
|
|
|
|
@router.post("/api/pronunciations/{name}/clear")
|
|
async def clear_pronunciation(request: Request, name: str):
|
|
config = _get_active_config(request)
|
|
records = _load_pronunciation_records(config.data_dir)
|
|
records.pop(name, None)
|
|
_save_pronunciation_records(config.data_dir, records)
|
|
audio_path = pronunciation_audio_path(config.data_dir, name)
|
|
if audio_path.exists():
|
|
audio_path.unlink()
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.get("/api/pronunciations/audio")
|
|
async def serve_pronunciation_audio(request: Request, name: str):
|
|
config = _get_active_config(request)
|
|
audio_path = pronunciation_audio_path(config.data_dir, name)
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
@router.get("/api/pronunciations/status")
|
|
async def pronunciation_status(request: Request):
|
|
config = _get_active_config(request)
|
|
status_path = config.data_dir / PRONUNCIATION_STATUS_FILE
|
|
if not status_path.exists():
|
|
return JSONResponse({"status": "idle"})
|
|
with open(status_path) as f:
|
|
data = json.load(f)
|
|
return JSONResponse(data)
|
|
|
|
|
|
@router.post("/api/auto-pronunciations")
|
|
async def auto_lookup_pronunciations(request: Request):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
threading.Thread(target=_run_pronunciation_batch, args=(config,), daemon=True).start()
|
|
return JSONResponse({"ok": True, "detail": "Auto-pronunciation lookup started"})
|
|
|
|
|
|
@router.get("/speakers/partial", response_class=HTMLResponse)
|
|
async def speakers_list_partial(request: Request):
|
|
import asyncio
|
|
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
|
|
|
speakers = _load_speakers(request)
|
|
config = _get_active_config(request)
|
|
|
|
def _health() -> bool:
|
|
if not config.tts.qwen_tts_url:
|
|
return False
|
|
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
|
|
|
engine = QwenTTSEngine(
|
|
qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url
|
|
)
|
|
try:
|
|
import httpx
|
|
|
|
r = httpx.get(f"{config.tts.qwen_tts_url}/health", timeout=5)
|
|
if r.status_code >= 500:
|
|
logger.warning("qwen health HTTP %s", r.status_code)
|
|
return False
|
|
return True
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("qwen _health failed: %r", e)
|
|
return False
|
|
|
|
qwen_up = await asyncio.to_thread(_health)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"speakers": speakers,
|
|
"qwen_up": qwen_up,
|
|
"qwen_url": config.tts.qwen_tts_url,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/speakers/partial-row/{index}", response_class=HTMLResponse)
|
|
async def speaker_row_partial(request: Request, index: int):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return HTMLResponse("")
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speaker_row.html",
|
|
{
|
|
"speaker": speakers[index],
|
|
"speaker_idx": index,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers")
|
|
async def add_speaker(
|
|
request: Request,
|
|
primary_name: str = Form(...),
|
|
gender: str = Form("Unknown"),
|
|
speaker_type: str = Form("Human"),
|
|
aliases: str = Form(""),
|
|
vocal_traits: str = Form(""),
|
|
communication_register: str = Form("Casual"),
|
|
style_notes: str = Form(""),
|
|
age: str = Form("Unknown"),
|
|
pitch: str = Form("mid"),
|
|
description: str = Form(""),
|
|
sample_dialogue: str = Form(""),
|
|
is_narrator: bool = Form(False),
|
|
is_group: bool = Form(False),
|
|
accent: str = Form("none"),
|
|
):
|
|
speakers = _load_speakers(request)
|
|
|
|
alias_list = [a.strip() for a in aliases.split(",") if a.strip()]
|
|
trait_list = [t.strip() for t in vocal_traits.split(",") if t.strip()]
|
|
|
|
cleaned_traits, final_notes, _conflict_message, cleaned_age, cleaned_pitch = (
|
|
_analyze_and_clean_speaker_params(
|
|
vocal_traits, communication_register, style_notes, age, pitch
|
|
)
|
|
)
|
|
|
|
new_speaker = {
|
|
"primary_name": primary_name,
|
|
"aliases": alias_list,
|
|
"demographics": {
|
|
"type": speaker_type,
|
|
"gender": gender,
|
|
"age": cleaned_age,
|
|
"pitch": cleaned_pitch,
|
|
},
|
|
"vocal_traits": cleaned_traits,
|
|
"communication_style": {
|
|
"primary_register": communication_register,
|
|
"style_shifts_or_notes": final_notes,
|
|
},
|
|
"is_narrator": is_narrator,
|
|
"is_group": is_group,
|
|
"description": description,
|
|
"sample_dialogue": sample_dialogue,
|
|
"chapters_appeared": [],
|
|
"accent": accent,
|
|
}
|
|
|
|
speakers.append(new_speaker)
|
|
_save_speakers(request, speakers)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
def _analyze_and_clean_speaker_params(
|
|
vocal_traits: str, communication_register: str, style_notes: str, age: str = "", pitch: str = ""
|
|
) -> tuple[list[str], str, str, str, str]:
|
|
"""Cleans and refines vocal traits and notes to remove duplicates and conflicts."""
|
|
|
|
# 1. Clean Traits (Deduplication and trimming)
|
|
trait_set = {t.strip().lower() for t in vocal_traits.split(",") if t.strip()}
|
|
cleaned_traits = sorted(list(trait_set))
|
|
|
|
# 2. Analyze/Refine Style Notes & Register
|
|
processed_notes = []
|
|
conflicts = []
|
|
all_descriptors = set(cleaned_traits)
|
|
|
|
# Conflict check example: Formal vs Casual (This is a simplified AI proxy)
|
|
register_lower = communication_register.lower()
|
|
if any(d in register_lower for d in ["casual", "informal"]) and (
|
|
"formal" in register_lower or "academic" in register_lower
|
|
):
|
|
conflicts.append("Conflict: Casual style conflicts with a formal register setting.")
|
|
|
|
# Deduplicate and normalize style notes
|
|
for note in style_notes.split(","):
|
|
note = note.strip()
|
|
if not note:
|
|
continue
|
|
normalized = note.lower().replace(" ", "").replace("-", "")
|
|
|
|
if normalized not in [n.lower().replace(" ", "").replace("-", "") for n in processed_notes]:
|
|
processed_notes.append(note)
|
|
|
|
final_notes = ", ".join(processed_notes) if processed_notes else ""
|
|
|
|
# 3. Clean Age
|
|
cleaned_age = age.strip()
|
|
valid_ages = {"Child", "Young", "Adult", "Middle-aged", "Elderly", "Unknown"}
|
|
if cleaned_age and cleaned_age not in valid_ages:
|
|
cleaned_age = "Unknown"
|
|
|
|
# Clean Pitch (full taxonomy, not just low/mid/high)
|
|
cleaned_pitch = pitch.strip()
|
|
if cleaned_pitch.lower() not in VALID_PITCH_REGISTERS:
|
|
cleaned_pitch = "mid"
|
|
|
|
return cleaned_traits, final_notes, "; ".join(conflicts), cleaned_age, cleaned_pitch
|
|
|
|
|
|
@router.put("/api/speakers/{index}")
|
|
async def update_speaker(
|
|
request: Request,
|
|
index: int,
|
|
primary_name: str = Form(...),
|
|
gender: str = Form("Unknown"),
|
|
speaker_type: str = Form("Human"),
|
|
aliases: str = Form(""),
|
|
vocal_traits: str = Form(""),
|
|
communication_register: str = Form("Casual"),
|
|
style_notes: str = Form(""),
|
|
age: str = Form(""),
|
|
pitch: str = Form(""),
|
|
description: str = Form(""),
|
|
sample_dialogue: str = Form(""),
|
|
accent: str = Form(""),
|
|
accent_strength: str = Form("medium"),
|
|
voice_description: str = Form(""),
|
|
distortion_effects: list[str] = Form([]),
|
|
):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
|
|
speaker = speakers[index]
|
|
old_primary_name = speaker["primary_name"]
|
|
|
|
# --- NEW STEP: Parameter Analysis and Cleaning ---
|
|
cleaned_traits, final_notes, conflict_message, cleaned_age, cleaned_pitch = (
|
|
_analyze_and_clean_speaker_params(
|
|
vocal_traits, communication_register, style_notes, age, pitch
|
|
)
|
|
)
|
|
|
|
updated_data = {
|
|
"primary_name": primary_name,
|
|
"demographics": {
|
|
"type": speaker_type,
|
|
"gender": gender,
|
|
"age": cleaned_age,
|
|
"pitch": cleaned_pitch,
|
|
},
|
|
"accent": accent or speaker.get("accent"),
|
|
"accent_strength": accent_strength,
|
|
"vocal_traits": cleaned_traits,
|
|
"communication_style": {
|
|
"primary_register": communication_register,
|
|
"style_shifts_or_notes": final_notes,
|
|
},
|
|
"description": description,
|
|
"sample_dialogue": sample_dialogue,
|
|
"distortion_effects": distortion_effects if distortion_effects else [],
|
|
}
|
|
|
|
if voice_description.strip():
|
|
updated_data["voice_description"] = voice_description.strip()
|
|
else:
|
|
speakers[index].pop("voice_description", None)
|
|
|
|
# Update the speaker dictionary with new/cleaned data
|
|
speakers[index].update(updated_data)
|
|
|
|
# --- Save Speakers (Handles persistence) ---
|
|
_save_speakers(request, speakers)
|
|
|
|
# --- Regeneration Triggering and Feedback ---
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speaker_row.html",
|
|
{
|
|
"request": request,
|
|
"speaker": speakers[index],
|
|
"speaker_idx": index,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers/{index}/alias")
|
|
async def add_alias(request: Request, index: int, alias: str = Form(...)):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
|
|
if "aliases" not in speakers[index]:
|
|
speakers[index]["aliases"] = []
|
|
if alias not in speakers[index]["aliases"]:
|
|
speakers[index]["aliases"].append(alias)
|
|
_save_speakers(request, speakers)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speaker_row.html",
|
|
{
|
|
"request": request,
|
|
"speaker": speakers[index],
|
|
"speaker_idx": index,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/api/speakers/{index}/alias/{alias}")
|
|
async def remove_alias(request: Request, index: int, alias: str):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
|
|
if "aliases" in speakers[index] and alias in speakers[index]["aliases"]:
|
|
speakers[index]["aliases"].remove(alias)
|
|
_save_speakers(request, speakers)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers/{index}/alias/{alias}/move/{target_index}")
|
|
async def move_alias(request: Request, index: int, alias: str, target_index: int):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid source index"}, status_code=400)
|
|
if target_index < 0 or target_index >= len(speakers):
|
|
return JSONResponse({"error": "invalid target index"}, status_code=400)
|
|
if index == target_index:
|
|
return JSONResponse({"error": "cannot move alias to same speaker"}, status_code=400)
|
|
|
|
if "aliases" not in speakers[index] or alias not in speakers[index]["aliases"]:
|
|
return JSONResponse({"error": "alias not found"}, status_code=404)
|
|
|
|
speakers[index]["aliases"].remove(alias)
|
|
if "aliases" not in speakers[target_index]:
|
|
speakers[target_index]["aliases"] = []
|
|
speakers[target_index]["aliases"].append(alias)
|
|
_save_speakers(request, speakers)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers/{index}/alias/{alias}/create")
|
|
async def create_speaker_from_alias(request: Request, index: int, alias: str):
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
|
|
if "aliases" not in speakers[index] or alias not in speakers[index]["aliases"]:
|
|
return JSONResponse({"error": "alias not found"}, status_code=404)
|
|
|
|
speakers[index]["aliases"].remove(alias)
|
|
|
|
new_speaker = {
|
|
"primary_name": alias,
|
|
"aliases": [],
|
|
"demographics": {
|
|
"type": "Unknown",
|
|
"gender": "Unknown",
|
|
},
|
|
"vocal_traits": [],
|
|
"communication_style": {
|
|
"primary_register": "Casual",
|
|
"style_shifts_or_notes": "",
|
|
},
|
|
"is_narrator": False,
|
|
"is_group": False,
|
|
"description": f"Created from alias of {speakers[index]['primary_name']}",
|
|
"sample_dialogue": "",
|
|
"chapters_appeared": [],
|
|
"accent": "none",
|
|
}
|
|
speakers.append(new_speaker)
|
|
_save_speakers(request, speakers)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers/{source_index}/merge/{target_index}")
|
|
async def merge_speakers(request: Request, source_index: int, target_index: int):
|
|
speakers = _load_speakers(request)
|
|
if source_index < 0 or source_index >= len(speakers):
|
|
return JSONResponse({"error": "invalid source index"}, status_code=400)
|
|
if target_index < 0 or target_index >= len(speakers):
|
|
return JSONResponse({"error": "invalid target index"}, status_code=400)
|
|
if source_index == target_index:
|
|
return JSONResponse({"error": "cannot merge speaker into itself"}, status_code=400)
|
|
|
|
source = speakers[source_index]
|
|
target = speakers[target_index]
|
|
|
|
source_name = source.get("primary_name", source.get("canonical_name", ""))
|
|
target_name = target.get("primary_name", "")
|
|
|
|
old_names = [source_name] + source.get("aliases", [])
|
|
old_names = [n for n in old_names if n]
|
|
|
|
if source_name and source_name not in target.get("aliases", []):
|
|
target.setdefault("aliases", []).append(source_name)
|
|
|
|
for alias in source.get("aliases", []):
|
|
if alias not in target.get("aliases", []):
|
|
target.setdefault("aliases", []).append(alias)
|
|
|
|
source_demos = source.get("demographics", {})
|
|
target_demos = target.setdefault("demographics", {})
|
|
if source_demos.get("type") and source_demos["type"] != "Human":
|
|
target_demos.setdefault("type", source_demos["type"])
|
|
if source_demos.get("gender") and source_demos["gender"] != "Unknown":
|
|
target_demos.setdefault("gender", source_demos["gender"])
|
|
|
|
for trait in source.get("vocal_traits", []):
|
|
if trait not in target.get("vocal_traits", []):
|
|
target.setdefault("vocal_traits", []).append(trait)
|
|
|
|
source_chapters = set(source.get("chapters_appeared", []))
|
|
target_chapters = set(target.get("chapters_appeared", []))
|
|
target["chapters_appeared"] = sorted(source_chapters | target_chapters)
|
|
|
|
del speakers[source_index]
|
|
_save_speakers(request, speakers)
|
|
|
|
db = _get_db(request)
|
|
for old_name in old_names:
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
|
|
(target_name, old_name),
|
|
)
|
|
db.commit()
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/api/speakers/{index}")
|
|
async def delete_speaker(request: Request, index: int, reassign_to: str = ""):
|
|
"""Delete a speaker. The UI has offered this since before the endpoint existed.
|
|
|
|
Deleting a speaker who still has dialogue would leave every one of their
|
|
utterances pointing at a name that no longer resolves - the orphaned-
|
|
utterance condition this project already has a review page for. So a
|
|
speaker with dialogue is refused unless the caller says where that
|
|
dialogue should go; ``reassign_to`` re-points it, exactly as a merge does.
|
|
A speaker with no dialogue is simply removed.
|
|
"""
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
|
|
speaker = speakers[index]
|
|
name = speaker.get("primary_name", "")
|
|
old_names = [n for n in ([name] + speaker.get("aliases", [])) if n]
|
|
|
|
db = _get_db(request)
|
|
placeholders = ",".join("?" for _ in old_names) or "''"
|
|
count = db.execute(
|
|
f"SELECT COUNT(*) FROM utterances WHERE speaker_name IN ({placeholders})",
|
|
old_names,
|
|
).fetchone()[0]
|
|
|
|
if count and not reassign_to:
|
|
others = [
|
|
s.get("primary_name", "")
|
|
for i, s in enumerate(speakers)
|
|
if i != index and s.get("primary_name")
|
|
]
|
|
return JSONResponse(
|
|
{
|
|
"error": (
|
|
f"{name} has {count} utterance(s). Choose which speaker "
|
|
"should speak them instead."
|
|
),
|
|
"utterance_count": count,
|
|
"speaker_name": name,
|
|
"options": others,
|
|
},
|
|
status_code=409,
|
|
)
|
|
|
|
if reassign_to:
|
|
target = next(
|
|
(
|
|
s
|
|
for s in speakers
|
|
if s.get("primary_name", "").strip().casefold() == reassign_to.strip().casefold()
|
|
),
|
|
None,
|
|
)
|
|
if target is None:
|
|
return JSONResponse(
|
|
{"error": f"no speaker named {reassign_to!r} to reassign to"},
|
|
status_code=400,
|
|
)
|
|
target_name = target.get("primary_name", "")
|
|
if name and name not in target.get("aliases", []):
|
|
target.setdefault("aliases", []).append(name)
|
|
moved = 0
|
|
for old_name in old_names:
|
|
cur = db.execute(
|
|
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
|
|
(target_name, old_name),
|
|
)
|
|
moved += cur.rowcount or 0
|
|
db.commit()
|
|
else:
|
|
target_name = ""
|
|
moved = 0
|
|
|
|
# Remove this speaker's own reference audio, but never a shared one.
|
|
removed_audio: list[str] = []
|
|
config = _get_active_config(request)
|
|
voices_dir = config.data_dir / "voices"
|
|
slug = name.strip().lower().replace(" ", "_")
|
|
if slug and slug not in ("narrator", "general"):
|
|
for candidate in (slug + "_ref.wav", slug + ".wav"):
|
|
ref = voices_dir / candidate
|
|
if ref.exists():
|
|
try:
|
|
ref.unlink()
|
|
removed_audio.append(candidate)
|
|
except OSError as e: # never fail the delete over a stale file
|
|
logger.warning("Could not remove %s: %s", ref, e)
|
|
|
|
del speakers[index]
|
|
_save_speakers(request, speakers)
|
|
|
|
# Drop the approval entry too. Leaving a stale "approved" behind would let a
|
|
# speaker re-created later inherit an approval nobody gave for it.
|
|
if name:
|
|
ledger = config.data_dir / "voice_status.json"
|
|
try:
|
|
if ledger.exists():
|
|
data = json.loads(ledger.read_text())
|
|
if isinstance(data, dict) and data.pop(name, None) is not None:
|
|
ledger.write_text(json.dumps(data, indent=2))
|
|
except (OSError, ValueError) as e:
|
|
logger.warning("Could not update voice_status ledger: %s", e)
|
|
|
|
speakers = _load_speakers(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_speakers_list.html",
|
|
{
|
|
"request": request,
|
|
"speakers": speakers,
|
|
"deleted": name,
|
|
"reassigned_to": target_name,
|
|
"utterances_moved": moved,
|
|
"removed_audio": removed_audio,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/orphaned-utterances", response_class=HTMLResponse)
|
|
async def orphaned_utterances_page(request: Request):
|
|
db = _get_db(request)
|
|
speakers = _load_speakers(request)
|
|
known = _all_speaker_names_and_aliases(speakers)
|
|
|
|
rows = db.execute(
|
|
"SELECT id, chapter_id, speaker_name, text, utterance_index FROM utterances "
|
|
"WHERE speaker_name IS NOT NULL AND speaker_name != '' AND speaker_name != '__unassigned__'"
|
|
).fetchall()
|
|
orphaned = []
|
|
for r in rows:
|
|
if r["speaker_name"].lower() not in known:
|
|
orphaned.append(dict(r))
|
|
|
|
unassigned = db.execute(
|
|
"SELECT id, chapter_id, speaker_name, text, utterance_index FROM utterances "
|
|
"WHERE speaker_name = '__unassigned__'"
|
|
).fetchall()
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"orphaned_utterances.html",
|
|
{
|
|
"orphaned": orphaned,
|
|
"unassigned": [dict(r) for r in unassigned],
|
|
"speakers": speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/orphaned-utterances/{utt_id}/reassign")
|
|
async def reassign_utterance(request: Request, utt_id: int):
|
|
body = await request.json()
|
|
new_speaker = body.get("speaker_name", "").strip()
|
|
if not new_speaker:
|
|
return JSONResponse({"error": "speaker_name required"}, status_code=400)
|
|
|
|
db = _get_db(request)
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name = ? WHERE id = ?",
|
|
(new_speaker, utt_id),
|
|
)
|
|
db.commit()
|
|
|
|
await generate_utterance_audio(request, utt_id)
|
|
|
|
return JSONResponse({"ok": True, "speaker_name": new_speaker})
|
|
|
|
|
|
@router.post("/api/orphaned-utterances/reassign-all")
|
|
async def reassign_all_orphans(request: Request):
|
|
body = await request.json()
|
|
new_speaker = body.get("speaker_name", "").strip()
|
|
old_names = body.get("old_names", [])
|
|
if not new_speaker or not old_names:
|
|
return JSONResponse({"error": "speaker_name and old_names required"}, status_code=400)
|
|
|
|
db = _get_db(request)
|
|
for old_name in old_names:
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name = ? WHERE speaker_name = ?",
|
|
(new_speaker, old_name),
|
|
)
|
|
db.commit()
|
|
return JSONResponse({"ok": True, "count": len(old_names)})
|
|
|
|
|
|
@router.get("/voices/partial", response_class=HTMLResponse)
|
|
async def voice_list_partial(request: Request):
|
|
db = _get_db(request)
|
|
characters = db.execute("SELECT * FROM characters ORDER BY is_narrator DESC, name").fetchall()
|
|
voices_dir = _get_active_config(request).data_dir / "voices"
|
|
|
|
character_data = []
|
|
for c in characters:
|
|
cd = dict(c)
|
|
if cd.get("profile_json"):
|
|
try:
|
|
cd["profile"] = json.loads(cd["profile_json"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
cd["profile"] = {}
|
|
else:
|
|
cd["profile"] = {}
|
|
|
|
name_hash = int(hashlib.md5(cd["name"].encode()).hexdigest()[:8], 16)
|
|
energy = cd.get("energy", "measured")
|
|
from umbraaudio.voices.voice_designer import (
|
|
ATTRIBUTE_PARAM_MAP,
|
|
NARRATOR_CFG_WEIGHT,
|
|
NARRATOR_EXAGGERATION,
|
|
)
|
|
|
|
if cd.get("is_narrator") and cd["name"] == "Narrator":
|
|
cd["exaggeration"] = NARRATOR_EXAGGERATION
|
|
cd["cfg_weight"] = NARRATOR_CFG_WEIGHT
|
|
elif energy in ATTRIBUTE_PARAM_MAP:
|
|
exag_range, cfg_range = ATTRIBUTE_PARAM_MAP[energy]
|
|
cd["exaggeration"] = round(
|
|
exag_range[0] + ((name_hash % 100) / 100.0) * (exag_range[1] - exag_range[0]), 3
|
|
)
|
|
cd["cfg_weight"] = round(
|
|
cfg_range[0] + (((name_hash >> 8) % 100) / 100.0) * (cfg_range[1] - cfg_range[0]), 3
|
|
)
|
|
else:
|
|
cd["exaggeration"] = 0.35
|
|
cd["cfg_weight"] = 0.45
|
|
|
|
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
|
|
|
|
if cd.get("is_narrator"):
|
|
ref_name = "narrator_ref.wav"
|
|
elif cd["name"].lower() in GENERAL_SPEAKER_NAMES:
|
|
ref_name = "general_ref.wav"
|
|
else:
|
|
ref_name = cd["name"].lower().replace(" ", "_") + "_ref.wav"
|
|
cd["ref_exists"] = (Path(voices_dir) / ref_name).exists()
|
|
cd["ref_name"] = ref_name
|
|
|
|
character_data.append(cd)
|
|
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_voice_list.html",
|
|
{
|
|
"request": request,
|
|
"characters": character_data,
|
|
"voices_dir": str(voices_dir),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/voices/{character_name}/history", response_class=HTMLResponse)
|
|
async def voice_history(request: Request, character_name: str):
|
|
db = _get_db(request)
|
|
history = db.execute(
|
|
"SELECT * FROM voice_review_history WHERE character_name = ? ORDER BY reviewed_at DESC LIMIT 20",
|
|
(character_name,),
|
|
).fetchall()
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_voice_history.html",
|
|
{
|
|
"request": request,
|
|
"character_name": character_name,
|
|
"history": [dict(h) for h in history],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/chapters/{chapter_id}", response_class=HTMLResponse)
|
|
async def chapter_review(request: Request, chapter_id: int):
|
|
db = _get_db(request)
|
|
chapter = db.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
|
|
utterances = db.execute(
|
|
"""
|
|
SELECT u.*, ga.audio_path, ga.duration_ms, u.speaker_name
|
|
FROM utterances u
|
|
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
|
|
WHERE u.chapter_id = ?
|
|
ORDER BY u.utterance_index
|
|
""",
|
|
(chapter_id,),
|
|
).fetchall()
|
|
total_chapters = db.execute("SELECT COUNT(*) as cnt FROM chapters").fetchone()["cnt"]
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chapter_review.html",
|
|
{
|
|
"request": request,
|
|
"chapter": dict(chapter) if chapter else {},
|
|
"utterances": [dict(u) for u in utterances],
|
|
"chapter_id": chapter_id,
|
|
"total_chapters": total_chapters,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/chapters/{chapter_id}/partial", response_class=HTMLResponse)
|
|
async def chapter_utterances_partial(request: Request, chapter_id: int):
|
|
db = _get_db(request)
|
|
utterances = db.execute(
|
|
"""
|
|
SELECT u.*, ga.audio_path, ga.duration_ms, u.speaker_name
|
|
FROM utterances u
|
|
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
|
|
WHERE u.chapter_id = ?
|
|
ORDER BY u.utterance_index
|
|
""",
|
|
(chapter_id,),
|
|
).fetchall()
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_utterance_list.html",
|
|
{
|
|
"request": request,
|
|
"utterances": [dict(u) for u in utterances],
|
|
"chapter_id": chapter_id,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/quality", response_class=HTMLResponse)
|
|
async def quality_report(request: Request):
|
|
db = _get_db(request)
|
|
reports = db.execute(
|
|
"SELECT * FROM quality_reports ORDER BY created_at DESC LIMIT 50"
|
|
).fetchall()
|
|
|
|
defects = []
|
|
defects_path = _get_active_config(request).data_dir / "defects.json"
|
|
if defects_path.exists():
|
|
with open(defects_path) as f:
|
|
defects = json.load(f)
|
|
|
|
chapters = db.execute("""
|
|
SELECT ch.id, ch.title,
|
|
COUNT(ga.id) as audio_count,
|
|
MIN(ga.mos_score) as min_mos,
|
|
MAX(ga.mos_score) as max_mos,
|
|
AVG(ga.speaker_similarity) as avg_sim
|
|
FROM chapters ch
|
|
LEFT JOIN utterances u ON u.chapter_id = ch.id
|
|
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
|
|
GROUP BY ch.id
|
|
ORDER BY ch.chapter_index
|
|
""").fetchall()
|
|
db.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"quality_report.html",
|
|
{
|
|
"request": request,
|
|
"reports": [dict(r) for r in reports],
|
|
"defects": defects,
|
|
"chapters": [dict(c) for c in chapters],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/audio/{slug}/{chapter_id}/{filename}")
|
|
async def serve_audio(request: Request, slug: str, chapter_id: int, filename: str):
|
|
config = _get_active_config(request)
|
|
if config.project_dir.name != slug:
|
|
return JSONResponse({"error": "project mismatch"}, status_code=403)
|
|
audio_path = config.data_dir / "audio_normalized" / f"ch{chapter_id}" / filename
|
|
if not audio_path.exists():
|
|
audio_path = config.data_dir / "audio" / f"ch{chapter_id}" / filename
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(
|
|
str(audio_path),
|
|
media_type="audio/wav",
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
@router.get("/voice-audio/{filename}")
|
|
async def serve_voice_audio(request: Request, filename: str):
|
|
config = _get_active_config(request)
|
|
audio_path = config.data_dir / "voices" / filename
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
@router.get("/voice-tests", response_class=HTMLResponse)
|
|
async def voice_tests_review(request: Request):
|
|
config = _get_active_config(request)
|
|
tests_dir = config.data_dir / "voice_tests"
|
|
|
|
# Load voice assignments
|
|
assignments_path = config.data_dir / "voice_assignments.json"
|
|
assignments = {}
|
|
if assignments_path.exists():
|
|
with open(assignments_path) as f:
|
|
assignments = json.load(f)
|
|
|
|
# Load speakers for context
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speakers = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
|
|
# Build list of test samples with metadata
|
|
test_samples = []
|
|
if tests_dir.exists():
|
|
for wav_file in sorted(tests_dir.glob("*.wav")):
|
|
speaker_name = wav_file.stem.replace("_", " ")
|
|
voice = assignments.get(speaker_name, "unknown")
|
|
speaker_info = speakers.get(speaker_name, {})
|
|
|
|
test_samples.append(
|
|
{
|
|
"speaker_name": speaker_name,
|
|
"filename": wav_file.name,
|
|
"voice": voice,
|
|
"demographics": speaker_info.get("demographics", {}),
|
|
"vocal_traits": speaker_info.get("vocal_traits", []),
|
|
"chapters": len(speaker_info.get("chapters_appeared", [])),
|
|
}
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"voice_tests.html",
|
|
{
|
|
"request": request,
|
|
"test_samples": test_samples,
|
|
"total_count": len(test_samples),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/voice-tests/partial", response_class=HTMLResponse)
|
|
async def voice_tests_partial(request: Request):
|
|
config = _get_active_config(request)
|
|
tests_dir = config.data_dir / "voice_tests"
|
|
|
|
# Load voice assignments
|
|
assignments_path = config.data_dir / "voice_assignments.json"
|
|
assignments = {}
|
|
if assignments_path.exists():
|
|
with open(assignments_path) as f:
|
|
assignments = json.load(f)
|
|
|
|
# Load speakers for context
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speakers = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
|
|
# Build list of test samples with metadata
|
|
test_samples = []
|
|
if tests_dir.exists():
|
|
for wav_file in sorted(tests_dir.glob("*.wav")):
|
|
speaker_name = wav_file.stem.replace("_", " ")
|
|
voice = assignments.get(speaker_name, "unknown")
|
|
speaker_info = speakers.get(speaker_name, {})
|
|
|
|
test_samples.append(
|
|
{
|
|
"speaker_name": speaker_name,
|
|
"filename": wav_file.name,
|
|
"voice": voice,
|
|
"demographics": speaker_info.get("demographics", {}),
|
|
"vocal_traits": speaker_info.get("vocal_traits", []),
|
|
"chapters": len(speaker_info.get("chapters_appeared", [])),
|
|
}
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_voice_tests_list.html",
|
|
{
|
|
"request": request,
|
|
"test_samples": test_samples,
|
|
"total_count": len(test_samples),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/voice-analysis/partial", response_class=HTMLResponse)
|
|
async def voice_analysis_partial(request: Request):
|
|
config = _get_active_config(request)
|
|
analysis_dir = config.data_dir / "analysis"
|
|
report_path = analysis_dir / "voice_analysis.json"
|
|
|
|
report = None
|
|
if report_path.exists():
|
|
with open(report_path) as f:
|
|
report = json.load(f)
|
|
status = load_analysis_status(analysis_dir)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_voice_analysis_report.html",
|
|
{
|
|
"request": request,
|
|
"report": report,
|
|
"status": status,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/voice-analysis/run")
|
|
async def run_voice_analysis(request: Request):
|
|
config = _get_active_config(request)
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speaker_names: set[str] = set()
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
speaker_names = {s["primary_name"] for s in json.load(f).get("speakers", [])}
|
|
|
|
from umbraaudio.analysis.voice_analysis import run_voice_analysis_in_background
|
|
|
|
run_voice_analysis_in_background(
|
|
refs_dir=config.data_dir / "voices",
|
|
output_dir=config.data_dir / "analysis",
|
|
target_sample_rate=config.tts.sample_rate,
|
|
speaker_names=speaker_names,
|
|
)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.get("/voice-test-audio/{filename}")
|
|
async def serve_voice_test_audio(request: Request, filename: str):
|
|
config = _get_active_config(request)
|
|
audio_path = config.data_dir / "voices" / filename
|
|
if not audio_path.exists():
|
|
audio_path = config.data_dir / "voice_tests" / filename
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
@router.get("/api/speakers/{index}/preview-effects")
|
|
async def preview_speaker_effects(request: Request, index: int, effects: str = ""):
|
|
config = _get_active_config(request)
|
|
speakers = _load_speakers(request)
|
|
if index < 0 or index >= len(speakers):
|
|
return JSONResponse({"error": "invalid index"}, status_code=400)
|
|
speaker = speakers[index]
|
|
ref_name = speaker["primary_name"].lower().replace(" ", "_")
|
|
ref_file = f"{ref_name}_ref.wav"
|
|
src = config.data_dir / "voices" / ref_file
|
|
if not src.exists():
|
|
return JSONResponse({"error": "no voice ref"}, status_code=404)
|
|
if effects:
|
|
speaker_effects = [e.strip() for e in effects.split(",") if e.strip()]
|
|
else:
|
|
saved = load_speaker_distortion_effects(config.data_dir / "speakers.json")
|
|
speaker_effects = saved.get(speaker["primary_name"], [])
|
|
if not speaker_effects:
|
|
return FileResponse(str(src), media_type="audio/wav")
|
|
try:
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
|
tmp.close()
|
|
shutil.copy2(src, tmp.name)
|
|
ok = apply_effects_to_file_by_name(Path(tmp.name), speaker_effects)
|
|
if not ok:
|
|
return FileResponse(str(src), media_type="audio/wav")
|
|
return FileResponse(tmp.name, media_type="audio/wav")
|
|
except Exception as exc:
|
|
logger.error("Preview effects failed: %s", exc, exc_info=True)
|
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
|
|
|
|
@router.get("/chapters-audio", response_class=HTMLResponse)
|
|
async def chapters_audio_review(request: Request, lang: str | None = None):
|
|
config = _get_active_config(request)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
rows = db.execute("""
|
|
SELECT c.id, c.title, c.chapter_index,
|
|
COUNT(u.id) as utt_count
|
|
FROM chapters c
|
|
LEFT JOIN utterances u ON u.chapter_id = c.id
|
|
GROUP BY c.id
|
|
ORDER BY c.chapter_index
|
|
""").fetchall()
|
|
db.close()
|
|
|
|
chapters = []
|
|
for row in rows:
|
|
ch_id = row["id"]
|
|
chapters.append(
|
|
{
|
|
"id": ch_id,
|
|
"title": row["title"] or f"Chapter {row['chapter_index'] + 1}",
|
|
"chapter_index": row["chapter_index"],
|
|
"audio_count": row["utt_count"],
|
|
"directory": f"ch{ch_id}",
|
|
}
|
|
)
|
|
|
|
languages = _languages_for_template()
|
|
active_language = lang if lang in dict(languages) else _active_language_for_template(request)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chapters_audio.html",
|
|
{
|
|
"request": request,
|
|
"chapters": chapters,
|
|
"total_chapters": len(chapters),
|
|
"languages": languages,
|
|
"active_language": active_language,
|
|
"active_language_name": dict(languages).get(active_language, active_language),
|
|
},
|
|
)
|
|
|
|
|
|
ACTIVE_LANGUAGE_COOKIE = "active_language"
|
|
|
|
|
|
def _active_language_for_template(request: Request) -> str:
|
|
"""Resolve the active content language from the cookie.
|
|
|
|
Defaults to English, which is the source language of every project that
|
|
exists today. An unsupported cookie value is ignored rather than honoured:
|
|
the registry is the authority on what can be shown.
|
|
"""
|
|
from umbraaudio.web.language_routes import SUPPORTED
|
|
|
|
cookie_lang = (request.cookies.get(ACTIVE_LANGUAGE_COOKIE) or "").strip()
|
|
supported_codes = {code for code, _ in SUPPORTED}
|
|
return cookie_lang if cookie_lang in supported_codes else "en"
|
|
|
|
|
|
def _languages_for_template() -> list[tuple[str, str]]:
|
|
"""Return the supported languages for the selector dropdown."""
|
|
from umbraaudio.web.language_routes import SUPPORTED
|
|
|
|
return SUPPORTED
|
|
|
|
|
|
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
|
|
async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | None = None):
|
|
config = _get_active_config(request)
|
|
|
|
# Handle language selection: read from query param, set cookie, fall back to cookie
|
|
supported_codes = {code for code, _ in _languages_for_template()}
|
|
if lang and lang in supported_codes:
|
|
active_language = lang
|
|
# Will set cookie in response
|
|
else:
|
|
active_language = _active_language_for_template(request)
|
|
|
|
languages = _languages_for_template()
|
|
languages_json = [{"code": code, "name": name} for code, name in languages]
|
|
audio_base = config.data_dir / "chapter_audio" / chapter_dir
|
|
|
|
assignments_path = config.data_dir / "voice_assignments.json"
|
|
assignments = {}
|
|
if assignments_path.exists():
|
|
with open(assignments_path) as f:
|
|
assignments = json.load(f)
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
speakers = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
speakers = {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
|
|
try:
|
|
ch_id = int(chapter_dir[2:])
|
|
except (ValueError, IndexError):
|
|
ch_id = 0
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_ensure_utterance_columns(db)
|
|
chapter = (
|
|
db.execute("SELECT title, chapter_index FROM chapters WHERE id = ?", (ch_id,)).fetchone()
|
|
if ch_id
|
|
else None
|
|
)
|
|
utterances = []
|
|
if ch_id:
|
|
utterances = db.execute(
|
|
"""
|
|
SELECT u.id, u.speaker_name, u.text, u.utterance_index,
|
|
u.emotion, u.pace, u.instruct, u.status, u.type,
|
|
u.qa_accepted, u.qa_accepted_sig
|
|
FROM utterances u
|
|
WHERE u.chapter_id = ?
|
|
ORDER BY u.utterance_index
|
|
""",
|
|
(ch_id,),
|
|
).fetchall()
|
|
|
|
# Flag utterance rows with no text as requiring review so they surface
|
|
# in the UI instead of silently corrupting the audiobook. Their spoken
|
|
# audio is skipped at build time, but any stray wav on disk must not be
|
|
# stitched in.
|
|
blank_ids = [u["id"] for u in utterances if not (u["text"] or "").strip()]
|
|
if blank_ids:
|
|
db.execute(
|
|
"UPDATE utterances SET status = 'review' WHERE id IN (%s)"
|
|
% ",".join("?" * len(blank_ids)),
|
|
blank_ids,
|
|
)
|
|
db.commit()
|
|
alias_map = _load_speaker_alias_map(config)
|
|
issues_by_index = _qa_issues_for_chapter(db, ch_id, alias_map) if ch_id else {}
|
|
|
|
# Fetch translations for the active language (if not English).
|
|
#
|
|
# Two stores hold translations: the pipeline's ``utterance_translations``
|
|
# table and the on-disk sample files under ``samples/translations``. The
|
|
# resolver merges them, keyed by (chapter_id, utterance_index), so a
|
|
# language with a valid on-disk translation renders it instead of English.
|
|
translations_map = {}
|
|
if ch_id and active_language != "en":
|
|
from umbraaudio.translations import resolved_translations
|
|
|
|
translations_map = resolved_translations(db, config.project_dir, active_language)
|
|
|
|
db.close()
|
|
|
|
title = chapter["title"] if chapter else f"Chapter {chapter_dir}"
|
|
|
|
existing_wav = {}
|
|
if audio_base.exists():
|
|
for wav_file in audio_base.glob("*.wav"):
|
|
try:
|
|
idx = int(wav_file.stem.split("_")[1])
|
|
except (IndexError, ValueError):
|
|
idx = 0
|
|
existing_wav[idx] = wav_file.name
|
|
|
|
speaker_names = sorted(speakers.keys())
|
|
if "Narrator" not in speaker_names:
|
|
speaker_names.append("Narrator")
|
|
speaker_names = sorted(speaker_names)
|
|
alias_map = _load_speaker_alias_map(config)
|
|
|
|
audio_files = []
|
|
for u in utterances:
|
|
idx = u["utterance_index"]
|
|
speaker = (u["speaker_name"] or "Narrator").strip()
|
|
speaker = alias_map.get(speaker.lower(), speaker)
|
|
source_text = u["text"] or ""
|
|
wav = existing_wav.get(idx)
|
|
voice = assignments.get(speaker, "unknown")
|
|
is_blank = not source_text.strip()
|
|
qa_issues = issues_by_index.get(idx, [])
|
|
|
|
# Get translated text if available for active language
|
|
translated = translations_map.get((ch_id, idx))
|
|
has_translation = translated is not None and translated.text.strip()
|
|
display_text = translated.text if has_translation else source_text
|
|
translation_status = translated.status if translated else None
|
|
# A delivered gap: the translation exists but does not voice this
|
|
# utterance, and the delivery says why. "Chinese has not been
|
|
# translated yet" would be a false statement here.
|
|
translation_gap = (
|
|
translated is not None and not translated.voiced and translated.reason.strip()
|
|
)
|
|
translation_gap_reason = translated.reason if translation_gap else ""
|
|
|
|
audio_files.append(
|
|
{
|
|
"filename": wav,
|
|
"index": idx,
|
|
"utt_id": u["id"],
|
|
"speaker": speaker,
|
|
"text": display_text[:100] + "..." if len(display_text) > 100 else display_text,
|
|
"full_text": source_text,
|
|
"display_text": display_text,
|
|
"source_text": source_text,
|
|
"translated_text": translated.text if has_translation else None,
|
|
"translation_status": translation_status,
|
|
"translation_gap": translation_gap,
|
|
"translation_gap_reason": translation_gap_reason,
|
|
"has_translation": has_translation,
|
|
"voice": voice,
|
|
"emotion": u["emotion"] or "neutral",
|
|
"pace": u["pace"] or "moderate",
|
|
"instruct": u["instruct"] or "",
|
|
"is_blank": is_blank,
|
|
"qa_issues": qa_issues,
|
|
"qa_accepted": _qa_accepted_for(
|
|
u["qa_accepted"], u["qa_accepted_sig"], speaker, source_text
|
|
),
|
|
}
|
|
)
|
|
|
|
last_utt_id = audio_files[-1]["utt_id"] if audio_files else None
|
|
blank_count = sum(1 for a in audio_files if a["is_blank"])
|
|
|
|
chapter_full_path = config.data_dir / "chapter_audio" / f"ch{ch_id}_full.wav"
|
|
has_chapter_audio = chapter_full_path.exists()
|
|
|
|
# The active_language cookie is set by the language middleware in
|
|
# web/__init__.py, so the choice survives on every page.
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chapter_audio_detail.html",
|
|
{
|
|
"request": request,
|
|
"chapter_title": title,
|
|
"chapter_dir": chapter_dir,
|
|
"chapter_id": ch_id,
|
|
"audio_files": audio_files,
|
|
"blank_count": blank_count,
|
|
"total_utterances": len(audio_files),
|
|
"speaker_names": speaker_names,
|
|
"last_utt_id": last_utt_id,
|
|
"has_chapter_audio": has_chapter_audio,
|
|
"active_language": active_language,
|
|
"active_language_name": dict(languages).get(active_language, active_language),
|
|
"languages": languages,
|
|
"languages_json": languages_json,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/chapter-audio-file/{slug}/{chapter_dir}/{filename}")
|
|
async def serve_chapter_audio(request: Request, slug: str, chapter_dir: str, filename: str):
|
|
config = _get_active_config(request)
|
|
if config.project_dir.name != slug:
|
|
return JSONResponse({"error": "project mismatch"}, status_code=403)
|
|
audio_path = config.data_dir / "chapter_audio" / chapter_dir / filename
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(
|
|
str(audio_path),
|
|
media_type="audio/wav",
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
@router.put("/api/chapter/{chapter_id}/title")
|
|
async def update_chapter_title(request: Request, chapter_id: int):
|
|
body = await request.json()
|
|
title = (body.get("title") or "").strip()
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.execute(
|
|
"UPDATE chapters SET title = ? WHERE id = ?",
|
|
(title or None, chapter_id),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return JSONResponse({"ok": True, "chapter_id": chapter_id, "title": title})
|
|
|
|
|
|
@router.get("/api/chapter/{chapter_id}/full-audio")
|
|
async def serve_chapter_full_audio(request: Request, chapter_id: int):
|
|
config = _get_active_config(request)
|
|
audio_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
@router.get("/api/chapter/{chapter_id}/download-wav")
|
|
async def download_chapter_wav(request: Request, chapter_id: int):
|
|
config = _get_active_config(request)
|
|
audio_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "Chapter audio not built yet"}, status_code=404)
|
|
db = sqlite3.connect(str(config.data_dir / "project.db"))
|
|
row = db.execute("SELECT title FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
|
|
db.close()
|
|
title = row[0] if row and row[0] else f"chapter_{chapter_id}"
|
|
safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip().replace(" ", "_")
|
|
return FileResponse(
|
|
str(audio_path),
|
|
media_type="audio/wav",
|
|
filename=f"{safe}.wav",
|
|
)
|
|
|
|
|
|
@router.get("/api/chapter/{chapter_id}/download-mp3")
|
|
async def download_chapter_mp3(request: Request, chapter_id: int):
|
|
config = _get_active_config(request)
|
|
full_wav = config.data_dir / "chapter_audio" / f"ch{chapter_id}_full.wav"
|
|
if not full_wav.exists():
|
|
return JSONResponse({"error": "Chapter audio not built yet"}, status_code=404)
|
|
|
|
db = sqlite3.connect(str(config.data_dir / "project.db"))
|
|
row = db.execute("SELECT title FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
|
|
db.close()
|
|
title = row[0] if row and row[0] else f"chapter_{chapter_id}"
|
|
safe = "".join(c if c.isalnum() or c in " -_" else "_" for c in title).strip().replace(" ", "_")
|
|
|
|
import tempfile
|
|
|
|
from umbraaudio.assembly.ar_export import export_chapter_mp3
|
|
|
|
mp3_path = Path(tempfile.mktemp(suffix=".mp3"))
|
|
try:
|
|
export_chapter_mp3(config.data_dir, full_wav, mp3_path)
|
|
except Exception as e:
|
|
mp3_path.unlink(missing_ok=True)
|
|
err_msg = str(e)[:300] if e else "unknown error"
|
|
return JSONResponse({"error": f"MP3 encoding failed: {err_msg}"}, status_code=500)
|
|
|
|
from starlette.responses import StreamingResponse
|
|
|
|
def _stream_and_cleanup():
|
|
with open(mp3_path, "rb") as f:
|
|
while chunk := f.read(65536):
|
|
yield chunk
|
|
mp3_path.unlink(missing_ok=True)
|
|
|
|
return StreamingResponse(
|
|
_stream_and_cleanup(),
|
|
media_type="audio/mpeg",
|
|
headers={"Content-Disposition": f'attachment; filename="{safe}.mp3"'},
|
|
)
|
|
|
|
|
|
@router.put("/api/utterance/{utt_id}/speaker")
|
|
async def update_utterance_speaker(request: Request, utt_id: int):
|
|
body = await request.json()
|
|
new_speaker = body.get("speaker_name", "").strip()
|
|
if not new_speaker:
|
|
return JSONResponse({"error": "speaker_name required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_ensure_utterance_columns(db)
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name = ?, user_edited = 1 WHERE id = ?",
|
|
(new_speaker, utt_id),
|
|
)
|
|
|
|
utt = db.execute(
|
|
"SELECT chapter_id, utterance_index, text FROM utterances WHERE id = ?", (utt_id,)
|
|
).fetchone()
|
|
db.commit()
|
|
db.close()
|
|
|
|
if utt:
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{utt['chapter_id']}"
|
|
old_audio = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
|
|
if old_audio.exists():
|
|
old_audio.unlink()
|
|
|
|
def _regen():
|
|
from umbraaudio.analysis import CharacterProfile
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
|
|
try:
|
|
designer = VoiceDesigner(config.tts)
|
|
output_path = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
|
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
designer._generate_edge_fallback(
|
|
CharacterProfile(
|
|
name=new_speaker,
|
|
pitch_register="mid",
|
|
energy="measured",
|
|
gender=None,
|
|
timbre="clear",
|
|
),
|
|
text=utt["text"],
|
|
output_path=output_path,
|
|
)
|
|
except Exception as e:
|
|
logger.error("Utterance regen failed: %s", e)
|
|
|
|
import threading
|
|
|
|
threading.Thread(target=_regen, daemon=True).start()
|
|
|
|
return JSONResponse({"ok": True, "speaker_name": new_speaker})
|
|
|
|
|
|
@router.put("/api/utterance/{utt_id}/text")
|
|
async def update_utterance_text(request: Request, utt_id: int):
|
|
body = await request.json()
|
|
new_text = body.get("text", "").strip()
|
|
if not new_text:
|
|
return JSONResponse({"error": "text required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_ensure_utterance_columns(db)
|
|
|
|
utt = db.execute(
|
|
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
|
|
).fetchone()
|
|
db.execute(
|
|
"UPDATE utterances SET text = ?, status = 'pending', user_edited = 1 WHERE id = ?",
|
|
(new_text, utt_id),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
|
|
if utt:
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{utt['chapter_id']}"
|
|
old_audio = audio_dir / f"utt_{utt['utterance_index']:04d}.wav"
|
|
if old_audio.exists():
|
|
old_audio.unlink()
|
|
|
|
return JSONResponse({"ok": True, "text": new_text})
|
|
|
|
|
|
@router.put("/api/utterance/{utt_id}/variation")
|
|
async def update_utterance_variation(request: Request, utt_id: int):
|
|
body = await request.json()
|
|
emotion = body.get("emotion", "").strip() or None
|
|
pace = body.get("pace", "").strip() or None
|
|
instruct = body.get("instruct", "").strip() or None
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_ensure_utterance_columns(db)
|
|
|
|
utt = db.execute(
|
|
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
|
|
).fetchone()
|
|
if not utt:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
db.execute(
|
|
"UPDATE utterances SET emotion = ?, pace = ?, instruct = ?, user_edited = 1 WHERE id = ?",
|
|
(emotion, pace, instruct, utt_id),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
|
|
return JSONResponse({"ok": True, "emotion": emotion, "pace": pace, "instruct": instruct})
|
|
|
|
|
|
@router.post("/api/utterance/{utt_id}/recheck")
|
|
async def recheck_utterance_qa(request: Request, utt_id: int):
|
|
"""Re-run the mechanical QA check (+auto-fix) for the utterance's chapter."""
|
|
db = _get_db(request)
|
|
try:
|
|
payload = _recheck_utterance_qa(db, _get_active_config(request), utt_id)
|
|
finally:
|
|
db.close()
|
|
if payload.get("error"):
|
|
return JSONResponse(payload, status_code=404)
|
|
return JSONResponse({"ok": True, **payload})
|
|
|
|
|
|
@router.post("/api/utterance/{utt_id}/qa-accept")
|
|
async def accept_utterance_qa(request: Request, utt_id: int):
|
|
"""Mark the utterance as human-accepted as-is (reverts on speaker/text edit)."""
|
|
db = _get_db(request)
|
|
try:
|
|
payload = _qa_accept_utterance(db, utt_id)
|
|
finally:
|
|
db.close()
|
|
if payload.get("error"):
|
|
return JSONResponse(payload, status_code=404)
|
|
return JSONResponse({"ok": True, **payload})
|
|
|
|
|
|
@router.post("/api/utterance/{utt_id}/generate")
|
|
async def generate_utterance_audio(request: Request, utt_id: int):
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
utt = db.execute(
|
|
"SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct, "
|
|
"language_code "
|
|
"FROM utterances WHERE id = ?",
|
|
(utt_id,),
|
|
).fetchone()
|
|
if not utt:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
ch_id = utt["chapter_id"]
|
|
idx = utt["utterance_index"]
|
|
text = utt["text"] or ""
|
|
speaker = (utt["speaker_name"] or "Narrator").strip()
|
|
emotion = utt["emotion"] or "neutral"
|
|
pace = utt["pace"] or "moderate"
|
|
instruct = utt["instruct"] or ""
|
|
|
|
# The language the line is written in (spec 31 source axis). NULL on the
|
|
# utterance means the project's source language.
|
|
from umbraaudio.translations import source_language
|
|
|
|
line_language = utt["language_code"] or source_language(db)
|
|
|
|
db.close()
|
|
|
|
if not text.strip():
|
|
return JSONResponse({"error": "utterance text is empty"}, status_code=400)
|
|
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{ch_id}"
|
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = audio_dir / f"utt_{idx:04d}.wav"
|
|
|
|
if is_scene_break(text):
|
|
write_scene_break_audio(output_path)
|
|
logger.info("Wrote scene-break silence: %s", output_path)
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"utterance_id": utt_id,
|
|
"filename": output_path.name,
|
|
"speaker": "scene_break",
|
|
"emotion": "neutral",
|
|
}
|
|
)
|
|
|
|
if output_path.exists():
|
|
output_path.unlink()
|
|
|
|
def _generate():
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from umbraaudio.generation.audio_effects import (
|
|
apply_effects_to_file,
|
|
apply_effects_to_file_by_name,
|
|
load_speaker_distortion_effects,
|
|
load_speaker_traits,
|
|
)
|
|
from umbraaudio.generation.chapter import compose_utterance_instruct
|
|
from umbraaudio.generation.staleness import write_marker
|
|
from umbraaudio.voices.qwen_tts import (
|
|
VOICE_REFERENCE_TEXT,
|
|
QwenTTSEngine,
|
|
QwenTTSUnavailableError,
|
|
)
|
|
|
|
try:
|
|
phonetic = _load_phonetic_names(config.data_dir)
|
|
proper_names = _load_proper_names(config.data_dir)
|
|
spoken_text = _prepare_tts_text(text, phonetic, proper_names)
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
speakers = _load_speakers(request)
|
|
alias_map = {}
|
|
for s in speakers:
|
|
alias_map[s["primary_name"]] = s
|
|
for a in s.get("aliases", []):
|
|
alias_map[a] = s
|
|
|
|
speaker_info = alias_map.get(speaker)
|
|
voice_ref = None
|
|
if speaker_info:
|
|
ref_name = speaker.lower().replace(" ", "_") + "_ref.wav"
|
|
ref_path = voices_dir / ref_name
|
|
if ref_path.exists():
|
|
voice_ref = ref_path
|
|
|
|
if not voice_ref:
|
|
for vf in voices_dir.glob("*_ref.wav"):
|
|
if speaker.lower().replace(" ", "_") in vf.stem.lower():
|
|
voice_ref = vf
|
|
break
|
|
|
|
engine = QwenTTSEngine(
|
|
qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url
|
|
)
|
|
|
|
tts_instruct = compose_utterance_instruct(instruct, emotion, pace)
|
|
|
|
if voice_ref:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
voice_ref=voice_ref,
|
|
ref_text=VOICE_REFERENCE_TEXT,
|
|
instruct=tts_instruct or None,
|
|
language=line_language,
|
|
)
|
|
else:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
|
|
language=line_language,
|
|
)
|
|
if output_path.exists():
|
|
explicit = load_speaker_distortion_effects(config.data_dir / "speakers.json").get(
|
|
speaker, []
|
|
)
|
|
if explicit:
|
|
apply_effects_to_file_by_name(output_path, explicit)
|
|
else:
|
|
traits = load_speaker_traits(config.data_dir / "speakers.json").get(speaker, [])
|
|
if traits:
|
|
apply_effects_to_file(output_path, traits)
|
|
write_marker(
|
|
spoken_text,
|
|
output_path,
|
|
speaker=speaker,
|
|
voice_ref=voice_ref,
|
|
voice_mode="embedding" if voice_ref else None,
|
|
instruct=tts_instruct or None,
|
|
language=line_language,
|
|
)
|
|
|
|
logger.info("Generated utterance %d audio: %s", utt_id, output_path)
|
|
|
|
except QwenTTSUnavailableError as e:
|
|
logger.error("TTS unavailable for utterance %d: %s", utt_id, e)
|
|
except Exception as e:
|
|
logger.error("Utterance %d generation failed: %s", utt_id, e)
|
|
|
|
threading.Thread(target=_generate, daemon=True).start()
|
|
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"utterance_id": utt_id,
|
|
"filename": f"utt_{idx:04d}.wav",
|
|
"speaker": speaker,
|
|
"emotion": emotion,
|
|
}
|
|
)
|
|
|
|
|
|
@router.delete("/api/utterance/{utt_id}")
|
|
async def delete_utterance(request: Request, utt_id: int):
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
utt = db.execute(
|
|
"SELECT chapter_id, utterance_index FROM utterances WHERE id = ?", (utt_id,)
|
|
).fetchone()
|
|
if not utt:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
ch_id = utt["chapter_id"]
|
|
db.execute("DELETE FROM utterances WHERE id = ?", (utt_id,))
|
|
|
|
remaining = db.execute(
|
|
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
for i, row in enumerate(remaining):
|
|
db.execute("UPDATE utterances SET utterance_index = ? WHERE id = ?", (i, row["id"]))
|
|
db.commit()
|
|
db.close()
|
|
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{ch_id}"
|
|
for f in list(audio_dir.glob("utt_*.wav")) + list(audio_dir.glob("utt_*.wav.txt")):
|
|
if f.is_file():
|
|
f.unlink()
|
|
|
|
import sys as _sys
|
|
import threading
|
|
|
|
db2 = sqlite3.connect(str(db_path))
|
|
db2.row_factory = sqlite3.Row
|
|
remaining = db2.execute(
|
|
"SELECT utterance_index, text, type FROM utterances "
|
|
"WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
db2.close()
|
|
|
|
def _regen():
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from umbraaudio.voices.qwen_tts import (
|
|
VOICE_REFERENCE_TEXT,
|
|
QwenTTSEngine,
|
|
QwenTTSUnavailableError,
|
|
)
|
|
|
|
try:
|
|
phonetic = _load_phonetic_names(config.data_dir)
|
|
proper_names = _load_proper_names(config.data_dir)
|
|
voices_dir = config.data_dir / "voices"
|
|
narrator_ref = None
|
|
for vf in voices_dir.glob("*_ref.wav"):
|
|
if "Narrator" in vf.stem.lower():
|
|
narrator_ref = vf
|
|
break
|
|
if "kainda" in vf.stem.lower():
|
|
narrator_ref = vf
|
|
break
|
|
engine = QwenTTSEngine(
|
|
qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url
|
|
)
|
|
for u in remaining:
|
|
out = audio_dir / f"utt_{u['utterance_index']:04d}.wav"
|
|
if is_scene_break(u["text"], u["type"]):
|
|
write_scene_break_audio(out)
|
|
continue
|
|
if out.exists():
|
|
continue
|
|
spoken_text = _prepare_tts_text(u["text"], phonetic, proper_names)
|
|
if narrator_ref:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=out,
|
|
voice_ref=narrator_ref,
|
|
ref_text=VOICE_REFERENCE_TEXT,
|
|
)
|
|
else:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=out,
|
|
instruct="A calm, neutral voice speaking clearly.",
|
|
)
|
|
except QwenTTSUnavailableError as e:
|
|
logger.error("TTS unavailable for utterance regen: %s", e)
|
|
except Exception as e:
|
|
logger.error("Utterance regen failed: %s", e)
|
|
|
|
threading.Thread(target=_regen, daemon=True).start()
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
def _reindex_chapter_audio(config, chapter_id: int, mapping: dict[int, int]) -> None:
|
|
"""Apply an index mapping to a chapter's utterances.
|
|
|
|
Updates ``utterance_index`` in the DB and renames the ``utt_*.wav``
|
|
(and sidecar ``.txt``) files so audio follows the utterance. Renames
|
|
are staged through temp names to stay safe under swaps/shifts.
|
|
"""
|
|
if not mapping:
|
|
return
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{chapter_id}"
|
|
|
|
staged: list[tuple[Path, int]] = []
|
|
if audio_dir.exists():
|
|
for file in sorted(audio_dir.glob("utt_*.wav")):
|
|
try:
|
|
old = int(file.stem.split("_")[1])
|
|
except (IndexError, ValueError):
|
|
continue
|
|
new = mapping.get(old)
|
|
if new is not None and new != old:
|
|
staged.append((file, new))
|
|
|
|
tmp_names: list[tuple[Path, int, Path]] = []
|
|
for file, new in staged:
|
|
tmp = audio_dir / f"._move_{file.stem}.wav.tmp"
|
|
file.rename(tmp)
|
|
tmp_names.append((tmp, new, file))
|
|
for tmp, new, orig in tmp_names:
|
|
tmp.rename(audio_dir / f"utt_{new:04d}.wav")
|
|
txt = audio_dir / f"{orig.stem}.wav.txt"
|
|
if txt.exists():
|
|
txt.rename(audio_dir / f"utt_{new:04d}.wav.txt")
|
|
|
|
db = sqlite3.connect(str(config.data_dir / "project.db"))
|
|
db.row_factory = sqlite3.Row
|
|
rows = db.execute(
|
|
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ?",
|
|
(chapter_id,),
|
|
).fetchall()
|
|
# Update by row id (unique) — updating by index mid-operation would
|
|
# cascade once two rows briefly share an index.
|
|
for rid, old in rows:
|
|
new = mapping.get(old)
|
|
if new is not None and new != old:
|
|
db.execute("UPDATE utterances SET utterance_index = ? WHERE id = ?", (new, rid))
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
@router.put("/api/utterance/{utt_id}/move")
|
|
async def move_utterance(request: Request, utt_id: int):
|
|
body = await request.json()
|
|
direction = body.get("direction", "up").lower()
|
|
if direction not in ("up", "down"):
|
|
return JSONResponse({"error": "direction must be up or down"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
row = db.execute("SELECT chapter_id FROM utterances WHERE id = ?", (utt_id,)).fetchone()
|
|
if not row:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
ch_id = row["chapter_id"]
|
|
order = db.execute(
|
|
"SELECT id, utterance_index FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
pos = next((i for i, r in enumerate(order) if r["id"] == utt_id), None)
|
|
if pos is None:
|
|
db.close()
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
target = pos - 1 if direction == "up" else pos + 1
|
|
if target < 0 or target >= len(order):
|
|
db.close()
|
|
return JSONResponse({"error": "already at edge"}, status_code=400)
|
|
a, b = order[pos], order[target]
|
|
mapping = {
|
|
a["utterance_index"]: b["utterance_index"],
|
|
b["utterance_index"]: a["utterance_index"],
|
|
}
|
|
db.close()
|
|
_reindex_chapter_audio(config, ch_id, mapping)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.post("/api/chapter/{chapter_id}/utterance")
|
|
async def create_utterance(request: Request, chapter_id: int):
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
body = await request.json()
|
|
text = body.get("text", "").strip()
|
|
speaker = body.get("speaker_name", "Narrator").strip()
|
|
if not text:
|
|
return JSONResponse({"error": "text required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
_ensure_utterance_columns(db)
|
|
|
|
max_idx = db.execute(
|
|
"SELECT MAX(utterance_index) as mx FROM utterances WHERE chapter_id = ?",
|
|
(chapter_id,),
|
|
).fetchone()["mx"]
|
|
end_idx = (max_idx or 0) + 1
|
|
|
|
# Optional position: insert before "index" instead of appending at the end.
|
|
target = body.get("index")
|
|
if isinstance(target, int) and 0 <= target <= end_idx:
|
|
next_idx = target
|
|
if target < end_idx:
|
|
mapping = {old: old + 1 for old in range(target, end_idx)}
|
|
_reindex_chapter_audio(config, chapter_id, mapping)
|
|
else:
|
|
next_idx = end_idx
|
|
|
|
db.execute(
|
|
"INSERT INTO utterances (chapter_id, speaker_name, text, utterance_index, user_edited) VALUES (?, ?, ?, ?, 1)",
|
|
(chapter_id, speaker, text, next_idx),
|
|
)
|
|
db.commit()
|
|
new_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
db.close()
|
|
|
|
audio_dir = config.data_dir / "chapter_audio" / f"ch{chapter_id}"
|
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = audio_dir / f"utt_{next_idx:04d}.wav"
|
|
|
|
def _generate():
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from umbraaudio.voices.qwen_tts import (
|
|
VOICE_REFERENCE_TEXT,
|
|
QwenTTSEngine,
|
|
QwenTTSUnavailableError,
|
|
)
|
|
|
|
phonetic = _load_phonetic_names(config.data_dir)
|
|
proper_names = _load_proper_names(config.data_dir)
|
|
spoken_text = _prepare_tts_text(text, phonetic, proper_names)
|
|
voices_dir = config.data_dir / "voices"
|
|
speakers = _load_speakers(request)
|
|
alias_map = {}
|
|
for name, info in speakers.items():
|
|
alias_map[name.lower()] = name
|
|
for alias in info.get("aliases", []):
|
|
alias_map[alias.lower()] = name
|
|
narrator_ref = None
|
|
for vf in voices_dir.glob("*_ref.wav"):
|
|
if "Narrator" in vf.stem.lower():
|
|
narrator_ref = vf
|
|
break
|
|
if "kainda" in vf.stem.lower():
|
|
narrator_ref = vf
|
|
break
|
|
try:
|
|
engine = QwenTTSEngine(
|
|
qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url
|
|
)
|
|
if narrator_ref:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
voice_ref=narrator_ref,
|
|
ref_text=VOICE_REFERENCE_TEXT,
|
|
)
|
|
else:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
instruct="A calm, neutral voice speaking clearly.",
|
|
)
|
|
except QwenTTSUnavailableError as e:
|
|
logger.error("TTS unavailable for new utterance: %s", e)
|
|
except Exception as e:
|
|
logger.error("New utterance generation failed: %s", e)
|
|
|
|
import threading
|
|
|
|
threading.Thread(target=_generate, daemon=True).start()
|
|
|
|
return JSONResponse({"ok": True, "utt_id": new_id, "index": next_idx})
|
|
|
|
|
|
@router.post("/api/voices/{character_id}/approve")
|
|
async def approve_voice(character_id: int, request: Request):
|
|
db = _get_db(request)
|
|
char = db.execute(
|
|
"SELECT name, energy FROM characters WHERE id = ?", (character_id,)
|
|
).fetchone()
|
|
if not char:
|
|
db.close()
|
|
return JSONResponse({"error": "character not found"}, status_code=404)
|
|
|
|
from umbraaudio.voices.voice_designer import (
|
|
ATTRIBUTE_PARAM_MAP,
|
|
NARRATOR_CFG_WEIGHT,
|
|
NARRATOR_EXAGGERATION,
|
|
)
|
|
|
|
name_hash = int(hashlib.md5(char["name"].encode()).hexdigest()[:8], 16)
|
|
energy = char["energy"] or "measured"
|
|
if char["name"] == "Narrator":
|
|
exag, cfg = NARRATOR_EXAGGERATION, NARRATOR_CFG_WEIGHT
|
|
elif energy in ATTRIBUTE_PARAM_MAP:
|
|
exag_range, cfg_range = ATTRIBUTE_PARAM_MAP[energy]
|
|
exag = exag_range[0] + ((name_hash % 100) / 100.0) * (exag_range[1] - exag_range[0])
|
|
cfg = cfg_range[0] + (((name_hash >> 8) % 100) / 100.0) * (cfg_range[1] - cfg_range[0])
|
|
else:
|
|
exag, cfg = 0.35, 0.45
|
|
|
|
db.execute("UPDATE characters SET status = 'voiced_approved' WHERE id = ?", (character_id,))
|
|
db.execute(
|
|
"INSERT INTO voice_review_history (character_name, action, resulting_exaggeration, resulting_cfg_weight) VALUES (?, 'approved', ?, ?)",
|
|
(char["name"], exag, cfg),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return {"status": "approved", "character_id": character_id}
|
|
|
|
|
|
@router.post("/api/voices/{character_id}/reject")
|
|
async def reject_voice(character_id: int, request: Request):
|
|
db = _get_db(request)
|
|
char = db.execute("SELECT name FROM characters WHERE id = ?", (character_id,)).fetchone()
|
|
if not char:
|
|
db.close()
|
|
return JSONResponse({"error": "character not found"}, status_code=404)
|
|
|
|
db.execute("UPDATE characters SET status = 'rejected' WHERE id = ?", (character_id,))
|
|
db.execute(
|
|
"INSERT INTO voice_review_history (character_name, action) VALUES (?, 'rejected')",
|
|
(char["name"],),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return {"status": "rejected", "character_id": character_id}
|
|
|
|
|
|
@router.post("/api/voices/{character_id}/edit")
|
|
async def edit_voice_prompt(character_id: int, request: Request, prompt: str = Form(...)):
|
|
db = _get_db(request)
|
|
char = db.execute("SELECT * FROM characters WHERE id = ?", (character_id,)).fetchone()
|
|
if not char:
|
|
db.close()
|
|
return JSONResponse({"error": "character not found"}, status_code=404)
|
|
|
|
db.execute(
|
|
"INSERT INTO voice_review_history (character_name, action, prompt_text) VALUES (?, 'edit_requested', ?)",
|
|
(char["name"], prompt),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return {"status": "edit_queued", "character_id": character_id, "prompt": prompt}
|
|
|
|
|
|
@router.post("/api/chapters/{chapter_id}/approve")
|
|
async def approve_chapter(chapter_id: int, request: Request):
|
|
db = _get_db(request)
|
|
project = db.execute("SELECT id FROM project LIMIT 1").fetchone()
|
|
if project:
|
|
db.execute(
|
|
"INSERT OR REPLACE INTO review_decisions (project_id, entity_type, entity_id, decision) VALUES (?, 'chapter', ?, 'approved')",
|
|
(project["id"], chapter_id),
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return {"status": "approved", "chapter_id": chapter_id}
|
|
|
|
|
|
@router.post("/api/chapters/{chapter_id}/regenerate")
|
|
async def regenerate_chapter(chapter_id: int, request: Request):
|
|
return {"status": "queued", "chapter_id": chapter_id}
|
|
|
|
|
|
@router.post("/api/speakers/voice-status")
|
|
async def set_voice_status(request: Request):
|
|
body = await request.json()
|
|
name = body.get("speaker_name", "").strip()
|
|
status = body.get("status", "pending")
|
|
if not name:
|
|
return JSONResponse({"error": "speaker_name required"}, status_code=400)
|
|
config = _get_active_config(request)
|
|
voice_status_path = config.data_dir / "voice_status.json"
|
|
voice_status = {}
|
|
if voice_status_path.exists():
|
|
with open(voice_status_path) as f:
|
|
voice_status = json.load(f)
|
|
voice_status[name] = status
|
|
with open(voice_status_path, "w") as f:
|
|
json.dump(voice_status, f, indent=2)
|
|
|
|
distortion_effects = body.get("distortion_effects", [])
|
|
if distortion_effects:
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
sp_data = json.load(f)
|
|
for sp in sp_data.get("speakers", []):
|
|
if sp.get("primary_name") == name:
|
|
sp["distortion_effects"] = distortion_effects
|
|
break
|
|
with open(speakers_path, "w") as f:
|
|
json.dump(sp_data, f, indent=2)
|
|
|
|
if status == "approved":
|
|
registry_path = config.voice_registry_path
|
|
voices_dir = config.data_dir / "voices"
|
|
ref_path = voices_dir / f"{name.lower().replace(' ', '_')}_ref.wav"
|
|
if registry_path.exists() and ref_path.exists():
|
|
try:
|
|
# Capture the approved voice's vmatrix so every utterance reuses
|
|
# the identical voice identity, not just a coarse x-vector.
|
|
# The vmatrix (.pt: ICL "8k matrix" ref_code + speaker embedding)
|
|
# is the set of numbers inside Qwen3-TTS that IS the voice; it is
|
|
# captured server-side under data_dir/voice_prompts/{slug}.pt.
|
|
vmatrix_path = None
|
|
try:
|
|
from umbraaudio.voices.qwen_tts import (
|
|
VOICE_REFERENCE_TEXT,
|
|
QwenTTSEngine,
|
|
)
|
|
|
|
engine = QwenTTSEngine(
|
|
qwen_tts_url=config.tts.qwen_tts_url,
|
|
helper_url=config.tts.qwen_tts_helper_url,
|
|
)
|
|
slug = engine.build_voice_prompt(
|
|
ref_path,
|
|
ref_text=VOICE_REFERENCE_TEXT,
|
|
x_vector_only=False,
|
|
)
|
|
candidate = config.data_dir / "voice_prompts" / f"{slug}.pt"
|
|
if candidate.exists():
|
|
vmatrix_path = str(candidate)
|
|
except Exception as e:
|
|
logger.warning("Failed to capture vmatrix for approved voice %s: %s", name, e)
|
|
|
|
rdb = sqlite3.connect(str(registry_path))
|
|
rdb.row_factory = sqlite3.Row
|
|
# ensure vmatrix_path column exists on legacy databases
|
|
has_vmatrix = any(
|
|
r["name"] == "vmatrix_path"
|
|
for r in rdb.execute("PRAGMA table_info(voice_registry)").fetchall()
|
|
)
|
|
if not has_vmatrix:
|
|
rdb.execute("ALTER TABLE voice_registry ADD COLUMN vmatrix_path TEXT")
|
|
existing = rdb.execute(
|
|
"SELECT id FROM voice_registry WHERE LOWER(character_name) = LOWER(?)",
|
|
(name,),
|
|
).fetchone()
|
|
if existing:
|
|
rdb.execute(
|
|
"UPDATE voice_registry SET status='voiced', voice_ref_path=?, vmatrix_path=?, updated_at=datetime('now') WHERE id=?",
|
|
(str(ref_path), vmatrix_path, existing["id"]),
|
|
)
|
|
else:
|
|
rdb.execute(
|
|
"INSERT INTO voice_registry (character_name, status, voice_ref_path, vmatrix_path, source_project) VALUES (?, 'voiced', ?, ?, ?)",
|
|
(name, str(ref_path), vmatrix_path, config.project_dir.name),
|
|
)
|
|
rdb.commit()
|
|
rdb.close()
|
|
except Exception as e:
|
|
logger.warning("Failed to save approved voice to registry: %s", e)
|
|
|
|
return JSONResponse({"ok": True, "speaker_name": name, "status": status})
|
|
|
|
|
|
@router.post("/api/speakers/regenerate-voice")
|
|
async def regenerate_speaker_voice(request: Request):
|
|
"""Deprecated: use POST /api/voices/queue.
|
|
|
|
Regeneration moved to a queue so a second speaker can be enqueued while one
|
|
is generating, and so the UI can tag each row instead of showing a modal.
|
|
This endpoint ran its own thread and refused a second request with a 409,
|
|
which is exactly what the queue replaces. Kept as a thin shim so any
|
|
bookmarked call keeps working.
|
|
"""
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
body = await request.json()
|
|
name = (body.get("speaker_name") or "").strip()
|
|
if not name:
|
|
return JSONResponse({"error": "speaker_name required"}, status_code=400)
|
|
|
|
from umbraaudio.web.voice_queue_routes import get_voice_queue
|
|
|
|
item = get_voice_queue(request).enqueue(name, row_index=body.get("row_index"))
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"speaker_name": name,
|
|
"deprecated": True,
|
|
"queued": True,
|
|
"item": item,
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/api/speakers/generate-all-voices")
|
|
async def generate_all_speaker_voices(request: Request):
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active():
|
|
return JSONResponse(
|
|
{"error": "Generation already in progress", "active": True}, status_code=409
|
|
)
|
|
|
|
def _generate_all():
|
|
import json as _json
|
|
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
if not speakers_path.exists():
|
|
logger.error("No speakers.json found")
|
|
return
|
|
with open(speakers_path) as f:
|
|
speakers = _json.load(f).get("speakers", [])
|
|
|
|
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
|
|
|
|
designer = VoiceDesigner(config.tts)
|
|
total = len(speakers)
|
|
|
|
with gm.serialize("voices", total, "voice:bulk"):
|
|
generated = 0
|
|
for i, s in enumerate(speakers):
|
|
name = s["primary_name"]
|
|
profile = get_profile_for_speaker(name, s, config.data_dir, force_rebuild=True)
|
|
|
|
logger.info("[%d/%d] Generating voice for %s", generated + 1, total, name)
|
|
try:
|
|
designer.generate_voice_reference(
|
|
character=profile,
|
|
output_dir=voices_dir,
|
|
sample_text="When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.",
|
|
)
|
|
generated += 1
|
|
gm.update_item(name, success=True)
|
|
except Exception as e:
|
|
gm.update_item(name, success=False, error=str(e))
|
|
logger.error(" FAILED %s: %s", name, e)
|
|
|
|
logger.info("Generated %d/%d voice references", generated, total)
|
|
|
|
threading.Thread(target=_generate_all, daemon=True).start()
|
|
return JSONResponse({"ok": True, "detail": "Voice generation started in background"})
|
|
|
|
|
|
@router.post("/api/generate-audiobook")
|
|
async def generate_full_audiobook(request: Request):
|
|
if (blocked := _voice_maintenance_block()) is not None:
|
|
return blocked
|
|
|
|
import threading
|
|
import time as _time
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active():
|
|
return JSONResponse(
|
|
{"error": "Generation already in progress", "active": True}, status_code=409
|
|
)
|
|
|
|
def _write_status(
|
|
step,
|
|
state,
|
|
detail="",
|
|
progress=0,
|
|
error="",
|
|
total_chapters=0,
|
|
chapters_completed=None,
|
|
current_chapter=0,
|
|
completed_utterances=0,
|
|
total_utterances=0,
|
|
):
|
|
payload = {
|
|
"step": step,
|
|
"state": state,
|
|
"detail": detail,
|
|
"progress": progress,
|
|
"error": error,
|
|
"total_chapters": total_chapters,
|
|
"chapters_completed": chapters_completed or [],
|
|
"current_chapter": current_chapter,
|
|
"completed_utterances": completed_utterances,
|
|
"total_utterances": total_utterances,
|
|
}
|
|
status_path = config.data_dir / "pipeline_status.json"
|
|
with open(status_path, "w") as f:
|
|
json.dump(payload, f)
|
|
|
|
_last_write = {"time": 0.0}
|
|
|
|
def _status_callback(
|
|
step,
|
|
state,
|
|
detail,
|
|
progress,
|
|
total_chapters=0,
|
|
chapters_completed=None,
|
|
current_chapter=0,
|
|
completed_utterances=0,
|
|
total_utterances=0,
|
|
):
|
|
now = _time.time()
|
|
if state == "running" and now - _last_write["time"] < 5.0:
|
|
return
|
|
_last_write["time"] = now
|
|
_write_status(
|
|
step,
|
|
state,
|
|
detail,
|
|
progress,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=chapters_completed or [],
|
|
current_chapter=current_chapter,
|
|
completed_utterances=completed_utterances,
|
|
total_utterances=total_utterances,
|
|
)
|
|
|
|
def _generate():
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from scripts.generate_all_audio import main as gen_main
|
|
|
|
try:
|
|
_write_status("generate", "running", "Starting audio generation...", 0)
|
|
gen_main(status_callback=_status_callback, gen_manager=gm)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("Audio generation failed: %s\n%s", e, traceback.format_exc())
|
|
_write_status("generate", "failed", str(e), 0, error=str(e))
|
|
|
|
threading.Thread(target=_generate, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Full audiobook generation started"})
|
|
|
|
|
|
@router.post("/api/audiobook/export-m4b")
|
|
async def export_full_m4b(request: Request):
|
|
"""Build the final full-book .m4b from the per-chapter ``chN_full.wav``
|
|
files, embedding the publishing metadata from ``m4b_meta.json`` and the
|
|
cover art. Runs a pre-export quality check and reports status, warnings,
|
|
and errors to the pipeline status so the Step 6 UI can display them."""
|
|
import threading
|
|
import time as _time
|
|
|
|
config = _get_active_config(request)
|
|
pm = _get_pipeline(request)
|
|
gm = _get_gen_manager(request)
|
|
|
|
if gm.is_active() or pm.is_running:
|
|
return JSONResponse({"error": "A generation/pipeline is already running"}, status_code=409)
|
|
|
|
from umbraaudio.audiobook_meta import load_meta
|
|
from umbraaudio.assembly import ChapterQualityChecker
|
|
from umbraaudio.assembly.export import AudiobookExporter
|
|
from umbraaudio.pipeline_manager import PipelineState, PipelineStep
|
|
|
|
def _run() -> None:
|
|
try:
|
|
pm.update(
|
|
step=PipelineStep.EXPORT,
|
|
state=PipelineState.RUNNING,
|
|
progress=5,
|
|
detail="Preparing full-book export...",
|
|
error="",
|
|
warnings=[],
|
|
errors=[],
|
|
)
|
|
|
|
# --- Load publishing metadata -------------------------------------
|
|
meta = load_meta(config.data_dir)
|
|
|
|
# --- Gather chapter wavs (chN_full.wav) in book order ------------
|
|
audio_dir = config.data_dir / "chapter_audio"
|
|
if not audio_dir.exists():
|
|
raise RuntimeError("chapter_audio directory missing")
|
|
chapter_files = []
|
|
for p in audio_dir.glob("ch*_full.wav"):
|
|
num = p.stem[len("ch") :].split("_")[0]
|
|
if not num.isdigit():
|
|
continue
|
|
chapter_files.append((int(num), p))
|
|
if not chapter_files:
|
|
raise RuntimeError("No chN_full.wav chapter files found")
|
|
chapter_files = [p for _, p in sorted(chapter_files)]
|
|
|
|
# --- Chapter titles from DB ---------------------------------------
|
|
pm.update(
|
|
progress=15, detail=f"Found {len(chapter_files)} chapters; running quality check..."
|
|
)
|
|
import sqlite3 as _sqlite3
|
|
|
|
db = _sqlite3.connect(str(config.data_dir / "project.db"))
|
|
rows = db.execute("SELECT id, title FROM chapters").fetchall()
|
|
db.close()
|
|
title_by_id = {r[0]: (r[1] or "") for r in rows}
|
|
chapter_titles = {}
|
|
for cf in chapter_files:
|
|
ch_num = int(cf.stem[len("ch") :].split("_")[0])
|
|
chapter_titles[cf.name] = title_by_id.get(ch_num) or f"Chapter {ch_num}"
|
|
|
|
# --- Quality check: collect errors/warnings -----------------------
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
known = None
|
|
if speakers_path.exists():
|
|
import json as _json
|
|
|
|
with open(speakers_path) as f:
|
|
known = [s["primary_name"] for s in _json.load(f).get("speakers", [])]
|
|
checker = ChapterQualityChecker(config.data_dir, known)
|
|
results = checker.check_all_chapters()
|
|
flat = [
|
|
(ch, issue.severity, issue.message)
|
|
for ch, issues in results.items()
|
|
for issue in issues
|
|
]
|
|
errors = [f"ch{ch}: {msg}" for ch, sev, msg in flat if sev == "error"]
|
|
warnings = [f"ch{ch}: {msg}" for ch, sev, msg in flat if sev == "warning"]
|
|
|
|
if errors:
|
|
pm.update(
|
|
step=PipelineStep.EXPORT,
|
|
state=PipelineState.FAILED,
|
|
detail=f"Export blocked by {len(errors)} quality error(s)",
|
|
progress=100,
|
|
error=f"{len(errors)} quality error(s); fix before export",
|
|
warnings=warnings,
|
|
errors=errors,
|
|
)
|
|
return
|
|
|
|
# --- Cover ----------------------------------------------------------
|
|
cover_path = None
|
|
raw_cover = meta.get("cover_path", "")
|
|
if raw_cover:
|
|
p = Path(config.data_dir).parent / raw_cover
|
|
if not p.exists():
|
|
p = config.data_dir / raw_cover
|
|
if p.exists():
|
|
cover_path = p
|
|
|
|
out_name = meta.get("output_name") or "audiobook.m4b"
|
|
out_path = config.export_dir / out_name
|
|
meta["chapter_titles"] = chapter_titles
|
|
meta["chapter_gap_ms"] = 3000
|
|
|
|
pm.update(progress=40, detail="Assembling and encoding full-book M4B...")
|
|
exporter = AudiobookExporter()
|
|
exporter.export_full_book_m4b(chapter_files, out_path, meta=meta, cover_path=cover_path)
|
|
|
|
pm.update(
|
|
step=PipelineStep.EXPORT,
|
|
state=PipelineState.COMPLETED,
|
|
progress=100,
|
|
detail=f"Exported {out_name} ({len(chapter_files)} chapters)"
|
|
+ (f", {len(warnings)} warning(s)" if warnings else ""),
|
|
error="",
|
|
warnings=warnings,
|
|
errors=errors,
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("M4B export failed: %s\n%s", e, traceback.format_exc())
|
|
pm.update(
|
|
step=PipelineStep.EXPORT,
|
|
state=PipelineState.FAILED,
|
|
progress=0,
|
|
detail=f"Export failed: {e}",
|
|
error=str(e),
|
|
)
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Full-book M4B export started"})
|
|
|
|
|
|
@router.post("/api/audiobook/export-mp3")
|
|
async def export_ar_mp3_package(request: Request):
|
|
"""Build the Author's Republic MP3 submission package (sample track, one
|
|
track per chapter, closing track) into ``export/ar_package/``.
|
|
|
|
Mirrors the M4B export UX: runs in a background thread and reports progress
|
|
via ``export/ar_build_status.json``, polled by the dashboard. Every track
|
|
comes from the chapters' own audio — no narration is synthesized.
|
|
"""
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
pm = _get_pipeline(request)
|
|
|
|
if gm.is_active() or pm.is_running:
|
|
return JSONResponse({"error": "A generation/pipeline is already running"}, status_code=409)
|
|
|
|
from umbraaudio.assembly.ar_export import ARPackageExporter, discover_chapters
|
|
from umbraaudio.audiobook_meta import load_meta
|
|
|
|
status_path = config.export_dir / "ar_build_status.json"
|
|
config.export_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _write_status(state: str, detail: str, progress: int = 0, error: str = ""):
|
|
payload = {"state": state, "detail": detail, "progress": progress, "error": error}
|
|
status_path.write_text(json.dumps(payload))
|
|
|
|
def _run() -> None:
|
|
try:
|
|
_write_status("running", "Preparing AR package...", 5)
|
|
meta = load_meta(config.data_dir)
|
|
missing = [
|
|
f for f in ("title", "author", "narrator") if not str(meta.get(f, "")).strip()
|
|
]
|
|
if missing:
|
|
_write_status(
|
|
"failed",
|
|
f"m4b-meta is missing required fields: {', '.join(missing)}",
|
|
0,
|
|
error="Fill title/author/narrator on the m4b Meta page first.",
|
|
)
|
|
return
|
|
|
|
chapters = discover_chapters(config.data_dir)
|
|
with_audio = [c for c in chapters if c.audio_path is not None]
|
|
if not with_audio:
|
|
raise RuntimeError("No chapter audio found — run audio generation first")
|
|
|
|
exporter = ARPackageExporter(config.data_dir)
|
|
manifest = exporter.build(
|
|
chapters,
|
|
meta,
|
|
progress_cb=lambda p, d: _write_status("running", d, p),
|
|
)
|
|
warnings = manifest.get("warnings", [])
|
|
_write_status(
|
|
"completed",
|
|
"Built {} tracks ({:.1f} h)".format(
|
|
manifest["track_count"], manifest["total_duration_s"] / 3600
|
|
)
|
|
+ (f", {len(warnings)} warning(s)" if warnings else ""),
|
|
100,
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("AR MP3 export failed: %s\n%s", e, traceback.format_exc())
|
|
_write_status("failed", f"AR package build failed: {e}", 0, error=str(e))
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Author's Republic MP3 package build started"})
|
|
|
|
|
|
@router.get("/api/audiobook/ar-status")
|
|
async def ar_package_status(request: Request):
|
|
config = _get_active_config(request)
|
|
status_path = config.export_dir / "ar_build_status.json"
|
|
if status_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
return JSONResponse(json.loads(status_path.read_text()))
|
|
return JSONResponse(
|
|
{"state": "idle", "detail": "No AR package build yet", "progress": 0, "error": ""}
|
|
)
|
|
|
|
|
|
@router.get("/api/audiobook/ar-files")
|
|
async def ar_package_files(request: Request):
|
|
config = _get_active_config(request)
|
|
manifest_path = config.export_dir / "ar_package.json"
|
|
manifest = None
|
|
if manifest_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
manifest = json.loads(manifest_path.read_text())
|
|
files = []
|
|
pkg = config.export_dir / "ar_package"
|
|
if pkg.exists():
|
|
for f in sorted(pkg.glob("*.mp3")):
|
|
rel = f.relative_to(config.export_dir)
|
|
files.append(
|
|
{
|
|
"name": str(rel),
|
|
"url": f"/api/export/download/{rel}",
|
|
"size_mb": round(f.stat().st_size / (1024 * 1024), 1),
|
|
}
|
|
)
|
|
return JSONResponse({"manifest": manifest, "files": files})
|
|
|
|
|
|
@router.get("/api/batch/status")
|
|
async def batch_status(request: Request):
|
|
"""Live status of a background stale-voice rebuild batch.
|
|
|
|
Follows a ``rebuild_stale_voices_batch*_stdout.log`` under the repo ``logs/``
|
|
dir and reports which chapter is being regenerated, utterances generated so
|
|
far in it, completed chapters, and last-activity time so the UI can show
|
|
that a batch is actively working (not just idle errors).
|
|
"""
|
|
import os as _os
|
|
import re as _re
|
|
import time as _time
|
|
|
|
repo = Path(__file__).resolve().parent.parent.parent
|
|
logs_dir = repo / "logs"
|
|
payload: dict = {
|
|
"active": False,
|
|
"log": None,
|
|
"state": "idle",
|
|
"current_chapter": None,
|
|
"generated_current": 0,
|
|
"completed_chapters": [],
|
|
"attempted_total": 0,
|
|
"last_activity_ago": None,
|
|
"last_line": None,
|
|
"updated_at": _time.time(),
|
|
"detail": "No active rebuild batch observed",
|
|
}
|
|
if not logs_dir.exists():
|
|
return JSONResponse(payload)
|
|
|
|
candidates = sorted(
|
|
logs_dir.glob("rebuild_stale_voices_batch*_stdout.log"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
if not candidates:
|
|
return JSONResponse(payload)
|
|
|
|
log = candidates[0]
|
|
payload["log"] = log.name
|
|
payload["active"] = True
|
|
|
|
try:
|
|
text = log.read_text(errors="replace")
|
|
except Exception as e:
|
|
payload["detail"] = f"Could not read {log.name}: {e}"
|
|
return JSONResponse(payload)
|
|
|
|
lines = text.splitlines()
|
|
# bookkeeping order
|
|
building = None
|
|
generated = 0
|
|
completed: list[int] = []
|
|
for line in lines:
|
|
m = _re.search(r"===== Building chapter (\d+) =====", line)
|
|
if m:
|
|
building = int(m.group(1))
|
|
generated = 0
|
|
continue
|
|
m = _re.search(r"Chapter (\d+) done:", line)
|
|
if m:
|
|
ch = int(m.group(1))
|
|
completed.append(ch)
|
|
if building == ch:
|
|
building = None
|
|
continue
|
|
if (
|
|
building is not None
|
|
and "Generated Qwen3-TTS audio" in line
|
|
and _re.search(r"/ch%d/utt_" % building, line)
|
|
):
|
|
generated += 1
|
|
|
|
# last activity = age of most recent non-health-poll meaningful write
|
|
mtime = log.stat().st_mtime
|
|
last_activity_ago = _time.time() - mtime
|
|
last_line = lines[-1][:160] if lines else ""
|
|
|
|
finished = "=== Batch complete ===" in text or building is None and completed and generated == 0
|
|
payload.update(
|
|
{
|
|
"state": "running" if not ("=== Batch complete ===" in text) else "complete",
|
|
"current_chapter": building,
|
|
"generated_current": generated,
|
|
"completed_chapters": completed,
|
|
"last_activity_ago": round(last_activity_ago, 1) if last_activity_ago > 0 else 0.0,
|
|
"last_line": last_line,
|
|
"detail": (
|
|
f"Rebuilding chapter {building}"
|
|
+ (f" ({generated} utterances regenerated in this chapter)" if building else "")
|
|
if not ("=== Batch complete ===" in text)
|
|
else "Rebuild batch complete"
|
|
),
|
|
}
|
|
)
|
|
return JSONResponse(payload)
|
|
|
|
|
|
@router.post("/api/analyze")
|
|
async def run_analysis(request: Request):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
status_path = config.data_dir / "analysis_status.json"
|
|
|
|
def _write_status(status: str, detail: str = "", progress: int = 0, error: str = ""):
|
|
with open(status_path, "w") as f:
|
|
json.dump({"status": status, "detail": detail, "progress": progress, "error": error}, f)
|
|
|
|
def _analyze():
|
|
import asyncio
|
|
import sqlite3 as _sqlite3
|
|
|
|
from umbraaudio.analysis.character_discovery import CharacterDiscoverer
|
|
|
|
try:
|
|
_write_status("starting", "Connecting to LLM...", 0)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = _sqlite3.connect(str(db_path))
|
|
db.row_factory = _sqlite3.Row
|
|
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
|
|
chapters = db.execute(
|
|
"SELECT chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
db.close()
|
|
|
|
ch_list = [dict(c) for c in chapters]
|
|
book_title = project["name"]
|
|
total = len(ch_list)
|
|
|
|
_write_status("discovering", f"Analyzing {total} chapters...", 5)
|
|
|
|
discoverer = CharacterDiscoverer(config.llm, data_dir=config.data_dir)
|
|
|
|
original_call = discoverer._call_llm
|
|
|
|
def _tracked_call(
|
|
text,
|
|
context="",
|
|
max_chars=40000,
|
|
known_speakers="",
|
|
pass_context="",
|
|
on_retry=None,
|
|
):
|
|
ch_num = 0
|
|
m = __import__("re").search(r"Chapter (\d+):", context)
|
|
if m:
|
|
ch_num = int(m.group(1))
|
|
pct = int(5 + (ch_num / max(total, 1)) * 85) if ch_num else 10
|
|
_write_status("discovering", f"Analyzing chapter {ch_num}/{total}...", pct)
|
|
|
|
def _retry_status(attempt: int):
|
|
_write_status(
|
|
"discovering",
|
|
f"Analyzing chapter {ch_num}/{total} — LLM retry {attempt}/6...",
|
|
pct,
|
|
)
|
|
|
|
return original_call(
|
|
text,
|
|
context=context,
|
|
max_chars=max_chars,
|
|
known_speakers=known_speakers,
|
|
pass_context=pass_context,
|
|
on_retry=_retry_status,
|
|
)
|
|
|
|
discoverer._call_llm = _tracked_call
|
|
|
|
result = asyncio.run(discoverer.discover_speakers(ch_list, book_title=book_title))
|
|
|
|
_write_status("saving", "Saving results...", 95)
|
|
result.save(speakers_path)
|
|
|
|
ALIAS_MERGE = {
|
|
"Hunter": "Deal Hunter",
|
|
"The Deal Hunter": "Deal Hunter",
|
|
"Weston": "Captain Weston",
|
|
"The captain": "Captain Weston",
|
|
"the captain": "Captain Weston",
|
|
"Princess": "Kainda",
|
|
"the princess": "Kainda",
|
|
"Kainda": "Kainda",
|
|
"Kainda Murphy": "Kainda",
|
|
"Princess Kainda": "Kainda",
|
|
"Vicky": "Aunt Vicky",
|
|
"Aunt Victoria": "Aunt Vicky",
|
|
"Victoria": "Aunt Vicky",
|
|
"the king": "Adric",
|
|
"Captain Wake": "Captain Willamina Wake",
|
|
"Willamina Wake": "Captain Willamina Wake",
|
|
"Duchess": "Duchess Victoria Murphy Wake",
|
|
}
|
|
with open(speakers_path) as f:
|
|
sp_data = json.load(f)
|
|
spkrs = sp_data.get("speakers", [])
|
|
merged = {}
|
|
alias_lower = {k.lower(): v.lower() for k, v in ALIAS_MERGE.items()}
|
|
remove_names = set()
|
|
|
|
def _find_speaker(target_name: str) -> dict | None:
|
|
tl = target_name.lower()
|
|
for s in spkrs:
|
|
if s["primary_name"].lower() == tl:
|
|
return s
|
|
for s in spkrs:
|
|
for a in s.get("aliases", []):
|
|
if a.lower() == tl:
|
|
return s
|
|
return None
|
|
|
|
for s in spkrs:
|
|
name = s["primary_name"]
|
|
nl = name.lower()
|
|
if nl in alias_lower:
|
|
cn = alias_lower[nl]
|
|
existing = _find_speaker(cn)
|
|
if existing and existing["primary_name"] != name:
|
|
remove_names.add(name)
|
|
existing.setdefault("aliases", [])
|
|
if name not in existing["aliases"]:
|
|
existing["aliases"].append(name)
|
|
for a in s.get("aliases", []):
|
|
al = a.lower()
|
|
if al != existing["primary_name"].lower() and al not in {
|
|
x.lower() for x in existing["aliases"]
|
|
}:
|
|
existing["aliases"].append(a)
|
|
s_already = _find_speaker(name)
|
|
if s_already and s_already is not existing:
|
|
remove_names.add(s_already["primary_name"])
|
|
for ch in s_already.get("chapters_appeared", []):
|
|
if ch not in existing.setdefault("chapters_appeared", []):
|
|
existing["chapters_appeared"].append(ch)
|
|
for a in s_already.get("aliases", []):
|
|
al = a.lower()
|
|
if al != existing["primary_name"].lower() and al not in {
|
|
x.lower() for x in existing["aliases"]
|
|
}:
|
|
existing["aliases"].append(a)
|
|
continue
|
|
merged[name] = s
|
|
sp_data["speakers"] = [
|
|
s for s in merged.values() if s["primary_name"] not in remove_names
|
|
]
|
|
with open(speakers_path, "w") as f:
|
|
json.dump(sp_data, f, indent=2)
|
|
|
|
pipeline_path = config.data_dir / "pipeline_status.json"
|
|
with open(pipeline_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": "discover",
|
|
"status": "complete",
|
|
"detail": f"Found {len(result.speakers)} speakers",
|
|
"progress": 100,
|
|
},
|
|
f,
|
|
)
|
|
|
|
_write_status("complete", f"Found {len(result.speakers)} speakers", 100)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("Analysis failed: %s\n%s", e, traceback.format_exc())
|
|
_write_status("error", str(e), 0, error=str(e))
|
|
|
|
_write_status("queued", "Analysis queued...")
|
|
threading.Thread(target=_analyze, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Character discovery started"})
|
|
|
|
|
|
@router.get("/api/analysis-status")
|
|
async def analysis_status(request: Request):
|
|
config = _get_active_config(request)
|
|
status_path = config.data_dir / "analysis_status.json"
|
|
if not status_path.exists():
|
|
return JSONResponse({"status": "idle"})
|
|
with open(status_path) as f:
|
|
data = json.load(f)
|
|
return JSONResponse(data)
|
|
|
|
|
|
@router.get("/api/pipeline/status")
|
|
async def pipeline_status(request: Request):
|
|
pm = _get_pipeline(request)
|
|
progress = pm.load_status()
|
|
return JSONResponse(progress.to_dict())
|
|
|
|
|
|
@router.post("/api/pipeline/status")
|
|
async def update_pipeline_status(request: Request):
|
|
body = await request.json()
|
|
pm = _get_pipeline(request)
|
|
from umbraaudio.pipeline_manager import PipelineState, PipelineStep
|
|
|
|
pm.update(
|
|
step=PipelineStep(body.get("step", "upload")) if "step" in body else None,
|
|
state=PipelineState(body["status"]) if "status" in body else None,
|
|
detail=body.get("detail"),
|
|
progress=body.get("progress"),
|
|
error=body.get("error"),
|
|
)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.get("/api/generation/status")
|
|
async def generation_status(request: Request):
|
|
gm = _get_gen_manager(request)
|
|
status = gm.get_status()
|
|
if gm.was_interrupted():
|
|
status["interrupted"] = True
|
|
return JSONResponse(status)
|
|
|
|
|
|
@router.post("/api/pipeline/start")
|
|
async def start_pipeline(request: Request):
|
|
pm = _get_pipeline(request)
|
|
if pm.is_running:
|
|
return JSONResponse({"error": "Pipeline already running"}, status_code=409)
|
|
|
|
async def _run(pipeline_manager: Any) -> None:
|
|
from umbraaudio.orchestrator import run_pipeline_background
|
|
|
|
await run_pipeline_background(pipeline_manager, _get_active_config(request))
|
|
|
|
pm.start(_run)
|
|
return JSONResponse({"ok": True, "detail": "Pipeline started"})
|
|
|
|
|
|
@router.post("/api/pipeline/pause")
|
|
async def pause_pipeline(request: Request):
|
|
pm = _get_pipeline(request)
|
|
pm.pause()
|
|
return JSONResponse({"ok": True, "detail": "Pipeline paused"})
|
|
|
|
|
|
@router.post("/api/pipeline/resume")
|
|
async def resume_pipeline(request: Request):
|
|
pm = _get_pipeline(request)
|
|
if not pm.is_paused:
|
|
return JSONResponse({"error": "Pipeline not paused"}, status_code=409)
|
|
|
|
async def _run(pipeline_manager: Any) -> None:
|
|
from umbraaudio.orchestrator import run_pipeline_background
|
|
|
|
await run_pipeline_background(pipeline_manager, _get_active_config(request))
|
|
|
|
pm.resume(_run)
|
|
return JSONResponse({"ok": True, "detail": "Pipeline resumed"})
|
|
|
|
|
|
@router.post("/api/pipeline/cancel")
|
|
async def cancel_pipeline(request: Request):
|
|
pm = _get_pipeline(request)
|
|
pm.cancel()
|
|
return JSONResponse({"ok": True, "detail": "Pipeline cancelled"})
|
|
|
|
|
|
@router.get("/registry-audio/{registry_id}/{filename}")
|
|
async def serve_registry_audio(request: Request, registry_id: int, filename: str):
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
if not registry:
|
|
return JSONResponse({"error": "registry not found"}, status_code=404)
|
|
row = registry.execute(
|
|
"SELECT voice_ref_path FROM voice_registry WHERE id = ?", (registry_id,)
|
|
).fetchone()
|
|
registry.close()
|
|
if not row or not row["voice_ref_path"]:
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
audio_path = Path(row["voice_ref_path"])
|
|
if not audio_path.exists():
|
|
return JSONResponse({"error": "file not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
@router.post("/api/pipeline/reset")
|
|
async def reset_pipeline(request: Request, step: str = "discover"):
|
|
import shutil
|
|
|
|
config = _get_active_config(request)
|
|
|
|
if step == "discover":
|
|
for f in (
|
|
config.data_dir / "speakers.json",
|
|
config.data_dir / "voice_assignments.json",
|
|
config.data_dir / "phonetic_names.json",
|
|
config.data_dir / "analysis_status.json",
|
|
):
|
|
if f.exists():
|
|
f.unlink()
|
|
voices_dir = config.data_dir / "voices"
|
|
if voices_dir.exists():
|
|
shutil.rmtree(voices_dir)
|
|
elif step == "voices":
|
|
voices_dir = config.data_dir / "voices"
|
|
if voices_dir.exists():
|
|
shutil.rmtree(voices_dir)
|
|
elif step == "segment":
|
|
db_path = config.data_dir / "project.db"
|
|
db = sqlite3.connect(str(db_path))
|
|
db.execute("DELETE FROM generated_audio")
|
|
db.execute("DELETE FROM utterances")
|
|
db.commit()
|
|
db.close()
|
|
|
|
pipeline_path = config.data_dir / "pipeline_status.json"
|
|
if pipeline_path.exists():
|
|
pipeline_path.unlink()
|
|
|
|
return JSONResponse({"ok": True, "step": step, "detail": f"Reset {step} step"})
|
|
|
|
|
|
@router.post("/api/pipeline/run-all")
|
|
async def run_full_pipeline(request: Request):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
gm = _get_gen_manager(request)
|
|
status_path = config.data_dir / "pipeline_status.json"
|
|
|
|
def _write_status(step: str, status: str, detail: str = "", progress: int = 0, error: str = ""):
|
|
with open(status_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": step,
|
|
"status": status,
|
|
"detail": detail,
|
|
"progress": progress,
|
|
"error": error,
|
|
},
|
|
f,
|
|
)
|
|
|
|
def _run_all():
|
|
try:
|
|
_write_status("discover", "starting", "Starting character discovery...", 0)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
import sqlite3 as _sqlite3
|
|
|
|
db = _sqlite3.connect(str(db_path))
|
|
db.row_factory = _sqlite3.Row
|
|
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
|
|
chapters_db = db.execute(
|
|
"SELECT chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
db.close()
|
|
|
|
ch_list = [dict(c) for c in chapters_db]
|
|
book_title = project["name"]
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
|
|
import asyncio
|
|
|
|
# --- Step 1: Discovery ---
|
|
_write_status("discover", "generating", "Analyzing chapters...", 5)
|
|
from umbraaudio.analysis.character_discovery import CharacterDiscoverer
|
|
|
|
discoverer = CharacterDiscoverer(config.llm, data_dir=config.data_dir)
|
|
result = asyncio.run(discoverer.discover_speakers(ch_list, book_title=book_title))
|
|
_write_status("discover", "saving", "Saving speakers...", 90)
|
|
result.save(speakers_path)
|
|
_write_status("discover", "complete", f"Found {len(result.speakers)} speakers", 100)
|
|
|
|
# --- Step 2: Voice Generation ---
|
|
_write_status("voices", "starting", "Starting voice generation...", 0)
|
|
from umbraaudio.voices.voice_designer import VoiceDesigner
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voices_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(speakers_path) as f:
|
|
speakers = json.load(f).get("speakers", [])
|
|
|
|
from umbraaudio.voices.voice_profile_manager import get_profile_for_speaker
|
|
|
|
designer = VoiceDesigner(config.tts)
|
|
total_speakers = len(speakers)
|
|
|
|
with gm.serialize("voices", total_speakers, "pipeline:voices"):
|
|
generated_voices = 0
|
|
|
|
for i, s in enumerate(speakers):
|
|
name = s["primary_name"]
|
|
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
|
|
ref_path = voices_dir / ref_name
|
|
|
|
if ref_path.exists():
|
|
generated_voices += 1
|
|
gm.update_item(name, success=True)
|
|
continue
|
|
|
|
pct = int(10 + (i / max(total_speakers, 1)) * 80)
|
|
_write_status(
|
|
"voices",
|
|
"generating",
|
|
f"Generating voice {i + 1}/{total_speakers}: {name}",
|
|
pct,
|
|
)
|
|
|
|
profile = get_profile_for_speaker(name, s, config.data_dir, force_rebuild=True)
|
|
try:
|
|
designer.generate_voice_reference(
|
|
character=profile,
|
|
output_dir=voices_dir,
|
|
sample_text="When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.",
|
|
)
|
|
generated_voices += 1
|
|
gm.update_item(name, success=True)
|
|
except Exception as e:
|
|
gm.update_item(name, success=False, error=str(e))
|
|
logger.error("Voice gen failed for %s: %s", name, e)
|
|
|
|
_write_status(
|
|
"voices",
|
|
"complete",
|
|
f"Generated {generated_voices}/{total_speakers} voice references",
|
|
100,
|
|
)
|
|
|
|
# --- Step 3: Segmentation ---
|
|
_write_status("segment", "starting", "Starting segmentation...", 0)
|
|
from umbraaudio.analysis.llm_attributor import LLMAttributor
|
|
from umbraaudio.mapping.segmenter import Segmenter
|
|
|
|
db = _sqlite3.connect(str(db_path))
|
|
db.row_factory = _sqlite3.Row
|
|
chapters_db = db.execute(
|
|
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
|
|
with open(speakers_path) as f:
|
|
character_names = [s["primary_name"] for s in json.load(f).get("speakers", [])]
|
|
|
|
segmenter = Segmenter()
|
|
total_ch = len(chapters_db)
|
|
|
|
for idx, ch in enumerate(chapters_db):
|
|
ch_id = ch["id"]
|
|
text = ch["source_text"] or ""
|
|
title = ch["title"] or f"Chapter {ch['chapter_index']}"
|
|
pct = int(5 + (idx / max(total_ch, 1)) * 60)
|
|
_write_status(
|
|
"segment",
|
|
"segmenting",
|
|
f"Segmenting chapter {idx + 1}/{total_ch}: {title}",
|
|
pct,
|
|
)
|
|
|
|
db.execute("DELETE FROM utterances WHERE chapter_id=?", (ch_id,))
|
|
db.commit()
|
|
|
|
if not text.strip():
|
|
continue
|
|
|
|
utterances = segmenter.segment(
|
|
text, ch_id, character_names, chapter_title=ch["title"]
|
|
)
|
|
for u in utterances:
|
|
speaker = u.get("character_name") or u.get("speaker_name", "narrator")
|
|
utt_idx = u.get("index", u.get("utterance_index", 0))
|
|
db.execute(
|
|
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name) VALUES (?, ?, ?, ?, ?)",
|
|
(ch_id, utt_idx, u.get("text", ""), u.get("type", "narration"), speaker),
|
|
)
|
|
db.commit()
|
|
|
|
_write_status("segment", "attributing", "Running LLM attribution...", 65)
|
|
|
|
try:
|
|
attributor = LLMAttributor(config.llm, data_dir=config.data_dir)
|
|
for idx, ch in enumerate(chapters_db):
|
|
ch_id = ch["id"]
|
|
rows = db.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
utts = [dict(r) for r in rows]
|
|
attributed = attributor.attribute_speakers(
|
|
utts,
|
|
ch.get("title", ""),
|
|
ch_id,
|
|
)
|
|
from umbraaudio.analysis.clarity import (
|
|
assess_utterance_clarity,
|
|
)
|
|
|
|
try:
|
|
alias_map: dict[str, str] = {}
|
|
with open(speakers_path) as f:
|
|
for s in json.load(f).get("speakers", []):
|
|
primary = s.get("primary_name", "")
|
|
if primary:
|
|
alias_map[primary.lower()] = primary
|
|
for a in s.get("aliases", []):
|
|
if a:
|
|
alias_map[a.lower()] = primary
|
|
attributed = assess_utterance_clarity(attributed, ch_id, alias_map)
|
|
except Exception:
|
|
pass
|
|
for u in attributed:
|
|
db.execute(
|
|
"UPDATE utterances SET speaker_name=?, "
|
|
"attribution_confidence=?, attribution_reasoning=?, "
|
|
"attribution_evidence_kind=?, attribution_tag_name=?, "
|
|
"attribution_uncertain=?, needs_review=?, review_reason=? "
|
|
"WHERE id=?",
|
|
(
|
|
u.get("speaker_name", "Narrator"),
|
|
u.get("attribution_confidence", 0.5),
|
|
u.get("attribution_reasoning", "") or "",
|
|
u.get("attribution_evidence_kind"),
|
|
u.get("attribution_tag_name"),
|
|
1 if u.get("attribution_uncertain") else 0,
|
|
1 if u.get("needs_review") else 0,
|
|
u.get("review_reason") or "",
|
|
u["id"],
|
|
),
|
|
)
|
|
db.commit()
|
|
pct = int(65 + (idx / max(total_ch, 1)) * 30)
|
|
_write_status(
|
|
"segment",
|
|
"attributing",
|
|
f"Attributing chapter {idx + 1}/{total_ch}...",
|
|
pct,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("LLM attribution failed: %s", e)
|
|
|
|
db.close()
|
|
_write_status("segment", "complete", "Segmentation complete", 100)
|
|
|
|
# --- Step 4: Audio Generation ---
|
|
_write_status("generate", "starting", "Starting audio generation...", 0)
|
|
import sys as _sys
|
|
|
|
_sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from scripts.generate_all_audio import main as gen_main
|
|
|
|
gen_main(gen_manager=gm)
|
|
_write_status("generate", "complete", "Audio generation complete", 100)
|
|
|
|
# --- Step 5: Export ---
|
|
_write_status("export", "starting", "Exporting audiobook...", 0)
|
|
from umbraaudio.assembly.export import AudiobookExporter
|
|
|
|
exporter = AudiobookExporter()
|
|
audio_dir = config.data_dir / "chapter_audio"
|
|
if not audio_dir.exists():
|
|
audio_dir = config.data_dir / "audio_normalized"
|
|
if not audio_dir.exists():
|
|
audio_dir = config.data_dir / "audio"
|
|
|
|
_write_status("export", "Running pre-export quality check...", 85)
|
|
|
|
from umbraaudio.assembly import ChapterQualityChecker
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
known_speakers = None
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
sp_data = json.load(f)
|
|
known_speakers = [s["primary_name"] for s in sp_data.get("speakers", [])]
|
|
|
|
checker = ChapterQualityChecker(config.data_dir, known_speakers)
|
|
quality_results = checker.check_all_chapters()
|
|
total_errors = sum(
|
|
1 for issues in quality_results.values() for i in issues if i.severity == "error"
|
|
)
|
|
total_warnings = sum(
|
|
1 for issues in quality_results.values() for i in issues if i.severity == "warning"
|
|
)
|
|
|
|
if total_errors > 0:
|
|
_write_status(
|
|
"export",
|
|
"complete",
|
|
f"Export skipped: {total_errors} quality errors, {total_warnings} warnings across {len(quality_results)} chapters",
|
|
100,
|
|
)
|
|
logger.warning(
|
|
"Export aborted: %d errors, %d warnings", total_errors, total_warnings
|
|
)
|
|
return
|
|
|
|
chapter_files = sorted(audio_dir.glob("*/utt_*.wav"))
|
|
|
|
if chapter_files:
|
|
m4b_path = config.export_dir / "audiobook.m4b"
|
|
try:
|
|
exporter.export_m4b(
|
|
chapter_files,
|
|
m4b_path,
|
|
title=book_title,
|
|
author="",
|
|
)
|
|
except Exception as e:
|
|
logger.error("M4B export failed: %s", e)
|
|
|
|
mp3_files = exporter.export_mp3(chapter_files, config.export_dir / "mp3")
|
|
_write_status(
|
|
"export",
|
|
"complete",
|
|
f"Exported M4B + {len(mp3_files)} MP3 chapters",
|
|
100,
|
|
)
|
|
else:
|
|
_write_status("export", "complete", "No audio files to export", 100)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error("Pipeline failed: %s\n%s", e, traceback.format_exc())
|
|
_write_status("pipeline", "error", str(e), 0, error=str(e))
|
|
|
|
_write_status("pipeline", "queued", "Full pipeline queued...")
|
|
threading.Thread(target=_run_all, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Full pipeline started"})
|
|
|
|
|
|
@router.get("/api/export/files")
|
|
async def list_export_files(request: Request):
|
|
config = _get_active_config(request)
|
|
export_dir = config.export_dir
|
|
files = []
|
|
if export_dir.exists():
|
|
for f in sorted(export_dir.rglob("*")):
|
|
if f.is_file():
|
|
rel = f.relative_to(export_dir)
|
|
size_mb = f.stat().st_size / (1024 * 1024)
|
|
files.append(
|
|
{
|
|
"name": str(rel),
|
|
"size_mb": round(size_mb, 1),
|
|
"url": f"/api/export/download/{rel}",
|
|
}
|
|
)
|
|
return JSONResponse({"files": files})
|
|
|
|
|
|
@router.get("/api/export/download/{path:path}")
|
|
async def download_export_file(request: Request, path: str):
|
|
config = _get_active_config(request)
|
|
file_path = (config.export_dir / path).resolve()
|
|
if not file_path.exists() or not file_path.is_file():
|
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
|
if not str(file_path).startswith(str(config.export_dir.resolve())):
|
|
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
media_type = (
|
|
"audio/m4b"
|
|
if file_path.suffix == ".m4b"
|
|
else "audio/mpeg"
|
|
if file_path.suffix == ".mp3"
|
|
else "application/octet-stream"
|
|
)
|
|
return FileResponse(str(file_path), media_type=media_type, filename=file_path.name)
|
|
|
|
|
|
@router.get("/api/export/download-package.zip")
|
|
async def download_mp3_package(request: Request):
|
|
"""Download the whole AR MP3 package as one ZIP.
|
|
|
|
The per-track MP3 links exist, but a distributor wants every track plus the
|
|
manifest in a single artifact. Streamed rather than buffered: this package
|
|
is ~628 MB for one book, so building it in memory (or in a temp file) would
|
|
be wasteful. zipfile writes sequentially, so a generator works.
|
|
"""
|
|
import io
|
|
import zipfile
|
|
|
|
from starlette.responses import StreamingResponse
|
|
|
|
config = _get_active_config(request)
|
|
package_dir = config.export_dir / "ar_package"
|
|
manifest_path = config.export_dir / "ar_package.json"
|
|
|
|
if not package_dir.is_dir():
|
|
return JSONResponse(
|
|
{"error": "No MP3 package found. Build the AR package first."},
|
|
status_code=404,
|
|
)
|
|
|
|
tracks = sorted(package_dir.glob("*.mp3"))
|
|
if not tracks:
|
|
return JSONResponse({"error": "MP3 package is empty. Rebuild it first."}, status_code=404)
|
|
|
|
# AppConfig has no project_name; the directory name is the project slug.
|
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", config.project_dir.name).strip("_")
|
|
|
|
def _iter_zip() -> object:
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as zf:
|
|
for track in tracks:
|
|
zf.write(track, arcname=f"{safe}/{track.name}")
|
|
# The manifest travels with the audio so the ZIP describes itself:
|
|
# track order, roles, durations and the measured RMS/peak per file.
|
|
if manifest_path.is_file():
|
|
zf.write(manifest_path, arcname=f"{safe}/ar_package.json")
|
|
# Rewind AFTER the ZipFile closes. Inside the block the central
|
|
# directory has not been written yet, so seeking there truncated every
|
|
# download 1,763 bytes short and produced an unreadable archive.
|
|
buffer.seek(0)
|
|
while chunk := buffer.read(1 << 18):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
_iter_zip(),
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{safe}_mp3_package.zip"'},
|
|
)
|
|
|
|
|
|
@router.get("/api/quality/check")
|
|
async def quality_check_all(request: Request):
|
|
config = _get_active_config(request)
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
known_speakers = None
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
known_speakers = [s["primary_name"] for s in data.get("speakers", [])]
|
|
|
|
from umbraaudio.assembly import ChapterQualityChecker
|
|
|
|
checker = ChapterQualityChecker(config.data_dir, known_speakers)
|
|
results = checker.check_all_chapters()
|
|
serialized = {str(k): [i.to_dict() for i in v] for k, v in results.items()}
|
|
total_issues = sum(len(v) for v in results.values())
|
|
return JSONResponse({"chapters": serialized, "total_issues": total_issues})
|
|
|
|
|
|
@router.get("/api/quality/check/{chapter_id}")
|
|
async def quality_check_chapter(request: Request, chapter_id: int):
|
|
config = _get_active_config(request)
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
known_speakers = None
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
data = json.load(f)
|
|
known_speakers = [s["primary_name"] for s in data.get("speakers", [])]
|
|
|
|
from umbraaudio.assembly import ChapterQualityChecker
|
|
|
|
checker = ChapterQualityChecker(config.data_dir, known_speakers)
|
|
issues = checker.check_chapter(chapter_id)
|
|
return JSONResponse({"chapter_id": chapter_id, "issues": [i.to_dict() for i in issues]})
|
|
|
|
|
|
@router.post("/api/chapter/{chapter_id}/build")
|
|
async def build_chapter_audio(request: Request, chapter_id: int):
|
|
import threading
|
|
|
|
from umbraaudio.generation.chapter_builder import ChapterBuilder
|
|
|
|
config = _get_active_config(request)
|
|
|
|
def _run_builder():
|
|
builder = ChapterBuilder(config, chapter_id)
|
|
builder.run()
|
|
|
|
threading.Thread(target=_run_builder, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": f"Chapter {chapter_id} build started"})
|
|
|
|
|
|
@router.get("/api/chapter/{chapter_id}/build-status")
|
|
async def chapter_build_status(request: Request, chapter_id: int):
|
|
config = _get_active_config(request)
|
|
status_path = config.data_dir / "chapter_audio" / f"ch{chapter_id}_build_status.json"
|
|
if not status_path.exists():
|
|
return JSONResponse({"status": "idle"})
|
|
with open(status_path) as f:
|
|
data = json.load(f)
|
|
return JSONResponse(data)
|
|
|
|
|
|
@router.post("/api/segment-utterances")
|
|
async def segment_utterances(request: Request):
|
|
import threading
|
|
|
|
config = _get_active_config(request)
|
|
status_path = config.data_dir / "pipeline_status.json"
|
|
|
|
def _write_status(status: str, detail: str = "", progress: int = 0, error: str = ""):
|
|
with open(status_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": "segmenting",
|
|
"status": status,
|
|
"detail": detail,
|
|
"progress": progress,
|
|
"error": error,
|
|
},
|
|
f,
|
|
)
|
|
|
|
def _segment():
|
|
import sqlite3 as _sqlite3
|
|
|
|
from umbraaudio.analysis.llm_attributor import LLMAttributor
|
|
from umbraaudio.mapping.segmenter import Segmenter
|
|
|
|
try:
|
|
_write_status("segmenting", "Starting utterance segmentation...", 5)
|
|
|
|
db_path = config.data_dir / "project.db"
|
|
db = _sqlite3.connect(str(db_path))
|
|
db.row_factory = _sqlite3.Row
|
|
project = db.execute("SELECT * FROM project LIMIT 1").fetchone()
|
|
chapters = db.execute(
|
|
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
|
|
speakers_path = config.data_dir / "speakers.json"
|
|
character_names = []
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
speakers_data = json.load(f)
|
|
character_names = [s["primary_name"] for s in speakers_data.get("speakers", [])]
|
|
|
|
segmenter = Segmenter()
|
|
total = len(chapters)
|
|
|
|
for idx, ch in enumerate(chapters):
|
|
ch_id = ch["id"]
|
|
text = ch["source_text"] or ""
|
|
title = ch["title"] or f"Chapter {ch['chapter_index']}"
|
|
pct = int(5 + (idx / max(total, 1)) * 60)
|
|
_write_status("segmenting", f"Segmenting chapter {idx + 1}/{total}: {title}", pct)
|
|
|
|
db.execute("DELETE FROM utterances WHERE chapter_id=?", (ch_id,))
|
|
db.commit()
|
|
|
|
if not text.strip():
|
|
continue
|
|
|
|
utterances = segmenter.segment(
|
|
text, ch_id, character_names, chapter_title=ch["title"]
|
|
)
|
|
for u in utterances:
|
|
speaker = u.get("character_name") or u.get("speaker_name", "narrator")
|
|
utt_idx = u.get("index", u.get("utterance_index", 0))
|
|
db.execute(
|
|
"INSERT INTO utterances (chapter_id, utterance_index, text, type, speaker_name) VALUES (?, ?, ?, ?, ?)",
|
|
(ch_id, utt_idx, u.get("text", ""), u.get("type", "narration"), speaker),
|
|
)
|
|
db.commit()
|
|
|
|
_write_status("attributing", "Running LLM speaker attribution...", 65)
|
|
|
|
try:
|
|
from umbraaudio.voices.lmstudio_lifecycle import get_loaded_model, is_running
|
|
|
|
lm_running = is_running()
|
|
lm_model = get_loaded_model() if lm_running else None
|
|
logger.info("LM Studio check: running=%s, model=%s", lm_running, lm_model)
|
|
if not lm_running or not lm_model:
|
|
_write_status(
|
|
"complete",
|
|
"Segmentation complete (LM Studio offline - attribution skipped)",
|
|
100,
|
|
)
|
|
logger.warning(
|
|
"LM Studio not running or no model loaded - skipping LLM attribution"
|
|
)
|
|
else:
|
|
attributor = LLMAttributor(config.llm, data_dir=config.data_dir)
|
|
db2 = _sqlite3.connect(str(db_path))
|
|
db2.row_factory = _sqlite3.Row
|
|
all_chapters = db2.execute(
|
|
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
|
|
for idx, ch in enumerate(all_chapters):
|
|
ch_id = ch["id"]
|
|
title = ch["title"] or f"Chapter {ch['chapter_index']}"
|
|
pct = int(65 + (idx / max(total, 1)) * 30)
|
|
_write_status(
|
|
"attributing", f"Attributing chapter {idx + 1}/{total}: {title}", pct
|
|
)
|
|
|
|
utts = db2.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
if not utts:
|
|
continue
|
|
|
|
utt_list = [dict(u) for u in utts]
|
|
attributed = attributor.attribute_speakers(utt_list, title, ch_id)
|
|
|
|
try:
|
|
from umbraaudio.analysis.clarity import (
|
|
assess_utterance_clarity,
|
|
)
|
|
|
|
alias_map: dict[str, str] = {}
|
|
if speakers_path.exists():
|
|
with open(speakers_path) as f:
|
|
for s in json.load(f).get("speakers", []):
|
|
primary = s.get("primary_name", "")
|
|
if primary:
|
|
alias_map[primary.lower()] = primary
|
|
for a in s.get("aliases", []):
|
|
if a:
|
|
alias_map[a.lower()] = primary
|
|
attributed = assess_utterance_clarity(attributed, ch_id, alias_map)
|
|
except Exception:
|
|
pass
|
|
|
|
for u in attributed:
|
|
db2.execute(
|
|
"UPDATE utterances SET speaker_name=?, "
|
|
"attribution_confidence=?, attribution_reasoning=?, "
|
|
"attribution_evidence_kind=?, attribution_tag_name=?, "
|
|
"attribution_uncertain=?, needs_review=?, review_reason=? "
|
|
"WHERE id=?",
|
|
(
|
|
u.get("speaker_name", "Narrator"),
|
|
u.get("attribution_confidence", 0.5),
|
|
u.get("attribution_reasoning", "") or "",
|
|
u.get("attribution_evidence_kind"),
|
|
u.get("attribution_tag_name"),
|
|
1 if u.get("attribution_uncertain") else 0,
|
|
1 if u.get("needs_review") else 0,
|
|
u.get("review_reason") or "",
|
|
u["id"],
|
|
),
|
|
)
|
|
db2.commit()
|
|
|
|
db2.close()
|
|
except Exception as e:
|
|
logger.warning("LLM attribution failed, using mechanical results: %s", e)
|
|
|
|
_write_status("emotions", "Analyzing emotion and tone...", 95)
|
|
|
|
try:
|
|
from umbraaudio.mapping.emotion import EmotionMapper
|
|
from umbraaudio.voices.lmstudio_lifecycle import get_loaded_model
|
|
from umbraaudio.voices.lmstudio_lifecycle import is_running as _is_running
|
|
|
|
if _is_running() and get_loaded_model():
|
|
from openai import OpenAI
|
|
|
|
emotion_client = OpenAI(
|
|
base_url=config.llm.lmstudio_base_url,
|
|
api_key="lm-studio",
|
|
)
|
|
emotion_mapper = EmotionMapper(
|
|
llm_client=emotion_client, model=get_loaded_model()
|
|
)
|
|
|
|
db_em = _sqlite3.connect(str(db_path))
|
|
db_em.row_factory = _sqlite3.Row
|
|
em_chapters = db_em.execute(
|
|
"SELECT id, chapter_index, title, source_text FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(project["id"],),
|
|
).fetchall()
|
|
|
|
for idx, ch in enumerate(em_chapters):
|
|
ch_id = ch["id"]
|
|
title = ch["title"] or f"Chapter {ch['chapter_index']}"
|
|
_write_status("emotions", f"Analyzing emotions: {title}", 95)
|
|
|
|
utts = db_em.execute(
|
|
"SELECT id, utterance_index, text, type, speaker_name FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
|
|
(ch_id,),
|
|
).fetchall()
|
|
if not utts:
|
|
continue
|
|
|
|
utt_list = [dict(u) for u in utts]
|
|
context = ch["source_text"][:500] if ch["source_text"] else ""
|
|
emotions = emotion_mapper.analyze_utterances(utt_list, context)
|
|
|
|
for u, emo in zip(utt_list, emotions):
|
|
db_em.execute(
|
|
"UPDATE utterances SET emotion=?, pace=?, instruct=? WHERE id=?",
|
|
(
|
|
emo.get("emotion", "neutral"),
|
|
emo.get("pace", "moderate"),
|
|
emo.get("instruct", ""),
|
|
u["id"],
|
|
),
|
|
)
|
|
db_em.commit()
|
|
|
|
db_em.close()
|
|
logger.info("Emotion analysis complete for %d chapters", len(em_chapters))
|
|
else:
|
|
logger.info("LM Studio offline, skipping emotion analysis")
|
|
except Exception as e:
|
|
logger.warning("Emotion analysis failed: %s", e)
|
|
|
|
db.close()
|
|
pipeline_path = config.data_dir / "pipeline_status.json"
|
|
with open(pipeline_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": "segment",
|
|
"status": "complete",
|
|
"detail": "Utterance segmentation complete",
|
|
"progress": 100,
|
|
},
|
|
f,
|
|
)
|
|
_write_status("complete", "Segmentation complete", 100)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
tb = traceback.format_exc()
|
|
logger.error("Segmentation failed: %s\n%s", e, tb)
|
|
_write_status("error", str(e), 0, error=str(e))
|
|
pipeline_path = config.data_dir / "pipeline_status.json"
|
|
with open(pipeline_path, "w") as f:
|
|
json.dump(
|
|
{
|
|
"step": "segment",
|
|
"status": "error",
|
|
"detail": str(e),
|
|
"progress": 0,
|
|
"error": tb,
|
|
},
|
|
f,
|
|
)
|
|
|
|
_write_status("queued", "Segmentation queued...")
|
|
threading.Thread(target=_segment, daemon=True).start()
|
|
return JSONResponse({"ok": True, "message": "Utterance segmentation started"})
|
|
|
|
|
|
def _get_registry(config: Any) -> sqlite3.Connection | None:
|
|
registry_path: Path = config.voice_registry_path
|
|
if not registry_path.exists():
|
|
return None
|
|
db = sqlite3.connect(str(registry_path))
|
|
db.row_factory = sqlite3.Row
|
|
return db
|
|
|
|
|
|
@router.get("/registry", response_class=HTMLResponse)
|
|
async def registry_index(request: Request):
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
characters: list[dict[str, Any]] = []
|
|
if registry:
|
|
rows = registry.execute(
|
|
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
|
|
).fetchall()
|
|
characters = [dict(r) for r in rows]
|
|
registry.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"registry.html",
|
|
{
|
|
"characters": characters,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/registry/search", response_class=JSONResponse)
|
|
async def registry_search(request: Request, q: str = ""):
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
if not registry:
|
|
return JSONResponse([])
|
|
like = f"%{q}%"
|
|
rows = registry.execute(
|
|
"""SELECT vr.* FROM voice_registry vr
|
|
LEFT JOIN registry_aliases ra ON ra.registry_id = vr.id
|
|
WHERE vr.character_name LIKE ? OR ra.alias_name LIKE ?
|
|
GROUP BY vr.id ORDER BY vr.character_name""",
|
|
(like, like),
|
|
).fetchall()
|
|
registry.close()
|
|
return JSONResponse([dict(r) for r in rows])
|
|
|
|
|
|
@router.get("/registry/partial", response_class=HTMLResponse)
|
|
async def registry_list_partial(request: Request):
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
characters: list[dict[str, Any]] = []
|
|
if registry:
|
|
rows = registry.execute(
|
|
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
|
|
).fetchall()
|
|
characters = [dict(r) for r in rows]
|
|
registry.close()
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"_registry_list.html",
|
|
{
|
|
"request": request,
|
|
"characters": characters,
|
|
},
|
|
)
|
|
|
|
|
|
# ==============================================================================
|
|
# Pipeline Activity API
|
|
# ==============================================================================
|
|
|
|
|
|
def _collect_pipeline_activity(config: Any) -> dict[str, Any]:
|
|
"""Aggregate live status of every background task type into one structure.
|
|
|
|
Returns ``{"idle": bool, "tasks": [ {id, name, state, detail, progress}, ...]}``
|
|
where ``state`` is one of "idle", "active", "complete", "error".
|
|
"""
|
|
import re as _re
|
|
|
|
from umbraaudio.pipeline_manager import PipelineManager
|
|
from umbraaudio.voices.generation_manager import GenerationManager
|
|
|
|
tasks: list[dict[str, Any]] = []
|
|
|
|
def _norm_state(active: bool, raw: str) -> str:
|
|
if active:
|
|
return "active"
|
|
if raw in ("complete", "completed", "success"):
|
|
return "complete"
|
|
if raw in ("error", "failed", "failure"):
|
|
return "error"
|
|
return "idle"
|
|
|
|
def _add(name, active, raw_state, detail, progress, task_id=""):
|
|
tasks.append(
|
|
{
|
|
"id": task_id or name.lower().replace(" ", "_"),
|
|
"name": name,
|
|
"state": _norm_state(active, raw_state),
|
|
"detail": detail,
|
|
"progress": progress,
|
|
}
|
|
)
|
|
|
|
# --- Full Pipeline ---
|
|
pm = PipelineManager(config.data_dir)
|
|
p = pm.load_status()
|
|
p_state = p.state.value
|
|
_add(
|
|
"Full Pipeline",
|
|
p_state in ("running", "queued", "paused"),
|
|
p_state,
|
|
f"{p.step.value}: {p.detail}" if p.detail else p.step.value,
|
|
p.progress,
|
|
)
|
|
|
|
# --- Character Discovery (creating speakers) ---
|
|
analysis_status = {"status": "idle"}
|
|
analysis_path = config.data_dir / "analysis_status.json"
|
|
if analysis_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
analysis_status = dict(json.loads(analysis_path.read_text()))
|
|
a_raw = analysis_status.get("status", "idle")
|
|
_add(
|
|
"Character Discovery",
|
|
a_raw in ("starting", "discovering", "saving", "queued", "running"),
|
|
a_raw,
|
|
analysis_status.get("detail", ""),
|
|
analysis_status.get("progress", 0),
|
|
)
|
|
|
|
# --- Voice / Audio Generation (create speaker refs, chapter audio) ---
|
|
gm = GenerationManager(config.data_dir)
|
|
g = gm.get_status()
|
|
g_active = g.get("active", False)
|
|
g_type = g.get("type") or "audio"
|
|
_add(
|
|
"Voice Generation" if g_type == "voices" else "Audio Generation",
|
|
g_active,
|
|
"active" if g_active else ("complete" if g.get("completed_at") else "idle"),
|
|
g.get("current") or f"{g.get('done', 0)}/{g.get('total', 0)}",
|
|
round((g.get("done", 0) / g.get("total", 1)) * 100) if g.get("total") else 0,
|
|
)
|
|
|
|
# --- Chapter Builds ---
|
|
chapter_status_dir = config.data_dir / "chapter_audio"
|
|
active_chapters: list[dict[str, Any]] = []
|
|
if chapter_status_dir.exists():
|
|
for sf in sorted(chapter_status_dir.glob("ch*_build_status.json")):
|
|
try:
|
|
cstatus = dict(json.loads(sf.read_text()))
|
|
except Exception:
|
|
continue
|
|
m = _re.search(r"ch(\d+)_build_status\.json", sf.name)
|
|
ch_id = int(m.group(1)) if m else 0
|
|
phase = cstatus.get("phase", "idle")
|
|
if phase in ("idle", "complete", "error"):
|
|
continue
|
|
active_chapters.append(
|
|
{
|
|
"chapter": ch_id,
|
|
"phase": phase,
|
|
"detail": cstatus.get("detail", ""),
|
|
"progress": cstatus.get("progress", 0),
|
|
"generated": cstatus.get("generated", 0),
|
|
"total": cstatus.get("total_utterances", 0),
|
|
}
|
|
)
|
|
if active_chapters:
|
|
_add(
|
|
"Chapter Builds",
|
|
True,
|
|
"active",
|
|
"; ".join(f"ch{c['chapter']} {c['phase']} ({c['detail']})" for c in active_chapters),
|
|
round(sum(c["progress"] for c in active_chapters) / len(active_chapters)),
|
|
)
|
|
|
|
# --- AR Package Build ---
|
|
ar_status = {"state": "idle"}
|
|
ar_path = config.export_dir / "ar_build_status.json"
|
|
if ar_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
ar_status = dict(json.loads(ar_path.read_text()))
|
|
ar_raw = ar_status.get("state", "idle")
|
|
_add(
|
|
"AR Package Build",
|
|
ar_raw == "running",
|
|
ar_raw,
|
|
ar_status.get("detail", ""),
|
|
ar_status.get("progress", 0),
|
|
)
|
|
|
|
# --- Voice Analysis ---
|
|
va_status = load_analysis_status(config.data_dir / "analysis")
|
|
va_raw = va_status.get("status", "idle")
|
|
_add(
|
|
"Voice Analysis",
|
|
va_raw == "running",
|
|
va_raw,
|
|
va_status.get("detail", ""),
|
|
va_status.get("progress", 0),
|
|
)
|
|
|
|
# --- Background Rebuild Batch ---
|
|
repo = Path(__file__).resolve().parent.parent.parent
|
|
logs_dir = repo / "logs"
|
|
batch_active = False
|
|
batch_detail = "No active rebuild batch"
|
|
if logs_dir.exists():
|
|
candidates = sorted(
|
|
logs_dir.glob("rebuild_stale_voices_batch*_stdout.log"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
if candidates:
|
|
log = candidates[0]
|
|
try:
|
|
text = log.read_text(errors="replace")
|
|
except Exception:
|
|
text = ""
|
|
batch_active = "=== Batch complete ===" not in text
|
|
last_line = next((ln for ln in reversed(text.splitlines()) if ln.strip()), "")[:160]
|
|
if batch_active and last_line:
|
|
batch_detail = f"Rebuild batch ({log.name}): {last_line}"
|
|
elif text:
|
|
batch_detail = "Rebuild batch complete"
|
|
else:
|
|
batch_detail = "Rebuild batch (empty log)"
|
|
_add(
|
|
"Background Rebuild Batch",
|
|
batch_active,
|
|
"active" if batch_active else "idle",
|
|
batch_detail,
|
|
0,
|
|
)
|
|
|
|
return {
|
|
"idle": all(t["state"] != "active" for t in tasks),
|
|
"tasks": tasks,
|
|
}
|
|
|
|
|
|
@router.get("/api/pipeline/activity")
|
|
async def pipeline_activity(request: Request):
|
|
"""One-stop status of every background task (pipeline, discovery, generation,
|
|
chapter builds, AR export, voice analysis, rebuild batches)."""
|
|
import time as _time
|
|
|
|
config = _get_active_config(request)
|
|
data = _collect_pipeline_activity(config)
|
|
data["updated_at"] = _time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
return JSONResponse(data)
|
|
|
|
|
|
# ==============================================================================
|
|
# Model Management API
|
|
# ==============================================================================
|
|
|
|
|
|
@router.get("/api/models/status")
|
|
async def models_status(request: Request):
|
|
"""Get status of all GPU models (Qwen TTS + LM Studio)"""
|
|
config = _get_active_config(request)
|
|
|
|
# Check Qwen TTS server
|
|
qwen_health = {"status": "unreachable", "voicedesign_loaded": False, "base_loaded": False}
|
|
if config.tts.qwen_tts_url:
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
r = await client.get(f"{config.tts.qwen_tts_url}/health")
|
|
if r.status_code < 500:
|
|
qwen_health = r.json()
|
|
except Exception:
|
|
pass
|
|
|
|
# Check LM Studio
|
|
lm_status = {"loaded": False, "model": None, "instance_id": None}
|
|
if config.llm.lmstudio_base_url:
|
|
try:
|
|
import httpx
|
|
|
|
base = config.llm.lmstudio_base_url.replace("/v1", "")
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
r = await client.get(f"{base}/api/v1/models")
|
|
if r.status_code < 500:
|
|
data = r.json()
|
|
for model in data.get("models", []):
|
|
for inst in model.get("loaded_instances", []):
|
|
lm_status = {
|
|
"loaded": True,
|
|
"model": model.get("key"),
|
|
"instance_id": inst.get("id"),
|
|
"context_length": inst.get("config", {}).get("context_length"),
|
|
"gpu_memory_gb": inst.get("config", {}).get("gpu_memory_gb"),
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return JSONResponse(
|
|
{
|
|
"qwen_tts": qwen_health,
|
|
"lm_studio": lm_status,
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/api/models/qwen/load/{model_type}")
|
|
async def load_qwen_model(request: Request, model_type: str):
|
|
"""Load a Qwen TTS model (voicedesign or base)"""
|
|
config = _get_active_config(request)
|
|
|
|
if model_type not in ("voicedesign", "base"):
|
|
return JSONResponse({"error": "Invalid model type"}, status_code=400)
|
|
|
|
if not config.tts.qwen_tts_url:
|
|
return JSONResponse({"error": "Qwen TTS URL not configured"}, status_code=400)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=120) as client:
|
|
# Load requested model (server auto-unloads the other model)
|
|
r = await client.post(
|
|
f"{config.tts.qwen_tts_url}/load",
|
|
json={"model_type": model_type},
|
|
)
|
|
if r.status_code < 400:
|
|
return JSONResponse({"ok": True, "model": model_type})
|
|
return JSONResponse({"error": r.text[:200]}, status_code=500)
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/api/models/qwen/unload")
|
|
async def unload_qwen_model(request: Request, model_type: str = "all"):
|
|
"""Unload Qwen TTS model(s)"""
|
|
config = _get_active_config(request)
|
|
|
|
if not config.tts.qwen_tts_url:
|
|
return JSONResponse({"error": "Qwen TTS URL not configured"}, status_code=400)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
r = await client.post(
|
|
f"{config.tts.qwen_tts_url}/unload",
|
|
json={"model_type": model_type},
|
|
)
|
|
if r.status_code < 400:
|
|
return JSONResponse({"ok": True, "unloaded": model_type})
|
|
return JSONResponse({"error": r.text[:200]}, status_code=500)
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/api/models/switch/{stage}")
|
|
async def switch_models(request: Request, stage: str):
|
|
"""Switch the loaded model set to match a pipeline stage.
|
|
|
|
Unloads every model the stage does not need and loads the ones it does,
|
|
so the model about to do work gets maximum memory.
|
|
"""
|
|
from umbraaudio.voices.model_coordinator import (
|
|
AsyncModelCoordinator,
|
|
PipelineStage,
|
|
)
|
|
|
|
try:
|
|
parsed = PipelineStage(stage)
|
|
except ValueError:
|
|
valid = ", ".join(s.value for s in PipelineStage)
|
|
return JSONResponse(
|
|
{"error": f"Unknown stage '{stage}'. Valid: {valid}"},
|
|
status_code=400,
|
|
)
|
|
|
|
config = _get_active_config(request)
|
|
coordinator = AsyncModelCoordinator(config)
|
|
try:
|
|
result = await coordinator.switch_to(parsed)
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
status = 200 if result.get("ok") else 502
|
|
return JSONResponse(result, status_code=status)
|
|
|
|
|
|
@router.post("/api/models/lm/load")
|
|
async def load_lm_model(request: Request):
|
|
"""Load an LM Studio model"""
|
|
config = _get_active_config(request)
|
|
body = await request.json()
|
|
model_id = body.get("model", config.llm.lmstudio_model)
|
|
context_length = body.get("context_length", 4096)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
base = config.llm.lmstudio_base_url.replace("/v1", "")
|
|
async with httpx.AsyncClient(timeout=180) as client:
|
|
# Unload any existing model first
|
|
r = await client.get(f"{base}/api/v1/models")
|
|
if r.status_code < 500:
|
|
data = r.json()
|
|
for model in data.get("models", []):
|
|
for inst in model.get("loaded_instances", []):
|
|
await client.post(
|
|
f"{base}/api/v1/models/unload", json={"instance_id": inst.get("id")}
|
|
)
|
|
|
|
# Load new model
|
|
r = await client.post(
|
|
f"{base}/api/v1/models/load",
|
|
json={"model": model_id, "context_length": context_length},
|
|
)
|
|
if r.status_code < 400:
|
|
data = r.json()
|
|
if data.get("status") == "loaded":
|
|
return JSONResponse(
|
|
{"ok": True, "model": model_id, "load_time": data.get("load_time_seconds")}
|
|
)
|
|
return JSONResponse({"error": r.text[:300]}, status_code=500)
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/api/models/lm/unload")
|
|
async def unload_lm_model(request: Request):
|
|
"""Unload LM Studio model"""
|
|
config = _get_active_config(request)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
base = config.llm.lmstudio_base_url.replace("/v1", "")
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
r = await client.get(f"{base}/api/v1/models")
|
|
if r.status_code < 500:
|
|
data = r.json()
|
|
unloaded = []
|
|
for model in data.get("models", []):
|
|
for inst in model.get("loaded_instances", []):
|
|
inst_id = inst.get("id")
|
|
await client.post(
|
|
f"{base}/api/v1/models/unload", json={"instance_id": inst_id}
|
|
)
|
|
unloaded.append(inst_id)
|
|
return JSONResponse({"ok": True, "unloaded": unloaded})
|
|
return JSONResponse({"ok": True, "unloaded": []})
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.get("/api/models/lm/available")
|
|
async def list_lm_models(request: Request):
|
|
"""List available LM Studio models"""
|
|
config = _get_active_config(request)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
base = config.llm.lmstudio_base_url.replace("/v1", "")
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
r = await client.get(f"{base}/api/v1/models")
|
|
if r.status_code < 500:
|
|
data = r.json()
|
|
models = []
|
|
for model in data.get("models", []):
|
|
models.append(
|
|
{
|
|
"key": model.get("key"),
|
|
"display_name": model.get("display_name"),
|
|
"params": model.get("params_string"),
|
|
"size_gb": round(model.get("size_bytes", 0) / 1e9, 1),
|
|
"loaded": len(model.get("loaded_instances", [])) > 0,
|
|
"instance_id": model.get("loaded_instances", [{}])[0].get("id")
|
|
if model.get("loaded_instances")
|
|
else None,
|
|
}
|
|
)
|
|
return JSONResponse({"models": models})
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
# ==================== Exception Review API Routes ====================
|
|
|
|
|
|
@router.get("/exception-review", response_class=HTMLResponse)
|
|
async def exception_review_page(request: Request):
|
|
"""Exception review page - shows utterances with low attribution confidence."""
|
|
config = _get_active_config(request)
|
|
db = _get_db(request)
|
|
|
|
# Get filter parameters
|
|
chapter_filter = request.query_params.get("chapter")
|
|
type_filter = request.query_params.get("ambiguity_type")
|
|
confidence_filter = request.query_params.get("confidence")
|
|
status_filter = request.query_params.get("status", "pending")
|
|
page = int(request.query_params.get("page", 1))
|
|
per_page = 50
|
|
|
|
# Build query
|
|
where_clauses = ["er.status = ?"]
|
|
params = [status_filter]
|
|
|
|
if chapter_filter:
|
|
where_clauses.append("er.chapter_id = ?")
|
|
params.append(int(chapter_filter))
|
|
|
|
if type_filter:
|
|
where_clauses.append("er.ambiguity_type = ?")
|
|
params.append(type_filter)
|
|
|
|
if confidence_filter == "very_low":
|
|
where_clauses.append("er.confidence < 0.3")
|
|
elif confidence_filter == "low":
|
|
where_clauses.append("er.confidence >= 0.3 AND er.confidence < 0.5")
|
|
elif confidence_filter == "medium":
|
|
where_clauses.append("er.confidence >= 0.5 AND er.confidence < 0.7")
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
# Get total count
|
|
count_sql = f"""
|
|
SELECT COUNT(*) as cnt
|
|
FROM exception_reviews er
|
|
JOIN chapters c ON er.chapter_id = c.id
|
|
WHERE {where_sql}
|
|
"""
|
|
total = db.execute(count_sql, params).fetchone()["cnt"]
|
|
|
|
# Get paginated results
|
|
offset = (page - 1) * per_page
|
|
sql = f"""
|
|
SELECT
|
|
er.*,
|
|
c.chapter_index,
|
|
c.title as chapter_title
|
|
FROM exception_reviews er
|
|
JOIN chapters c ON er.chapter_id = c.id
|
|
WHERE {where_sql}
|
|
ORDER BY er.confidence ASC, c.chapter_index ASC, er.utterance_index ASC
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
params.extend([per_page, offset])
|
|
rows = db.execute(sql, params).fetchall()
|
|
|
|
exceptions = []
|
|
for row in rows:
|
|
exc = dict(row)
|
|
# Parse JSON fields
|
|
import json
|
|
|
|
exc["evidence"] = json.loads(exc["evidence"]) if exc["evidence"] else []
|
|
exc["alternatives"] = json.loads(exc["alternatives"]) if exc["alternatives"] else []
|
|
exceptions.append(exc)
|
|
|
|
# Get chapters for filter dropdown
|
|
chapters = db.execute(
|
|
"SELECT id, chapter_index, title FROM chapters ORDER BY chapter_index"
|
|
).fetchall()
|
|
|
|
# Calculate exception rate
|
|
total_utterances = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
|
|
).fetchone()["cnt"]
|
|
total_exceptions = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
|
|
).fetchone()["cnt"]
|
|
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
|
|
|
|
total_pages = (total + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"exception_review.html",
|
|
{
|
|
"request": request,
|
|
"project": {"name": config.project_dir.name},
|
|
"exceptions": exceptions,
|
|
"chapters": chapters,
|
|
"selected_chapter": int(chapter_filter) if chapter_filter else None,
|
|
"selected_type": type_filter,
|
|
"selected_confidence": confidence_filter,
|
|
"selected_status": status_filter,
|
|
"page": page,
|
|
"total_pages": total_pages,
|
|
"total_exceptions": total_exceptions,
|
|
"total_utterances": total_utterances,
|
|
"exception_rate": exception_rate,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/api/exception-review/stats")
|
|
async def exception_review_stats(request: Request):
|
|
"""Get exception review statistics for badge updates."""
|
|
db = _get_db(request)
|
|
|
|
total_utterances = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
|
|
).fetchone()["cnt"]
|
|
total_exceptions = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
|
|
).fetchone()["cnt"]
|
|
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
|
|
|
|
by_type = db.execute("""
|
|
SELECT ambiguity_type, COUNT(*) as cnt
|
|
FROM exception_reviews
|
|
WHERE status = 'pending'
|
|
GROUP BY ambiguity_type
|
|
ORDER BY cnt DESC
|
|
""").fetchall()
|
|
|
|
return JSONResponse(
|
|
{
|
|
"total_utterances": total_utterances,
|
|
"total_exceptions": total_exceptions,
|
|
"exception_rate": exception_rate,
|
|
"by_type": [{"type": r["ambiguity_type"], "count": r["cnt"]} for r in by_type],
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/api/exception-review/filter", response_class=HTMLResponse)
|
|
async def exception_review_filter(request: Request):
|
|
"""HTMX endpoint for filtered exception table."""
|
|
return await exception_review_page(request)
|
|
|
|
|
|
@router.get("/api/exception-review/{exc_id}/context")
|
|
async def exception_context(request: Request, exc_id: int):
|
|
"""Get context for an exception (surrounding utterances)."""
|
|
db = _get_db(request)
|
|
|
|
exc = db.execute("SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)).fetchone()
|
|
|
|
if not exc:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
|
|
# Get surrounding utterances
|
|
context = db.execute(
|
|
"""
|
|
SELECT u.*, c.title as chapter_title
|
|
FROM utterances u
|
|
JOIN chapters c ON u.chapter_id = c.id
|
|
WHERE u.chapter_id = ? AND u.utterance_index BETWEEN ? AND ?
|
|
ORDER BY u.utterance_index
|
|
""",
|
|
(exc["chapter_id"], exc["utterance_index"] - 5, exc["utterance_index"] + 5),
|
|
).fetchall()
|
|
|
|
return templates.TemplateResponse(
|
|
"_exception_context.html",
|
|
{
|
|
"request": request,
|
|
"exception": dict(exc),
|
|
"context": [dict(r) for r in context],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/api/exception-review/{exc_id}/resolve-form")
|
|
async def exception_resolve_form(request: Request, exc_id: int):
|
|
"""Get resolve form for an exception."""
|
|
db = _get_db(request)
|
|
|
|
exc = db.execute("SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)).fetchone()
|
|
|
|
if not exc:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
|
|
# Get available speakers for this chapter
|
|
speakers = db.execute(
|
|
"""
|
|
SELECT DISTINCT speaker_name
|
|
FROM utterances
|
|
WHERE chapter_id = ? AND type = 'dialogue' AND speaker_name != 'narrator'
|
|
ORDER BY speaker_name
|
|
""",
|
|
(exc["chapter_id"],),
|
|
).fetchall()
|
|
|
|
# Also get global speakers
|
|
proj_row = db.execute("SELECT id FROM project ORDER BY id LIMIT 1").fetchone()
|
|
proj_id = proj_row["id"] if proj_row else 1
|
|
global_speakers = db.execute(
|
|
"""
|
|
SELECT DISTINCT name FROM characters WHERE project_id = ? AND is_narrator = 0
|
|
""",
|
|
(proj_id,),
|
|
).fetchall()
|
|
|
|
all_speakers = sorted(
|
|
set([s["speaker_name"] for s in speakers] + [s["name"] for s in global_speakers])
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"_exception_resolve_form.html",
|
|
{
|
|
"request": request,
|
|
"exception": dict(exc),
|
|
"speakers": all_speakers,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/exception-review/{exc_id}/resolve")
|
|
async def exception_resolve(request: Request, exc_id: int):
|
|
"""Resolve an exception with a corrected speaker."""
|
|
form = await request.form()
|
|
resolved_speaker = form.get("resolved_speaker")
|
|
notes = form.get("notes", "")
|
|
|
|
if not resolved_speaker:
|
|
return JSONResponse({"error": "No speaker selected"}, status_code=400)
|
|
|
|
db = _get_db(request)
|
|
|
|
# Update exception review
|
|
db.execute(
|
|
"""
|
|
UPDATE exception_reviews
|
|
SET status = 'resolved',
|
|
resolved_speaker = ?,
|
|
resolved_by = 'user',
|
|
resolved_at = datetime('now'),
|
|
notes = COALESCE(notes, '') || ?
|
|
WHERE id = ?
|
|
""",
|
|
(resolved_speaker, f"\nResolution: {notes}" if notes else "", exc_id),
|
|
)
|
|
|
|
# Update the utterance itself
|
|
exc = db.execute(
|
|
"SELECT utterance_id FROM exception_reviews WHERE id = ?", (exc_id,)
|
|
).fetchone()
|
|
|
|
if exc:
|
|
db.execute(
|
|
"""
|
|
UPDATE utterances
|
|
SET speaker_name = ?
|
|
WHERE id = ?
|
|
""",
|
|
(resolved_speaker, exc["utterance_id"]),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return JSONResponse({"ok": True, "speaker": resolved_speaker})
|
|
|
|
|
|
@router.get("/api/exception-review/{exc_id}/alternatives")
|
|
async def exception_alternatives(request: Request, exc_id: int):
|
|
"""Get alternative speakers for an exception."""
|
|
db = _get_db(request)
|
|
|
|
exc = db.execute("SELECT * FROM exception_reviews WHERE id = ?", (exc_id,)).fetchone()
|
|
|
|
if not exc:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
|
|
alternatives = json.loads(exc["alternatives"]) if exc["alternatives"] else []
|
|
|
|
return templates.TemplateResponse(
|
|
"_exception_alternatives.html",
|
|
{
|
|
"request": request,
|
|
"exception": dict(exc),
|
|
"alternatives": alternatives,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/api/exception-review/populate")
|
|
async def populate_exceptions(request: Request):
|
|
"""Populate exception_reviews table from recent attribution confidences."""
|
|
import json
|
|
|
|
config = _get_active_config(request)
|
|
db = _get_db(request)
|
|
|
|
# Get all chapters with their utterances
|
|
chapters = db.execute(
|
|
"SELECT id, chapter_index, title FROM chapters ORDER BY chapter_index"
|
|
).fetchall()
|
|
|
|
total_added = 0
|
|
|
|
for ch in chapters:
|
|
# Get utterances with low confidence
|
|
utts = db.execute(
|
|
"""
|
|
SELECT id, utterance_index, speaker_name, attribution_confidence, attribution_reasoning, text, type
|
|
FROM utterances
|
|
WHERE chapter_id = ? AND type = 'dialogue'
|
|
AND (attribution_confidence IS NULL OR attribution_confidence < 0.7)
|
|
""",
|
|
(ch["id"],),
|
|
).fetchall()
|
|
|
|
for utt in utts:
|
|
# Check if already in exception_reviews
|
|
existing = db.execute(
|
|
"SELECT id FROM exception_reviews WHERE utterance_id = ?", (utt["id"],)
|
|
).fetchone()
|
|
|
|
if existing:
|
|
continue
|
|
|
|
# Determine ambiguity type
|
|
ambiguity_type = "low_confidence"
|
|
if utt["attribution_confidence"] is None:
|
|
ambiguity_type = "no_confidence"
|
|
elif utt["speaker_name"] == "narrator":
|
|
ambiguity_type = "narrator_dialogue"
|
|
elif "pronoun" in (utt["attribution_reasoning"] or "").lower():
|
|
ambiguity_type = "pronoun"
|
|
elif "role" in (utt["attribution_reasoning"] or "").lower():
|
|
ambiguity_type = "unnamed_role"
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT INTO exception_reviews (
|
|
project_id, chapter_id, utterance_id, utterance_index,
|
|
current_speaker, attributed_speaker, confidence,
|
|
ambiguity_type, evidence, reasoning, alternatives, text_preview, status
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
|
""",
|
|
(
|
|
1,
|
|
ch["id"],
|
|
utt["id"],
|
|
utt["utterance_index"],
|
|
utt["speaker_name"],
|
|
utt["speaker_name"],
|
|
utt["attribution_confidence"] or 0.0,
|
|
ambiguity_type,
|
|
json.dumps([utt["attribution_reasoning"] or "Low confidence attribution"]),
|
|
json.dumps([]),
|
|
utt["text"][:200],
|
|
),
|
|
)
|
|
total_added += 1
|
|
|
|
db.commit()
|
|
return JSONResponse({"ok": True, "added": total_added})
|
|
|
|
|
|
@router.get("/api/exception-review/quality-gate")
|
|
async def quality_gate_check(request: Request):
|
|
"""Check if exception rate exceeds 1% quality gate."""
|
|
db = _get_db(request)
|
|
|
|
total_utterances = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM utterances WHERE type = 'dialogue'"
|
|
).fetchone()["cnt"]
|
|
total_exceptions = db.execute(
|
|
"SELECT COUNT(*) as cnt FROM exception_reviews WHERE status = 'pending'"
|
|
).fetchone()["cnt"]
|
|
exception_rate = (total_exceptions / total_utterances * 100) if total_utterances > 0 else 0
|
|
|
|
passed = exception_rate <= 1.0
|
|
|
|
return JSONResponse(
|
|
{
|
|
"passed": passed,
|
|
"exception_rate": exception_rate,
|
|
"total_exceptions": total_exceptions,
|
|
"total_utterances": total_utterances,
|
|
"threshold": 1.0,
|
|
"message": "Quality gate passed"
|
|
if passed
|
|
else f"Quality gate FAILED: {exception_rate:.2f}% exceptions exceeds 1% threshold",
|
|
}
|
|
)
|
|
return JSONResponse({"models": []})
|
|
|
|
|
|
# ==============================================================================
|
|
# Library (cross-project voices/names/utterances)
|
|
# ==============================================================================
|
|
|
|
|
|
@router.get("/library", response_class=HTMLResponse)
|
|
async def library_index(request: Request):
|
|
config = _get_active_config(request)
|
|
return templates.TemplateResponse(request, "library.html", {"active_tab": "speakers"})
|
|
|
|
|
|
@router.get("/library/speakers", response_class=HTMLResponse)
|
|
async def library_speakers(request: Request):
|
|
from umbraaudio.library import list_speakers
|
|
|
|
speakers = list_speakers()
|
|
by_name: dict[str, dict] = {}
|
|
for s in speakers:
|
|
s["source_kind"] = "lib"
|
|
by_name[s.get("primary_name", "").lower()] = s
|
|
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
merged = list(speakers)
|
|
if registry:
|
|
rows = registry.execute(
|
|
"SELECT * FROM voice_registry ORDER BY is_narrator DESC, character_name"
|
|
).fetchall()
|
|
registry.close()
|
|
for r in rows:
|
|
d = dict(r)
|
|
key = (d.get("character_name") or "").lower()
|
|
if not key or key in by_name:
|
|
continue
|
|
merged.append(
|
|
{
|
|
"id": d.get("id"),
|
|
"primary_name": d["character_name"],
|
|
"aliases": d.get("aliases") or "[]",
|
|
"gender": d.get("gender"),
|
|
"accent": d.get("accent_marker"),
|
|
"vocal_traits": "[]",
|
|
"distortion_effects": "[]",
|
|
"is_narrator": d.get("is_narrator") or 0,
|
|
"is_group": d.get("is_general") or 0,
|
|
"description": "",
|
|
"source_project": d.get("source_project") or "",
|
|
"voice_ref_path": d.get("voice_ref_path"),
|
|
"source_kind": "registry",
|
|
}
|
|
)
|
|
|
|
merged.sort(
|
|
key=lambda s: (
|
|
-(s.get("is_narrator") or 0),
|
|
(s.get("primary_name") or "").lower(),
|
|
)
|
|
)
|
|
return templates.TemplateResponse(request, "_library_speakers.html", {"speakers": merged})
|
|
|
|
|
|
@router.get("/library/names", response_class=HTMLResponse)
|
|
async def library_names(request: Request):
|
|
from umbraaudio.library import list_names
|
|
|
|
names = list_names()
|
|
return templates.TemplateResponse(request, "_library_names.html", {"names": names})
|
|
|
|
|
|
@router.get("/library/utterances", response_class=HTMLResponse)
|
|
async def library_utterances(request: Request):
|
|
from umbraaudio.library import list_utterances
|
|
|
|
utterances = list_utterances()
|
|
return templates.TemplateResponse(
|
|
request, "_library_utterances.html", {"utterances": utterances}
|
|
)
|
|
|
|
|
|
@router.post("/api/library/speakers")
|
|
async def library_add_speakers(request: Request):
|
|
from umbraaudio.library import add_speakers
|
|
|
|
body = await request.json()
|
|
names = body.get("speaker_names") or body.get("names") or []
|
|
if not names:
|
|
return JSONResponse({"error": "speaker_names required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
speakers = _load_speakers(request)
|
|
by_name = {s["primary_name"]: s for s in speakers}
|
|
|
|
# Resolve voice ref for each selected speaker (mirrors voice_ref resolution).
|
|
from umbraaudio.voices.voice_designer import GENERAL_SPEAKER_NAMES
|
|
|
|
voices_dir = config.data_dir / "voices"
|
|
voice_refs: dict[str, Path] = {}
|
|
for name in names:
|
|
if name not in by_name:
|
|
continue
|
|
sp = by_name[name]
|
|
if sp.get("is_narrator"):
|
|
ref_name = "narrator_ref.wav"
|
|
elif name.lower() in GENERAL_SPEAKER_NAMES:
|
|
ref_name = "general_ref.wav"
|
|
else:
|
|
ref_name = name.lower().replace(" ", "_") + "_ref.wav"
|
|
ref_path = voices_dir / ref_name
|
|
if ref_path.exists():
|
|
voice_refs[name] = ref_path
|
|
|
|
to_add = [by_name[n] for n in names if n in by_name]
|
|
result = add_speakers(to_add, source_project=config.project_dir.name, voice_refs=voice_refs)
|
|
return JSONResponse({"ok": True, **result})
|
|
|
|
|
|
@router.post("/api/library/names")
|
|
async def library_add_names(request: Request):
|
|
from umbraaudio.library import add_names
|
|
|
|
body = await request.json()
|
|
names = body.get("names") or []
|
|
if not names:
|
|
return JSONResponse({"error": "names required"}, status_code=400)
|
|
|
|
config = _get_active_config(request)
|
|
phonetic = _load_pronunciation_records(config.data_dir)
|
|
records = []
|
|
for n in names:
|
|
rec = phonetic.get(n, {})
|
|
records.append(
|
|
{
|
|
"name": n,
|
|
"phonetic": rec.get("phonetic"),
|
|
"source": rec.get("source"),
|
|
"common": rec.get("common", False),
|
|
"confirmed": rec.get("confirmed", False),
|
|
}
|
|
)
|
|
result = add_names(records, source_project=config.project_dir.name)
|
|
return JSONResponse({"ok": True, **result})
|
|
|
|
|
|
@router.delete("/api/library/speakers/{speaker_id}")
|
|
async def library_delete_speaker(request: Request, speaker_id: int):
|
|
from umbraaudio.library import delete_speaker
|
|
|
|
ok = delete_speaker(speaker_id)
|
|
return JSONResponse({"ok": ok})
|
|
|
|
|
|
@router.post("/api/library/speakers/{speaker_id}/import")
|
|
async def library_import_to_project(request: Request, speaker_id: int):
|
|
"""Import a global library speaker into the active project."""
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
if not registry:
|
|
return JSONResponse({"error": "registry db not found"}, status_code=404)
|
|
row = registry.execute("SELECT * FROM library_speakers WHERE id = ?", (speaker_id,)).fetchone()
|
|
registry.close()
|
|
if not row:
|
|
return JSONResponse({"error": "library speaker not found"}, status_code=404)
|
|
r = dict(row)
|
|
try:
|
|
aliases = json.loads(r.get("aliases") or "[]")
|
|
except json.JSONDecodeError:
|
|
aliases = []
|
|
try:
|
|
vocal = json.loads(r.get("vocal_traits") or "[]")
|
|
except json.JSONDecodeError:
|
|
vocal = []
|
|
try:
|
|
dist = json.loads(r.get("distortion_effects") or "[]")
|
|
except json.JSONDecodeError:
|
|
dist = []
|
|
result = _import_speaker_record(
|
|
config,
|
|
name=r.get("primary_name") or "",
|
|
aliases=aliases,
|
|
gender=r.get("gender"),
|
|
accent=r.get("accent"),
|
|
description=r.get("description") or "",
|
|
vocal_traits=vocal,
|
|
distortion_effects=dist,
|
|
is_narrator=bool(r.get("is_narrator")),
|
|
is_group=bool(r.get("is_group")),
|
|
ref_path=r.get("voice_ref_path"),
|
|
)
|
|
return JSONResponse(result)
|
|
|
|
|
|
@router.post("/api/registry/{registry_id}/import")
|
|
async def registry_import_to_project(request: Request, registry_id: int):
|
|
"""Import a global registry voice into the active project."""
|
|
config = _get_active_config(request)
|
|
registry = _get_registry(config)
|
|
if not registry:
|
|
return JSONResponse({"error": "registry db not found"}, status_code=404)
|
|
row = registry.execute("SELECT * FROM voice_registry WHERE id = ?", (registry_id,)).fetchone()
|
|
registry.close()
|
|
if not row:
|
|
return JSONResponse({"error": "registry voice not found"}, status_code=404)
|
|
r = dict(row)
|
|
try:
|
|
aliases = json.loads(r.get("aliases") or "[]")
|
|
except json.JSONDecodeError:
|
|
aliases = []
|
|
result = _import_speaker_record(
|
|
config,
|
|
name=r.get("character_name") or "",
|
|
aliases=aliases,
|
|
gender=r.get("gender"),
|
|
accent=r.get("accent_marker"),
|
|
description="",
|
|
voice_description=r.get("voice_description") or "",
|
|
is_narrator=bool(r.get("is_narrator")),
|
|
is_group=bool(r.get("is_general")),
|
|
ref_path=r.get("voice_ref_path"),
|
|
)
|
|
return JSONResponse(result)
|
|
|
|
|
|
@router.delete("/api/library/names/{name_id}")
|
|
async def library_delete_name(request: Request, name_id: int):
|
|
from umbraaudio.library import delete_name
|
|
|
|
ok = delete_name(name_id)
|
|
return JSONResponse({"ok": ok})
|
|
|
|
|
|
@router.get("/library-audio/{speaker_id}/{filename}")
|
|
async def serve_library_audio(request: Request, speaker_id: int, filename: str):
|
|
from umbraaudio.library import speaker_voice_ref_path
|
|
|
|
audio_path = speaker_voice_ref_path(speaker_id)
|
|
if not audio_path or Path(audio_path).name != filename or not Path(audio_path).exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
return FileResponse(str(audio_path), media_type="audio/wav")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# m4b Meta
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _m4b_meta_view(request: Request) -> dict[str, Any]:
|
|
from umbraaudio.audiobook_meta import (
|
|
META_FIELDS,
|
|
completeness,
|
|
load_meta,
|
|
)
|
|
|
|
config = _get_active_config(request)
|
|
meta = load_meta(config.data_dir)
|
|
ready = completeness(meta)
|
|
return {
|
|
"fields": META_FIELDS,
|
|
"meta": meta,
|
|
"ready": ready,
|
|
"project_name": config.project_dir.name,
|
|
}
|
|
|
|
|
|
#: Fixed language order for the sample players, English first.
|
|
LANG_SAMPLE_ORDER = ["EN", "ZH", "JA", "KO", "DE", "ES", "FR", "RU", "IT", "PT"]
|
|
|
|
|
|
def _lang_samples_dir(request: Request) -> Path:
|
|
"""Where render_lang_test.py writes: ``<project>/Lang_test``.
|
|
|
|
Deliberately project_dir, not data_dir - .umbra is gitignored state and
|
|
Lang_test is render output that lives alongside the rest of the project.
|
|
"""
|
|
return _get_active_config(request).project_dir / "Lang_test"
|
|
|
|
|
|
def _discover_lang_samples(request: Request) -> list[dict]:
|
|
"""Per-language full-sample chapters found on disk, in the fixed order.
|
|
|
|
Discovered rather than hardcoded so a language that has not been rendered
|
|
simply does not appear, instead of showing a player that 404s on click.
|
|
"""
|
|
import soundfile as sf
|
|
|
|
root = _lang_samples_dir(request)
|
|
out: list[dict] = []
|
|
if not root.is_dir():
|
|
return out
|
|
seen = set()
|
|
for code in LANG_SAMPLE_ORDER:
|
|
f = root / code / f"_full_sample_{code}.wav"
|
|
seen.add(code)
|
|
if not f.exists():
|
|
continue
|
|
try:
|
|
info = sf.info(str(f))
|
|
dur = round(info.duration, 1)
|
|
except Exception:
|
|
dur = None
|
|
out.append(
|
|
{
|
|
"code": code,
|
|
"path": f,
|
|
"duration": dur,
|
|
"size_mb": round(f.stat().st_size / 1048576, 1),
|
|
}
|
|
)
|
|
# Anything rendered that is not in the fixed order still gets listed.
|
|
for d in sorted(p for p in root.iterdir() if p.is_dir() and p.name not in seen):
|
|
f = d / f"_full_sample_{d.name}.wav"
|
|
if not f.exists():
|
|
continue
|
|
out.append(
|
|
{
|
|
"code": d.name,
|
|
"path": f,
|
|
"duration": None,
|
|
"size_mb": round(f.stat().st_size / 1048576, 1),
|
|
}
|
|
)
|
|
combined = root / "_ALL_LANGUAGES_full_sample.wav"
|
|
if combined.exists():
|
|
out.append(
|
|
{
|
|
"code": "ALL",
|
|
"path": combined,
|
|
"duration": None,
|
|
"size_mb": round(combined.stat().st_size / 1048576, 1),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
@router.get("/lang-samples", response_class=HTMLResponse)
|
|
async def lang_samples_page(request: Request):
|
|
config = _get_active_config(request)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"lang_samples.html",
|
|
{
|
|
"request": request,
|
|
"samples": _discover_lang_samples(request),
|
|
"project_name": config.project_dir.name,
|
|
"project_slug": config.project_dir.name,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/lang-samples/audio/{code}")
|
|
async def lang_sample_audio(request: Request, code: str):
|
|
"""Serve one rendered language chapter.
|
|
|
|
The filename is built server-side from a validated two-letter code rather
|
|
than taken from the URL, so this cannot be walked out of the sample
|
|
directory.
|
|
"""
|
|
if not re.fullmatch(r"[A-Za-z]{2,3}", code):
|
|
return JSONResponse({"error": "invalid language code"}, status_code=400)
|
|
root = _lang_samples_dir(request)
|
|
upper = code.upper()
|
|
if upper == "ALL":
|
|
candidates = [root / "_ALL_LANGUAGES_full_sample.wav"]
|
|
else:
|
|
# A directory that does not exist is a miss, not a reason to fall back
|
|
# to some other file - otherwise an unrendered language silently plays
|
|
# the wrong audio instead of reporting that it has none.
|
|
candidates = [root / upper / f"_full_sample_{upper}.wav"]
|
|
for path in candidates:
|
|
if path.exists() and path.resolve().parent.is_relative_to(root.resolve()):
|
|
return FileResponse(str(path), media_type="audio/wav")
|
|
return JSONResponse({"error": f"no sample for {code}"}, status_code=404)
|
|
|
|
|
|
@router.get("/m4b-meta", response_class=HTMLResponse)
|
|
async def m4b_meta_page(request: Request):
|
|
return templates.TemplateResponse(request, "m4b_meta.html", _m4b_meta_view(request))
|
|
|
|
|
|
@router.get("/api/m4b-meta", response_class=JSONResponse)
|
|
async def m4b_meta_get(request: Request):
|
|
view = _m4b_meta_view(request)
|
|
return JSONResponse({"meta": view["meta"], "ready": view["ready"]})
|
|
|
|
|
|
@router.post("/api/m4b-meta", response_class=JSONResponse)
|
|
async def m4b_meta_save(request: Request):
|
|
from umbraaudio.audiobook_meta import completeness, load_meta, save_meta
|
|
|
|
config = _get_active_config(request)
|
|
body = await request.json()
|
|
values = body.get("meta") or body
|
|
path = save_meta(config.data_dir, values)
|
|
meta = load_meta(config.data_dir)
|
|
return JSONResponse({"ok": True, "path": str(path), "ready": completeness(meta)})
|
|
|
|
|
|
@router.post("/api/m4b-meta/import-epub", response_class=JSONResponse)
|
|
async def m4b_meta_import_epub(request: Request):
|
|
"""Lift publishing metadata + cover from the project's uploaded EPUB and
|
|
fill any still-empty m4b-meta fields (existing values are left untouched)."""
|
|
from umbraaudio.audiobook_meta import (
|
|
completeness,
|
|
find_project_epub,
|
|
load_meta,
|
|
merge_epub_metadata,
|
|
)
|
|
|
|
config = _get_active_config(request)
|
|
|
|
source_path = ""
|
|
try:
|
|
conn = sqlite3.connect(str(config.data_dir / "project.db"))
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute("SELECT source_path FROM project LIMIT 1").fetchone()
|
|
if row is not None:
|
|
source_path = str(row["source_path"] or "")
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
epub_path = find_project_epub(config.data_dir, source_path)
|
|
if epub_path is None:
|
|
return JSONResponse(
|
|
{
|
|
"error": "No uploaded EPUB found for this project (checked uploads/*.epub and project source_path)."
|
|
},
|
|
status_code=404,
|
|
)
|
|
|
|
try:
|
|
result = merge_epub_metadata(config.data_dir, epub_path)
|
|
except Exception as e:
|
|
logger.warning("EPUB metadata import failed: %s", e)
|
|
return JSONResponse({"error": f"Failed to import EPUB metadata: {e}"}, status_code=500)
|
|
|
|
if not result.get("ok"):
|
|
return JSONResponse({"error": result.get("error", "Import failed.")}, status_code=400)
|
|
|
|
saved_cover = str(result.get("cover") or "")
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"epub": result["epub"],
|
|
"filled": result.get("filled", {}),
|
|
"cover": saved_cover.rsplit("/", 1)[-1] if saved_cover else "",
|
|
"ready": completeness(load_meta(config.data_dir)),
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/api/m4b-meta/cover", response_class=JSONResponse)
|
|
async def m4b_meta_upload_cover(request: Request):
|
|
"""Accept a cover image file and write it into the project's .umbra dir."""
|
|
import shutil as _shutil
|
|
|
|
from umbraaudio.audiobook_meta import load_meta, meta_path, save_meta
|
|
|
|
config = _get_active_config(request)
|
|
meta = load_meta(config.data_dir)
|
|
|
|
form = await request.form()
|
|
cover = form.get("cover")
|
|
if cover is None or not getattr(cover, "filename", None):
|
|
return JSONResponse({"error": "cover file required"}, status_code=400)
|
|
|
|
uploads = config.data_dir / "m4b_assets"
|
|
uploads.mkdir(parents=True, exist_ok=True)
|
|
dest = uploads / Path(str(getattr(cover, "filename"))).name
|
|
with open(dest, "wb") as fh:
|
|
_shutil.copyfileobj(cover.file, fh)
|
|
|
|
rel = dest.relative_to(config.project_dir)
|
|
meta["cover_path"] = str(rel)
|
|
save_meta(config.data_dir, meta)
|
|
return JSONResponse({"ok": True, "path": str(rel), "stored": str(dest)})
|
|
|
|
|
|
@router.get("/m4b-assets/{filename}")
|
|
async def serve_m4b_asset(request: Request, filename: str):
|
|
from umbraaudio.audiobook_meta import load_meta
|
|
|
|
config = _get_active_config(request)
|
|
meta = load_meta(config.data_dir)
|
|
cover_field = str(meta.get("cover_path") or "").strip()
|
|
if not cover_field:
|
|
return JSONResponse({"error": "no cover set"}, status_code=404)
|
|
cover_field = cover_field.replace("\\", "/")
|
|
if cover_field.rsplit("/", 1)[-1] != filename:
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
path = config.data_dir / "m4b_assets" / filename
|
|
if not path.exists():
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
import mimetypes
|
|
|
|
media = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
return FileResponse(str(path), media_type=media)
|