The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
752 lines
27 KiB
Python
752 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiosqlite
|
|
|
|
SCHEMA = """
|
|
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,
|
|
emotion_confidence REAL DEFAULT 1.0,
|
|
user_edited INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
source_unit_key TEXT,
|
|
-- spec 31: the language this utterance's TEXT is written in. NULL means the
|
|
-- project's source language; a non-NULL value is a deliberate claim that the
|
|
-- line is in another language (a Spanish line inside an English book).
|
|
-- language_origin records where that claim came from ('unknown',
|
|
-- 'umbratrans', 'operator') so a machine identification is distinguishable
|
|
-- from a human correction.
|
|
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')),
|
|
-- spec 31 section 6.2: per-language voiced flag. 0 means this target
|
|
-- language must not render audio for this utterance, because the source
|
|
-- sentence was absorbed into another translated sentence.
|
|
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 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_source_unit ON utterances(source_unit_key);
|
|
CREATE INDEX IF NOT EXISTS idx_utterances_character ON utterances(character_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt ON utterance_translations(utterance_id);
|
|
CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang ON utterance_translations(language_code);
|
|
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);
|
|
"""
|
|
|
|
|
|
class Database:
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.db_path = db_path
|
|
self._conn: aiosqlite.Connection | None = None
|
|
|
|
async def connect(self) -> None:
|
|
self._conn = await aiosqlite.connect(str(self.db_path))
|
|
self._conn.row_factory = aiosqlite.Row
|
|
# WAL prevents SQLITE_BUSY when the web UI and a CLI/background batch
|
|
# touch the same project DB concurrently. Connections to the same DB
|
|
# share a single WAL; each new writer connection still enables it here.
|
|
try:
|
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
|
except Exception:
|
|
pass
|
|
await self._conn.executescript(SCHEMA)
|
|
await self._migrate()
|
|
await self._conn.commit()
|
|
|
|
async def _migrate(self) -> None:
|
|
cursor = await self._conn.execute("PRAGMA table_info(characters)")
|
|
existing_cols = {row[1] for row in await cursor.fetchall()}
|
|
|
|
new_cols = {
|
|
"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'",
|
|
"registry_id": "INTEGER",
|
|
}
|
|
for col, typedef in new_cols.items():
|
|
if col not in existing_cols:
|
|
await self._conn.execute(f"ALTER TABLE characters ADD COLUMN {col} {typedef}")
|
|
|
|
cursor = await self._conn.execute("PRAGMA table_info(utterances)")
|
|
utt_cols = {row[1] for row in await cursor.fetchall()}
|
|
if "user_edited" not in utt_cols:
|
|
await self._conn.execute(
|
|
"ALTER TABLE utterances ADD COLUMN user_edited INTEGER NOT NULL DEFAULT 0"
|
|
)
|
|
for col, typedef 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"),
|
|
("source_unit_key", "TEXT"),
|
|
("language_code", "TEXT"),
|
|
("language_origin", "TEXT"),
|
|
):
|
|
if col not in utt_cols:
|
|
await self._conn.execute(
|
|
f"ALTER TABLE utterances ADD COLUMN {col} {typedef}"
|
|
)
|
|
await self._conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterances_source_unit ON utterances(source_unit_key)"
|
|
)
|
|
# DELIBERATELY here and not in SCHEMA: for an existing project DB,
|
|
# `CREATE TABLE IF NOT EXISTS utterances` is a no-op, so language_code
|
|
# does not exist yet when SCHEMA runs. An index on it in SCHEMA fails
|
|
# with "no such column: language_code" and takes out every caller.
|
|
# The column is added by the ALTER above, so the index must follow it.
|
|
await self._conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)"
|
|
)
|
|
|
|
# spec 31: translations arrived before the project DB existed in its
|
|
# current shape, so the table is created here too and not only in SCHEMA.
|
|
await self._conn.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)
|
|
)
|
|
"""
|
|
)
|
|
await self._conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
|
|
"ON utterance_translations(utterance_id)"
|
|
)
|
|
await self._conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang "
|
|
"ON utterance_translations(language_code)"
|
|
)
|
|
|
|
# spec 31 section 13.1: the project's source language. An utterance
|
|
# with no language tag is written in this language, so the column has
|
|
# to live on the project rather than being implied by every row.
|
|
cursor = await self._conn.execute("PRAGMA table_info(project)")
|
|
project_cols = {row[1] for row in await cursor.fetchall()}
|
|
if "source_language" not in project_cols:
|
|
await self._conn.execute(
|
|
"ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'"
|
|
)
|
|
|
|
cursor = await self._conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='voice_review_history'"
|
|
)
|
|
if not await cursor.fetchone():
|
|
await self._conn.execute("""
|
|
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'))
|
|
)
|
|
""")
|
|
|
|
async def close(self) -> None:
|
|
if self._conn:
|
|
await self._conn.close()
|
|
|
|
@property
|
|
def conn(self) -> aiosqlite.Connection:
|
|
if not self._conn:
|
|
raise RuntimeError("Database not connected. Call connect() first.")
|
|
return self._conn
|
|
|
|
async def create_project(self, name: str, source_path: str, source_format: str) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO project (name, source_path, source_format) VALUES (?, ?, ?)",
|
|
(name, source_path, source_format),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def update_project_status(self, project_id: int, status: str) -> None:
|
|
await self.conn.execute(
|
|
"UPDATE project SET status = ?, updated_at = datetime('now') WHERE id = ?",
|
|
(status, project_id),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def insert_chapter(
|
|
self,
|
|
project_id: int,
|
|
index: int,
|
|
title: str | None,
|
|
source_text: str,
|
|
word_count: int,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO chapters (project_id, chapter_index, title, source_text, word_count) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(project_id, index, title, source_text, word_count),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def insert_character(
|
|
self,
|
|
project_id: int,
|
|
name: str,
|
|
aliases: list[str] | None = None,
|
|
gender: str | None = None,
|
|
age_range: str | None = None,
|
|
is_narrator: bool = False,
|
|
registry_id: int | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO characters "
|
|
"(project_id, name, aliases, gender, age_range, is_narrator, registry_id) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
project_id,
|
|
name,
|
|
json.dumps(aliases or []),
|
|
gender,
|
|
age_range,
|
|
int(is_narrator),
|
|
registry_id,
|
|
),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def update_character_profile(
|
|
self,
|
|
character_id: int,
|
|
profile_json: dict[str, Any],
|
|
voice_description: str | None = None,
|
|
) -> None:
|
|
taxonomy_fields = {}
|
|
for key in (
|
|
"pitch_register",
|
|
"pace_baseline",
|
|
"energy",
|
|
"timbre",
|
|
"texture",
|
|
"formality",
|
|
"emotional_baseline",
|
|
"distinguishing_trait",
|
|
"accent_marker",
|
|
):
|
|
if key in profile_json:
|
|
taxonomy_fields[key] = profile_json[key]
|
|
|
|
set_clauses = [
|
|
"profile_json = ?",
|
|
"voice_description = COALESCE(?, voice_description)",
|
|
"status = 'profiled'",
|
|
]
|
|
params: list[Any] = [json.dumps(profile_json), voice_description]
|
|
|
|
for field, value in taxonomy_fields.items():
|
|
set_clauses.append(f"{field} = ?")
|
|
params.append(value)
|
|
|
|
params.append(character_id)
|
|
await self.conn.execute(
|
|
f"UPDATE characters SET {', '.join(set_clauses)} WHERE id = ?",
|
|
params,
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def update_character_voice(
|
|
self, character_id: int, voice_ref_path: str, speaker_embedding: bytes | None = None
|
|
) -> None:
|
|
await self.conn.execute(
|
|
"UPDATE characters SET voice_ref_path = ?, speaker_embedding = ?, status = 'voiced' "
|
|
"WHERE id = ?",
|
|
(voice_ref_path, speaker_embedding, character_id),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def insert_pronunciation(
|
|
self, project_id: int, term: str, phonetic: str, notes: str | None = None
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT OR IGNORE INTO pronunciation_guide (project_id, term, phonetic, notes) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(project_id, term, phonetic, notes),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def insert_utterance(
|
|
self,
|
|
chapter_id: int,
|
|
character_id: int | None,
|
|
index: int,
|
|
text: str,
|
|
type: str = "narration",
|
|
context_before: str | None = None,
|
|
context_after: str | None = None,
|
|
emotion: str | None = None,
|
|
pace: str | None = None,
|
|
instruct: str | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO utterances "
|
|
"(chapter_id, character_id, utterance_index, text, type, context_before, context_after, "
|
|
"emotion, pace, instruct) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
chapter_id,
|
|
character_id,
|
|
index,
|
|
text,
|
|
type,
|
|
context_before,
|
|
context_after,
|
|
emotion,
|
|
pace,
|
|
instruct,
|
|
),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def insert_generated_audio(
|
|
self,
|
|
utterance_id: int,
|
|
character_id: int | None,
|
|
audio_path: str,
|
|
duration_ms: int | None = None,
|
|
generation_params: dict[str, Any] | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO generated_audio "
|
|
"(utterance_id, character_id, audio_path, duration_ms, generation_params) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
utterance_id,
|
|
character_id,
|
|
audio_path,
|
|
duration_ms,
|
|
json.dumps(generation_params) if generation_params else None,
|
|
),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def update_audio_quality(
|
|
self,
|
|
audio_id: int,
|
|
mos_score: float | None = None,
|
|
wer_score: float | None = None,
|
|
speaker_similarity: float | None = None,
|
|
artifact_score: float | None = None,
|
|
) -> None:
|
|
await self.conn.execute(
|
|
"UPDATE generated_audio SET mos_score = ?, wer_score = ?, "
|
|
"speaker_similarity = ?, artifact_score = ? WHERE id = ?",
|
|
(mos_score, wer_score, speaker_similarity, artifact_score, audio_id),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def insert_quality_report(
|
|
self,
|
|
project_id: int,
|
|
chapter_id: int | None,
|
|
report_type: str,
|
|
metrics: dict[str, Any],
|
|
passed: bool,
|
|
notes: str | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO quality_reports "
|
|
"(project_id, chapter_id, report_type, metrics_json, passed, notes) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(project_id, chapter_id, report_type, json.dumps(metrics), int(passed), notes),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def insert_review_decision(
|
|
self,
|
|
project_id: int,
|
|
entity_type: str,
|
|
entity_id: int,
|
|
decision: str,
|
|
notes: str | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO review_decisions (project_id, entity_type, entity_id, decision, notes) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(project_id, entity_type, entity_id, decision, notes),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def get_project(self, project_id: int) -> dict[str, Any] | None:
|
|
cursor = await self.conn.execute("SELECT * FROM project WHERE id = ?", (project_id,))
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def active_project_id(self) -> int:
|
|
"""Return the id of the project row in this project's DB.
|
|
|
|
One project per DB file is the isolation guarantee, so the active id is
|
|
simply the first (lowest) id in the ``project`` table. Raises if the DB
|
|
has no project row yet (e.g. before ``init`` has populated it).
|
|
"""
|
|
cursor = await self.conn.execute("SELECT id FROM project ORDER BY id LIMIT 1")
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
raise RuntimeError(
|
|
f"No project row in {self.db_path}; run 'umbraaudio init' first."
|
|
)
|
|
return int(row[0])
|
|
|
|
async def get_chapters(self, project_id: int) -> list[dict[str, Any]]:
|
|
cursor = await self.conn.execute(
|
|
"SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_index", (project_id,)
|
|
)
|
|
return [dict(r) for r in await cursor.fetchall()]
|
|
|
|
async def get_characters(self, project_id: int) -> list[dict[str, Any]]:
|
|
cursor = await self.conn.execute(
|
|
"SELECT * FROM characters WHERE project_id = ? ORDER BY is_narrator DESC, name",
|
|
(project_id,),
|
|
)
|
|
return [dict(r) for r in await cursor.fetchall()]
|
|
|
|
async def get_utterances(self, chapter_id: int) -> list[dict[str, Any]]:
|
|
cursor = await self.conn.execute(
|
|
"SELECT u.*, c.name as character_name FROM utterances u "
|
|
"LEFT JOIN characters c ON u.character_id = c.id "
|
|
"WHERE u.chapter_id = ? ORDER BY u.utterance_index",
|
|
(chapter_id,),
|
|
)
|
|
return [dict(r) for r in await cursor.fetchall()]
|
|
|
|
async def get_pronunciation_guide(self, project_id: int) -> dict[str, str]:
|
|
cursor = await self.conn.execute(
|
|
"SELECT term, phonetic FROM pronunciation_guide WHERE project_id = ?",
|
|
(project_id,),
|
|
)
|
|
return {row["term"]: row["phonetic"] for row in await cursor.fetchall()}
|
|
|
|
async def get_project_by_path(self, source_path: str) -> dict[str, Any] | None:
|
|
cursor = await self.conn.execute(
|
|
"SELECT * FROM project WHERE source_path = ?", (source_path,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
async def log_defect(
|
|
self,
|
|
project_id: int,
|
|
stage: str,
|
|
severity: str,
|
|
message: str,
|
|
context: str | None = None,
|
|
chapter_id: int | None = None,
|
|
) -> int:
|
|
metrics = {
|
|
"stage": stage,
|
|
"severity": severity,
|
|
"message": message,
|
|
"context": context,
|
|
}
|
|
return await self.insert_quality_report(
|
|
project_id=project_id,
|
|
chapter_id=chapter_id,
|
|
report_type=f"defect_{severity.lower()}",
|
|
metrics=metrics,
|
|
passed=False,
|
|
notes=message,
|
|
)
|
|
|
|
async def log_voice_review(
|
|
self,
|
|
character_name: str,
|
|
action: str,
|
|
prompt_text: str | None = None,
|
|
resulting_exaggeration: float | None = None,
|
|
resulting_cfg_weight: float | None = None,
|
|
taxonomy_snapshot: dict[str, Any] | None = None,
|
|
reviewed_by: str | None = None,
|
|
) -> int:
|
|
cursor = await self.conn.execute(
|
|
"INSERT INTO voice_review_history "
|
|
"(character_name, action, prompt_text, resulting_exaggeration, "
|
|
"resulting_cfg_weight, taxonomy_snapshot_json, reviewed_by) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
character_name,
|
|
action,
|
|
prompt_text,
|
|
resulting_exaggeration,
|
|
resulting_cfg_weight,
|
|
json.dumps(taxonomy_snapshot) if taxonomy_snapshot else None,
|
|
reviewed_by,
|
|
),
|
|
)
|
|
await self.conn.commit()
|
|
return cursor.lastrowid # type: ignore[return-value]
|
|
|
|
async def get_voice_review_history(self, character_name: str) -> list[dict[str, Any]]:
|
|
cursor = await self.conn.execute(
|
|
"SELECT * FROM voice_review_history WHERE character_name = ? ORDER BY reviewed_at",
|
|
(character_name,),
|
|
)
|
|
return [dict(r) for r in await cursor.fetchall()]
|
|
|
|
async def update_character_status(self, character_id: int, status: str) -> None:
|
|
await self.conn.execute(
|
|
"UPDATE characters SET status = ? WHERE id = ?",
|
|
(status, character_id),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def update_utterance_speaker(
|
|
self,
|
|
utterance_id: int,
|
|
speaker_name: str,
|
|
attribution_confidence: float | None = None,
|
|
attribution_reasoning: str | None = None,
|
|
emotion: str | None = None,
|
|
emotion_confidence: float | None = None,
|
|
evidence_kind: str | None = None,
|
|
tag_name: str | None = None,
|
|
uncertain: int | None = None,
|
|
needs_review: int | None = None,
|
|
review_reason: str | None = None,
|
|
) -> None:
|
|
"""Update utterance with LLM attribution results."""
|
|
fields = ["speaker_name = ?"]
|
|
params = [speaker_name]
|
|
|
|
if attribution_confidence is not None:
|
|
fields.append("attribution_confidence = ?")
|
|
params.append(attribution_confidence)
|
|
|
|
if attribution_reasoning is not None:
|
|
fields.append("attribution_reasoning = ?")
|
|
params.append(attribution_reasoning)
|
|
|
|
if emotion is not None:
|
|
fields.append("emotion = ?")
|
|
params.append(emotion)
|
|
|
|
if emotion_confidence is not None:
|
|
fields.append("emotion_confidence = ?")
|
|
params.append(emotion_confidence)
|
|
|
|
if evidence_kind is not None:
|
|
fields.append("attribution_evidence_kind = ?")
|
|
params.append(evidence_kind)
|
|
|
|
if tag_name is not None:
|
|
fields.append("attribution_tag_name = ?")
|
|
params.append(tag_name)
|
|
|
|
if uncertain is not None:
|
|
fields.append("attribution_uncertain = ?")
|
|
params.append(uncertain)
|
|
|
|
if needs_review is not None:
|
|
fields.append("needs_review = ?")
|
|
params.append(needs_review)
|
|
|
|
if review_reason is not None:
|
|
fields.append("review_reason = ?")
|
|
params.append(review_reason)
|
|
|
|
params.append(utterance_id)
|
|
await self.conn.execute(
|
|
f"UPDATE utterances SET {', '.join(fields)} WHERE id = ?",
|
|
params,
|
|
)
|
|
await self.conn.commit()
|