Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
6.6 KiB
Python
210 lines
6.6 KiB
Python
"""SQLite-backed voice library index.
|
|
|
|
Audio files stay on disk. SQLite stores the computed voice-library payload so
|
|
the UI can load the last known library quickly while a scanner refreshes it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from core.constants import _VOICES_DIR_DEFAULT
|
|
from core.database import (
|
|
voice_index_delete,
|
|
voice_index_get_all,
|
|
voice_index_replace_all,
|
|
voice_index_status,
|
|
voice_index_upsert,
|
|
)
|
|
from core.voice import (
|
|
_hidden_voices_dir,
|
|
_active_voices_dir,
|
|
_picture_path,
|
|
_voice_audio_files,
|
|
_voice_entry,
|
|
)
|
|
|
|
logger = logging.getLogger("uvicorn.error")
|
|
|
|
_REFRESH_MIN_INTERVAL_SEC = 45
|
|
_refresh_lock = threading.Lock()
|
|
_refresh_thread: threading.Thread | None = None
|
|
_last_refresh_started = 0.0
|
|
|
|
|
|
def _path_mtime(path: Path | None) -> float:
|
|
if not path:
|
|
return 0.0
|
|
try:
|
|
return path.stat().st_mtime
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def _path_size(path: Path | None) -> int:
|
|
if not path:
|
|
return 0
|
|
try:
|
|
return path.stat().st_size
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _voice_sort_key(path: Path, active_dir: Path, hidden_dir: Path) -> tuple[str, int, str]:
|
|
try:
|
|
path.relative_to(active_dir)
|
|
folder_rank = 0
|
|
except ValueError:
|
|
try:
|
|
path.relative_to(hidden_dir)
|
|
folder_rank = 1
|
|
except ValueError:
|
|
folder_rank = 2
|
|
return (path.stem.lower(), folder_rank, str(path).lower())
|
|
|
|
|
|
def _sort_key_string(path: Path, active_dir: Path, hidden_dir: Path) -> str:
|
|
stem, rank, full_path = _voice_sort_key(path, active_dir, hidden_dir)
|
|
return f"{stem}\0{rank:02d}\0{full_path}"
|
|
|
|
|
|
def _apply_folder_enabled(entry: dict, path: Path, active_dir: Path, hidden_dir: Path) -> dict:
|
|
try:
|
|
path.relative_to(hidden_dir)
|
|
entry["enabled"] = False
|
|
return entry
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
path.relative_to(active_dir)
|
|
entry["enabled"] = True
|
|
except ValueError:
|
|
pass
|
|
return entry
|
|
|
|
|
|
def _row_for_audio(settings: dict, audio: Path) -> dict:
|
|
active_dir = _active_voices_dir(settings)
|
|
hidden_dir = _hidden_voices_dir(settings)
|
|
entry = _apply_folder_enabled(_voice_entry(audio), audio, active_dir, hidden_dir)
|
|
meta = audio.with_suffix(".meta.json")
|
|
ref = audio.with_suffix(".reference.txt")
|
|
picture = _picture_path(audio)
|
|
return {
|
|
"voice_id": audio.stem,
|
|
"path": str(audio),
|
|
"sort_key": _sort_key_string(audio, active_dir, hidden_dir),
|
|
"enabled": entry.get("enabled", True) is not False,
|
|
"file_type": audio.suffix.lower().lstrip("."),
|
|
"file_mtime": _path_mtime(audio),
|
|
"file_size": _path_size(audio),
|
|
"meta_mtime": _path_mtime(meta),
|
|
"ref_mtime": _path_mtime(ref),
|
|
"picture_mtime": _path_mtime(picture),
|
|
"entry": entry,
|
|
}
|
|
|
|
|
|
def rebuild_voice_index(settings: dict) -> list[dict]:
|
|
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
|
|
active_dir = _active_voices_dir(settings)
|
|
hidden_dir = _hidden_voices_dir(settings)
|
|
rows: list[dict] = []
|
|
if scan_dir.exists():
|
|
seen: set[str] = set()
|
|
all_audio = sorted(_voice_audio_files(scan_dir), key=lambda p: _voice_sort_key(p, active_dir, hidden_dir))
|
|
for audio in all_audio:
|
|
if audio.stem in seen:
|
|
continue
|
|
seen.add(audio.stem)
|
|
try:
|
|
rows.append(_row_for_audio(settings, audio))
|
|
except Exception as exc:
|
|
logger.warning("Could not index voice %s: %s", audio, exc)
|
|
# This scan can take a noticeable while over 100+ voices, and runs in a
|
|
# background thread while the app keeps serving requests — including
|
|
# DELETE /api/voice/{id}, which removes its index row immediately.
|
|
# A delete landing mid-scan (before this point) leaves that voice's row
|
|
# sitting in `rows` from when it still existed on disk; replacing the
|
|
# WHOLE table with that stale snapshot would resurrect it right after the
|
|
# delete already removed it. Confirmed live as deleted voices reappearing.
|
|
# Re-check existence right here, as close to the write as possible, to
|
|
# shrink that window down from "however long the scan took" to next to
|
|
# nothing.
|
|
rows = [row for row in rows if Path(row["path"]).exists()]
|
|
voice_index_replace_all(rows)
|
|
return [row["entry"] for row in rows]
|
|
|
|
|
|
def upsert_voice_in_index(settings: dict, audio: Path) -> dict:
|
|
row = _row_for_audio(settings, audio)
|
|
voice_index_upsert(row)
|
|
return row["entry"]
|
|
|
|
|
|
def remove_voice_from_index(voice_id: str) -> bool:
|
|
return voice_index_delete(voice_id)
|
|
|
|
|
|
def _status_age(status: dict) -> float | None:
|
|
raw = status.get("indexed_at")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
stamped = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
return max(0.0, (datetime.now(timezone.utc) - stamped).total_seconds())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _refresh_due(force: bool = False) -> bool:
|
|
if force:
|
|
return True
|
|
status = voice_index_status()
|
|
age = _status_age(status)
|
|
return age is None or age >= _REFRESH_MIN_INTERVAL_SEC
|
|
|
|
|
|
def refresh_voice_index_background(settings: dict, *, force: bool = False) -> bool:
|
|
global _refresh_thread, _last_refresh_started
|
|
if not _refresh_due(force):
|
|
return False
|
|
with _refresh_lock:
|
|
if _refresh_thread and _refresh_thread.is_alive():
|
|
return False
|
|
now = time.monotonic()
|
|
if not force and now - _last_refresh_started < _REFRESH_MIN_INTERVAL_SEC:
|
|
return False
|
|
_last_refresh_started = now
|
|
snapshot = dict(settings)
|
|
|
|
def runner() -> None:
|
|
try:
|
|
rebuild_voice_index(snapshot)
|
|
except Exception as exc:
|
|
logger.warning("Voice index refresh failed: %s", exc)
|
|
|
|
_refresh_thread = threading.Thread(target=runner, name="voice-index-refresh", daemon=True)
|
|
_refresh_thread.start()
|
|
return True
|
|
|
|
|
|
def indexed_voices(settings: dict, *, refresh: bool = False) -> list[dict]:
|
|
status = voice_index_status()
|
|
if refresh or not status.get("indexed_at"):
|
|
return rebuild_voice_index(settings)
|
|
voices = voice_index_get_all()
|
|
refresh_voice_index_background(settings)
|
|
return voices
|
|
|
|
|
|
def voice_index_info() -> dict:
|
|
status = voice_index_status()
|
|
age = _status_age(status)
|
|
status["refreshing"] = bool(_refresh_thread and _refresh_thread.is_alive())
|
|
status["age_sec"] = round(age, 2) if age is not None else None
|
|
return status
|