257 lines
7.8 KiB
Python
257 lines
7.8 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Cross-project voice/name library.
|
|
|
|
The Library stores speakers (with their voice reference audio), confirmed
|
|
proper-name pronunciations, and (future) reusable utterances so they can be
|
|
used on other audiobook projects. Data lives in the shared
|
|
``~/.umbra/voice_registry.db`` (same database as the Voice Registry) and the
|
|
copied voice samples live in ``~/.umbra/library/voices/``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DEFAULT_LIBRARY_DB = Path.home() / ".umbra" / "voice_registry.db"
|
|
DEFAULT_LIBRARY_VOICES = Path.home() / ".umbra" / "library" / "voices"
|
|
|
|
LIBRARY_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS library_speakers (
|
|
id INTEGER PRIMARY KEY,
|
|
primary_name TEXT NOT NULL,
|
|
aliases TEXT NOT NULL DEFAULT '[]',
|
|
gender TEXT,
|
|
accent TEXT,
|
|
vocal_traits TEXT NOT NULL DEFAULT '[]',
|
|
distortion_effects TEXT NOT NULL DEFAULT '[]',
|
|
is_narrator INTEGER NOT NULL DEFAULT 0,
|
|
is_group INTEGER NOT NULL DEFAULT 0,
|
|
description TEXT,
|
|
source_project TEXT,
|
|
voice_ref_path TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(primary_name)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS library_names (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
phonetic TEXT,
|
|
source TEXT,
|
|
common INTEGER NOT NULL DEFAULT 0,
|
|
confirmed INTEGER NOT NULL DEFAULT 0,
|
|
source_project TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(name)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS library_utterances (
|
|
id INTEGER PRIMARY KEY,
|
|
text TEXT NOT NULL,
|
|
speaker_name TEXT,
|
|
voice_ref_path TEXT,
|
|
note TEXT,
|
|
source_project TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
"""
|
|
|
|
|
|
def get_library_db_path() -> Path:
|
|
return DEFAULT_LIBRARY_DB
|
|
|
|
|
|
def get_library_voices_dir() -> Path:
|
|
return DEFAULT_LIBRARY_VOICES
|
|
|
|
|
|
def ensure_library_schema(db_path: Path | None = None) -> sqlite3.Connection:
|
|
path = db_path or get_library_db_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(LIBRARY_SCHEMA)
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _json_str(value: Any) -> str:
|
|
if isinstance(value, str):
|
|
try:
|
|
json.loads(value)
|
|
return value
|
|
except Exception:
|
|
return json.dumps([value])
|
|
if value is None:
|
|
return "[]"
|
|
return json.dumps(value)
|
|
|
|
|
|
def add_speakers(
|
|
speakers: list[dict],
|
|
source_project: str | None,
|
|
voice_refs: dict[str, Path],
|
|
db_path: Path | None = None,
|
|
voices_dir: Path | None = None,
|
|
) -> dict:
|
|
"""Add speakers to the library, copying their voice reference into the
|
|
library voices directory.
|
|
|
|
``speakers`` are the speaker dicts (from speakers.json). ``voice_refs``
|
|
maps speaker primary_name -> path to that speaker's ``*_ref.wav``.
|
|
Returns ``{"added": [...], "already": [...], "skipped": [...]}``.
|
|
"""
|
|
conn = ensure_library_schema(db_path)
|
|
vdir = voices_dir or get_library_voices_dir()
|
|
vdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
added, already, skipped = [], [], []
|
|
inserted = 0
|
|
for sp in speakers:
|
|
name = (sp.get("primary_name") or "").strip()
|
|
if not name:
|
|
continue
|
|
exists = conn.execute(
|
|
"SELECT id FROM library_speakers WHERE primary_name = ?", (name,)
|
|
).fetchone()
|
|
if exists:
|
|
already.append(name)
|
|
continue
|
|
|
|
ref_src = voice_refs.get(name)
|
|
copied_ref = None
|
|
if ref_src and Path(ref_src).exists():
|
|
dest = vdir / f"{name.lower().replace(' ', '_')}_ref.wav"
|
|
try:
|
|
shutil.copyfile(str(ref_src), str(dest))
|
|
copied_ref = str(dest)
|
|
except Exception:
|
|
copied_ref = None
|
|
|
|
conn.execute(
|
|
"""INSERT INTO library_speakers
|
|
(primary_name, aliases, gender, accent, vocal_traits,
|
|
distortion_effects, is_narrator, is_group, description,
|
|
source_project, voice_ref_path)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(
|
|
name,
|
|
_json_str(sp.get("aliases", [])),
|
|
sp.get("gender") or sp.get("demographics", {}).get("gender"),
|
|
sp.get("accent") or sp.get("demographics", {}).get("accent"),
|
|
_json_str(sp.get("vocal_traits", [])),
|
|
_json_str(sp.get("distortion_effects", [])),
|
|
1 if sp.get("is_narrator") else 0,
|
|
1 if sp.get("is_group") else 0,
|
|
sp.get("description"),
|
|
source_project,
|
|
copied_ref,
|
|
),
|
|
)
|
|
inserted += 1
|
|
added.append(name)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return {"added": added, "already": already, "skipped": skipped, "inserted": inserted}
|
|
|
|
|
|
def add_names(
|
|
names: list[dict],
|
|
source_project: str | None,
|
|
db_path: Path | None = None,
|
|
) -> dict:
|
|
"""Add proper-name pronunciation records to the library.
|
|
|
|
``names`` are records with at least ``name`` and optionally ``phonetic``,
|
|
``source``, ``common``, ``confirmed``.
|
|
"""
|
|
conn = ensure_library_schema(db_path)
|
|
added, already = [], []
|
|
for rec in names:
|
|
name = (rec.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
exists = conn.execute(
|
|
"SELECT id FROM library_names WHERE name = ?", (name,)
|
|
).fetchone()
|
|
if exists:
|
|
already.append(name)
|
|
continue
|
|
conn.execute(
|
|
"""INSERT INTO library_names (name, phonetic, source, common, confirmed, source_project)
|
|
VALUES (?,?,?,?,?,?)""",
|
|
(
|
|
name,
|
|
rec.get("phonetic"),
|
|
rec.get("source"),
|
|
1 if rec.get("common") else 0,
|
|
1 if rec.get("confirmed") else 0,
|
|
source_project,
|
|
),
|
|
)
|
|
added.append(name)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"added": added, "already": already}
|
|
|
|
|
|
def list_speakers(db_path: Path | None = None) -> list[dict]:
|
|
conn = ensure_library_schema(db_path)
|
|
rows = conn.execute(
|
|
"SELECT * FROM library_speakers ORDER BY is_narrator DESC, primary_name"
|
|
).fetchall()
|
|
data = [dict(r) for r in rows]
|
|
conn.close()
|
|
return data
|
|
|
|
|
|
def list_names(db_path: Path | None = None) -> list[dict]:
|
|
conn = ensure_library_schema(db_path)
|
|
rows = conn.execute(
|
|
"SELECT * FROM library_names ORDER BY name"
|
|
).fetchall()
|
|
data = [dict(r) for r in rows]
|
|
conn.close()
|
|
return data
|
|
|
|
|
|
def list_utterances(db_path: Path | None = None) -> list[dict]:
|
|
conn = ensure_library_schema(db_path)
|
|
rows = conn.execute(
|
|
"SELECT * FROM library_utterances ORDER BY id"
|
|
).fetchall()
|
|
data = [dict(r) for r in rows]
|
|
conn.close()
|
|
return data
|
|
|
|
|
|
def delete_speaker(speaker_id: int, db_path: Path | None = None) -> bool:
|
|
conn = ensure_library_schema(db_path)
|
|
cur = conn.execute("DELETE FROM library_speakers WHERE id = ?", (speaker_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def delete_name(name_id: int, db_path: Path | None = None) -> bool:
|
|
conn = ensure_library_schema(db_path)
|
|
cur = conn.execute("DELETE FROM library_names WHERE id = ?", (name_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def speaker_voice_ref_path(speaker_id: int, db_path: Path | None = None) -> Path | None:
|
|
conn = ensure_library_schema(db_path)
|
|
row = conn.execute(
|
|
"SELECT voice_ref_path FROM library_speakers WHERE id = ?", (speaker_id,)
|
|
).fetchone()
|
|
conn.close()
|
|
if row and row["voice_ref_path"]:
|
|
return Path(row["voice_ref_path"])
|
|
return None
|