From dd697590ef47f9c51cb2f6015d59475d542586da Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 30 Jun 2026 15:06:43 +0200 Subject: [PATCH] Back user state with SQLite voice index --- CHANGELOG.md | 13 +++ VERSION | 2 +- core/config.py | 36 +++++-- core/database.py | 192 ++++++++++++++++++++++++++++++++++- core/presets.py | 15 +++ core/routing.py | 47 ++++++--- core/voice_index.py | 198 +++++++++++++++++++++++++++++++++++++ routes/conversation.py | 2 + routes/library.py | 64 ++++++------ routes/sources.py | 2 + routes/tts.py | 20 ++++ server.py | 6 ++ static/index.html | 6 +- static/js/voice-library.js | 7 +- 14 files changed, 547 insertions(+), 63 deletions(-) create mode 100644 core/voice_index.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ffad1..7e2e2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.55] — 2026-06-30 + +### Added +- **SQLite-backed app state** — server settings, TTS routes, and voice-design presets now persist through `config/tts_creator.db` via a shared `app_state` table while continuing to mirror JSON files for compatibility. +- **SQLite voice library index** — `/api/voices` now reads the last indexed voice payload from SQLite and refreshes the filesystem scan in the background; first load and explicit refresh still rebuild from disk. +- **Voice index status endpoint** — `GET /api/voices/index` reports index count, age, timestamp, and refresh state. + +### Performance +- **Faster voice library loads** — normal voice-list requests avoid rescanning every audio/meta/reference/image file on each page load; the Refresh button uses `/api/voices?refresh=1` when a full rescan is needed. +- **Shared voice dropdown cache** — TTS backend voice options now prefer the same SQLite index before falling back to filesystem scanning. + +--- + ## [1.12.54] — 2026-06-30 ### Fixed diff --git a/VERSION b/VERSION index abf6bfc..a32117e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.54 +1.12.55 diff --git a/core/config.py b/core/config.py index ec29a01..0ce7a46 100644 --- a/core/config.py +++ b/core/config.py @@ -18,6 +18,7 @@ from core.constants import ( _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT, _TTS_STREAM_DEFAULT, ) +from core.database import state_get, state_put, state_updated # ── Settings key whitelist ──────────────────────────────────────────────────── @@ -225,12 +226,18 @@ def _normalize_settings(s: dict) -> dict: _settings_cache: dict | None = None _settings_cache_mtime: float = -1.0 +_settings_cache_db_updated: str = "" def _load_settings() -> dict: - global _settings_cache, _settings_cache_mtime + global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated mtime = CONFIG_FILE.stat().st_mtime if CONFIG_FILE.exists() else 0.0 - if _settings_cache is not None and mtime == _settings_cache_mtime: + db_updated = state_updated("settings") + if ( + _settings_cache is not None + and mtime == _settings_cache_mtime + and db_updated == _settings_cache_db_updated + ): return dict(_settings_cache) defaults = { "whisper_url": _WHISPER_DEFAULT, @@ -285,21 +292,36 @@ def _load_settings() -> dict: "seed_finder_dir": "", "pt_dir": "", } + file_saved = None if CONFIG_FILE.exists(): try: - saved = json.loads(CONFIG_FILE.read_text()) - defaults.update({k: v for k, v in saved.items() if k in _SETTINGS_KEYS}) + file_saved = json.loads(CONFIG_FILE.read_text()) + if isinstance(file_saved, dict): + defaults.update({k: v for k, v in file_saved.items() if k in _SETTINGS_KEYS}) except Exception: pass + db_saved = state_get("settings", None) + if isinstance(db_saved, dict): + defaults.update({k: v for k, v in db_saved.items() if k in _SETTINGS_KEYS}) result = _normalize_settings(defaults) + if not db_updated and (file_saved is not None or CONFIG_FILE.exists()): + try: + state_put("settings", result) + db_updated = state_updated("settings") + except Exception: + pass _settings_cache = result _settings_cache_mtime = mtime + _settings_cache_db_updated = db_updated return dict(result) def _save_settings(s: dict) -> None: - global _settings_cache, _settings_cache_mtime + global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated CONFIG_DIR.mkdir(parents=True, exist_ok=True) - CONFIG_FILE.write_text(json.dumps(s, indent=2)) - _settings_cache = s + clean = _normalize_settings({k: v for k, v in s.items() if k in _SETTINGS_KEYS}) + state_put("settings", clean) + CONFIG_FILE.write_text(json.dumps(clean, indent=2)) + _settings_cache = clean _settings_cache_mtime = CONFIG_FILE.stat().st_mtime + _settings_cache_db_updated = state_updated("settings") diff --git a/core/database.py b/core/database.py index 00a3399..aa0d613 100644 --- a/core/database.py +++ b/core/database.py @@ -1,4 +1,4 @@ -"""SQLite persistence layer for characters and rehearsals. +"""SQLite persistence layer for user-owned app data. Single-file database at CONFIG_DIR/tts_creator.db. Designed for easy migration to PostgreSQL / Supabase later: @@ -10,6 +10,7 @@ from __future__ import annotations import json import sqlite3 +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -48,6 +49,29 @@ CREATE TABLE IF NOT EXISTS rehearsals ( updated TEXT ); CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); + +CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT 'null', + updated TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS voice_index ( + voice_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + sort_key TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + file_type TEXT NOT NULL DEFAULT '', + file_mtime REAL NOT NULL DEFAULT 0, + file_size INTEGER NOT NULL DEFAULT 0, + meta_mtime REAL NOT NULL DEFAULT 0, + ref_mtime REAL NOT NULL DEFAULT 0, + picture_mtime REAL NOT NULL DEFAULT 0, + entry_json TEXT NOT NULL DEFAULT '{}', + indexed_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_voice_index_enabled ON voice_index(enabled); +CREATE INDEX IF NOT EXISTS idx_voice_index_sort_key ON voice_index(sort_key); """ @@ -73,6 +97,10 @@ def _j(v: Any) -> str: return json.dumps(v, ensure_ascii=False, default=str) +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + def _row_to_char(row: sqlite3.Row) -> dict: d = dict(row) d["sheet"] = json.loads(d.get("sheet") or "{}") @@ -211,3 +239,165 @@ def reh_delete(reh_id: int) -> bool: conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) conn.commit() return True + + +# ── Generic app state ───────────────────────────────────────────────────────── + +def state_get(key: str, default: Any = None) -> Any: + with _open() as conn: + row = conn.execute("SELECT value FROM app_state WHERE key=?", (key,)).fetchone() + if not row: + return default + try: + return json.loads(row["value"]) + except Exception: + return default + + +def state_updated(key: str) -> str: + with _open() as conn: + row = conn.execute("SELECT updated FROM app_state WHERE key=?", (key,)).fetchone() + return str(row["updated"]) if row else "" + + +def state_put(key: str, value: Any) -> Any: + updated = _now() + with _open() as conn: + conn.execute(""" + INSERT INTO app_state (key, value, updated) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value=excluded.value, + updated=excluded.updated + """, (key, _j(value), updated)) + conn.commit() + return value + + +def state_delete(key: str) -> bool: + with _open() as conn: + conn.execute("DELETE FROM app_state WHERE key=?", (key,)) + conn.commit() + return True + + +# ── Voice library index ─────────────────────────────────────────────────────── + +def voice_index_get_all() -> list[dict]: + with _open() as conn: + rows = conn.execute( + "SELECT entry_json FROM voice_index ORDER BY sort_key, voice_id" + ).fetchall() + voices: list[dict] = [] + for row in rows: + try: + entry = json.loads(row["entry_json"] or "{}") + if isinstance(entry, dict): + voices.append(entry) + except Exception: + continue + return voices + + +def voice_index_count() -> int: + with _open() as conn: + row = conn.execute("SELECT COUNT(*) AS n FROM voice_index").fetchone() + return int(row["n"] if row else 0) + + +def voice_index_replace_all(rows: list[dict]) -> None: + indexed_at = _now() + with _open() as conn: + conn.execute("DELETE FROM voice_index") + conn.executemany(""" + INSERT INTO voice_index ( + voice_id, path, sort_key, enabled, file_type, file_mtime, + file_size, meta_mtime, ref_mtime, picture_mtime, entry_json, indexed_at + ) + VALUES ( + :voice_id, :path, :sort_key, :enabled, :file_type, :file_mtime, + :file_size, :meta_mtime, :ref_mtime, :picture_mtime, :entry_json, :indexed_at + ) + """, [ + { + "voice_id": str(row.get("voice_id", "")), + "path": str(row.get("path", "")), + "sort_key": str(row.get("sort_key", "")), + "enabled": 1 if row.get("enabled", True) else 0, + "file_type": str(row.get("file_type", "")), + "file_mtime": float(row.get("file_mtime") or 0), + "file_size": int(row.get("file_size") or 0), + "meta_mtime": float(row.get("meta_mtime") or 0), + "ref_mtime": float(row.get("ref_mtime") or 0), + "picture_mtime": float(row.get("picture_mtime") or 0), + "entry_json": _j(row.get("entry", {})), + "indexed_at": indexed_at, + } + for row in rows + if row.get("voice_id") and row.get("path") + ]) + conn.execute(""" + INSERT INTO app_state (key, value, updated) + VALUES ('voice_index_status', ?, ?) + ON CONFLICT(key) DO UPDATE SET + value=excluded.value, + updated=excluded.updated + """, (_j({"count": len(rows), "indexed_at": indexed_at}), indexed_at)) + conn.commit() + + +def voice_index_upsert(row: dict) -> None: + indexed_at = _now() + with _open() as conn: + conn.execute(""" + INSERT INTO voice_index ( + voice_id, path, sort_key, enabled, file_type, file_mtime, + file_size, meta_mtime, ref_mtime, picture_mtime, entry_json, indexed_at + ) + VALUES ( + :voice_id, :path, :sort_key, :enabled, :file_type, :file_mtime, + :file_size, :meta_mtime, :ref_mtime, :picture_mtime, :entry_json, :indexed_at + ) + ON CONFLICT(voice_id) DO UPDATE SET + path=excluded.path, + sort_key=excluded.sort_key, + enabled=excluded.enabled, + file_type=excluded.file_type, + file_mtime=excluded.file_mtime, + file_size=excluded.file_size, + meta_mtime=excluded.meta_mtime, + ref_mtime=excluded.ref_mtime, + picture_mtime=excluded.picture_mtime, + entry_json=excluded.entry_json, + indexed_at=excluded.indexed_at + """, { + "voice_id": str(row.get("voice_id", "")), + "path": str(row.get("path", "")), + "sort_key": str(row.get("sort_key", "")), + "enabled": 1 if row.get("enabled", True) else 0, + "file_type": str(row.get("file_type", "")), + "file_mtime": float(row.get("file_mtime") or 0), + "file_size": int(row.get("file_size") or 0), + "meta_mtime": float(row.get("meta_mtime") or 0), + "ref_mtime": float(row.get("ref_mtime") or 0), + "picture_mtime": float(row.get("picture_mtime") or 0), + "entry_json": _j(row.get("entry", {})), + "indexed_at": indexed_at, + }) + conn.commit() + + +def voice_index_delete(voice_id: str) -> bool: + with _open() as conn: + conn.execute("DELETE FROM voice_index WHERE voice_id=?", (voice_id,)) + conn.commit() + return True + + +def voice_index_status() -> dict: + status = state_get("voice_index_status", {}) or {} + if not isinstance(status, dict): + status = {} + status.setdefault("count", voice_index_count()) + status.setdefault("indexed_at", state_updated("voice_index_status")) + return status diff --git a/core/presets.py b/core/presets.py index 99a7215..d81498a 100644 --- a/core/presets.py +++ b/core/presets.py @@ -5,6 +5,7 @@ import json import re from core.constants import CONFIG_DIR, DESIGN_PRESETS_FILE +from core.database import state_get, state_put _DEFAULT_DESIGN_PRESETS = { "EN_M_Young_Energetic": { @@ -61,6 +62,19 @@ def _load_design_presets() -> dict: } except Exception: pass + try: + saved = state_get("voice_design_presets", None) + if isinstance(saved, dict): + for name, preset in saved.items(): + if isinstance(preset, dict): + presets[_slug_voice_design_name(str(name))] = { + "description": str(preset.get("description", "")), + "sample_text": str(preset.get("sample_text", preset.get("text", ""))), + "language": str(preset.get("language", "Auto")), + "gender": str(preset.get("gender", "N")), + } + except Exception: + pass return presets @@ -77,6 +91,7 @@ def _save_design_presets(presets: dict) -> None: "gender": str(preset.get("gender", "N")), } CONFIG_DIR.mkdir(parents=True, exist_ok=True) + state_put("voice_design_presets", cleaned) DESIGN_PRESETS_FILE.write_text(json.dumps(cleaned, indent=2)) diff --git a/core/routing.py b/core/routing.py index 6f06f06..374fec9 100644 --- a/core/routing.py +++ b/core/routing.py @@ -7,6 +7,7 @@ import re from fastapi import Request from core.constants import TTS_ROUTES_FILE, _routing_log_add +from core.database import state_get, state_put, state_updated # ── Route token / backend validation ───────────────────────────────────────── @@ -81,38 +82,58 @@ def _normalize_route(rule: dict, idx: int = 0) -> dict: _routes_cache: list[dict] | None = None _routes_cache_mtime: float = -1.0 +_routes_cache_db_updated: str = "" def _load_tts_routes() -> list[dict]: - global _routes_cache, _routes_cache_mtime + global _routes_cache, _routes_cache_mtime, _routes_cache_db_updated mtime = TTS_ROUTES_FILE.stat().st_mtime if TTS_ROUTES_FILE.exists() else 0.0 - if _routes_cache is not None and mtime == _routes_cache_mtime: + db_updated = state_updated("tts_routes") + if ( + _routes_cache is not None + and mtime == _routes_cache_mtime + and db_updated == _routes_cache_db_updated + ): return list(_routes_cache) - if not TTS_ROUTES_FILE.exists(): - _routes_cache = [] - _routes_cache_mtime = 0.0 - return [] + result: list[dict] = [] try: - raw = json.loads(TTS_ROUTES_FILE.read_text()) - routes = raw.get("routes", raw) if isinstance(raw, dict) else raw - if not isinstance(routes, list): - result: list[dict] = [] - else: - result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)] + if TTS_ROUTES_FILE.exists(): + raw = json.loads(TTS_ROUTES_FILE.read_text()) + routes = raw.get("routes", raw) if isinstance(raw, dict) else raw + if isinstance(routes, list): + result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)] except Exception: result = [] + try: + db_routes = state_get("tts_routes", None) + if isinstance(db_routes, dict): + db_routes = db_routes.get("routes") + if isinstance(db_routes, list): + routes = db_routes + result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)] + except Exception: + pass + if not db_updated and result: + try: + state_put("tts_routes", {"routes": result}) + db_updated = state_updated("tts_routes") + except Exception: + pass _routes_cache = result _routes_cache_mtime = mtime + _routes_cache_db_updated = db_updated return list(result) def _save_tts_routes(routes: list[dict]) -> None: - global _routes_cache, _routes_cache_mtime + global _routes_cache, _routes_cache_mtime, _routes_cache_db_updated TTS_ROUTES_FILE.parent.mkdir(parents=True, exist_ok=True) clean = [_normalize_route(r, i) for i, r in enumerate(routes)] + state_put("tts_routes", {"routes": clean}) TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2)) _routes_cache = clean _routes_cache_mtime = TTS_ROUTES_FILE.stat().st_mtime + _routes_cache_db_updated = state_updated("tts_routes") # ── App name helpers ────────────────────────────────────────────────────────── diff --git a/core/voice_index.py b/core/voice_index.py new file mode 100644 index 0000000..e4e59f0 --- /dev/null +++ b/core/voice_index.py @@ -0,0 +1,198 @@ +"""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) + 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 diff --git a/routes/conversation.py b/routes/conversation.py index 9b4d7ff..c911801 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -28,6 +28,7 @@ from core.voice import ( _find_voice_audio, _load_meta, _active_voices_dir, _voice_audio_files, _is_internal_voice_file, ) +from core.voice_index import rebuild_voice_index from core.tts_helpers import _preview_request_audio from routes.stt import _transcribe_audio, _clean_stt_backend @@ -985,6 +986,7 @@ async def voices_import(file: UploadFile = File(...)): except Exception as e: raise HTTPException(500, f"Import failed: {e}") + await asyncio.to_thread(rebuild_voice_index, settings) return {"ok": True, "imported": imported} diff --git a/routes/library.py b/routes/library.py index a3f3752..abcd8da 100644 --- a/routes/library.py +++ b/routes/library.py @@ -28,6 +28,14 @@ from core.voice import ( _move_voice_package, _read_reference_text, _voice_health, _is_internal_voice_file, _benchmark_voice, ) +from core.voice_index import ( + indexed_voices, + refresh_voice_index_background, + remove_voice_from_index, + rebuild_voice_index, + upsert_voice_in_index, + voice_index_info, +) router = APIRouter() @@ -207,6 +215,7 @@ async def save_voice(request: Request): meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav_dest, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "loudness": loudness} @@ -295,50 +304,22 @@ async def replace_voice_audio(request: Request): shutil.copy2(str(old_pic), str(new_pic)) keep.add(new_pic) _remove_voice_package(current, keep) + remove_voice_from_index(old_id) + await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest), "duration": _duration(wav_dest), "file_type": "wav", "loudness": loudness, "backup": str(backup) if backup else None} @router.get("/api/voices") -async def list_voices(): +async def list_voices(refresh: bool = False): settings = _load_settings() - scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) - active_dir = _active_voices_dir(settings) - hidden_dir = _hidden_voices_dir(settings) - voices = [] - if scan_dir.exists(): - seen: set[str] = set() - all_audio = list(_voice_audio_files(scan_dir)) + return await asyncio.to_thread(indexed_voices, settings, refresh=refresh) - def voice_sort_key(path: Path): - 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()) - for p in sorted(all_audio, key=voice_sort_key): - if p.stem in seen: - continue - seen.add(p.stem) - entry = _voice_entry(p) - try: - p.relative_to(hidden_dir) - entry["enabled"] = False - except ValueError: - try: - p.relative_to(active_dir) - entry["enabled"] = True - except ValueError: - pass - voices.append(entry) - return voices +@router.get("/api/voices/index") +async def get_voice_index_info(): + return voice_index_info() @router.post("/api/voice/meta") @@ -368,6 +349,7 @@ async def update_voice_meta(request: Request): _save_meta(wav, meta) if "transcript" in data: wav.with_suffix(".reference.txt").write_text(data.get("transcript", "").strip(), encoding="utf-8") + await asyncio.to_thread(upsert_voice_in_index, settings, wav) return {"ok": True, "path": str(wav), "enabled": meta.get("enabled", True), "transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip() if wav.with_suffix(".reference.txt").exists() else ""} @@ -404,6 +386,7 @@ async def sync_voice_folders(): except HTTPException as e: conflicts.append({"voice_id": p.stem, "detail": e.detail}) + await asyncio.to_thread(rebuild_voice_index, settings) return {"moved": moved, "conflicts": conflicts, "active_dir": str(active_dir), "hidden_dir": str(hidden_dir)} @@ -429,6 +412,7 @@ async def calculate_voice_db(): meta = _load_meta(audio) meta["loudness"] = loudness _save_meta(audio, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, audio) results.append({"voice_id": audio.stem, "path": str(audio), "loudness": loudness}) except Exception as e: errors.append({"voice_id": audio.stem, "detail": str(e)}) @@ -481,6 +465,7 @@ async def benchmark_voices(request: Request): meta = _load_meta(audio) meta["benchmark"] = benchmark _save_meta(audio, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, audio) except Exception as e: errors.append({"voice_id": audio.stem, "detail": f"Could not save benchmark: {e}"}) item = {"voice_id": audio.stem, "path": str(audio), "benchmark": benchmark} @@ -518,6 +503,7 @@ async def normalize_active_voices(): meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, wav) normalized.append({"voice_id": wav.stem, **loudness}) except Exception as e: errors.append({"voice_id": wav.stem, "detail": str(e)}) @@ -561,6 +547,7 @@ async def normalize_voice(request: Request): meta["loudness"] = loudness meta["needs_tts_restart"] = True _save_meta(audio, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, audio) return {"ok": True, "voice_id": audio.stem, "path": str(audio), "duration": _duration(audio), "file_type": "wav", "loudness": loudness} except Exception as e: @@ -593,6 +580,7 @@ async def undo_voice_edit(request: Request): meta.pop("loudness", None) meta["needs_tts_restart"] = True _save_meta(restored, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, restored) return { "ok": True, "voice_id": restored.stem, @@ -658,6 +646,8 @@ async def rename_voice(request: Request): new_meta["original_backup"] = str(new_backup) new_meta["needs_tts_restart"] = True _save_meta(new_audio, new_meta) + remove_voice_from_index(old_id) + await asyncio.to_thread(upsert_voice_in_index, settings, new_audio) return {"new_id": new_id, "path": str(new_audio), "file_type": new_audio.suffix.lower().lstrip(".")} @@ -681,6 +671,7 @@ async def delete_voice_group(request: Request): if f.exists(): f.unlink() deleted_ids.append(p.stem) + remove_voice_from_index(p.stem) return {"deleted": deleted_ids, "count": len(deleted_ids)} @@ -698,6 +689,7 @@ async def delete_voice(voice_id: str): if f.exists(): f.unlink() deleted.append(f.name) + remove_voice_from_index(voice_id) return {"deleted": deleted} @@ -724,6 +716,7 @@ async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...) with dest.open("wb") as f: _copy_limited(file.file, f, _MAX_PICTURE_BYTES) + await asyncio.to_thread(upsert_voice_in_index, settings, wav) return {"ok": True, "path": str(dest)} @@ -777,6 +770,7 @@ async def upload_picture_url(request: Request): raise except Exception as e: raise HTTPException(400, f"Image import failed: {e}") + await asyncio.to_thread(upsert_voice_in_index, settings, wav) return {"ok": True, "voice_id": voice_id, "path": str(dest)} diff --git a/routes/sources.py b/routes/sources.py index f179acc..0f6efa6 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -24,6 +24,7 @@ from core.voice import ( _AUDIO_EXTS, _active_voices_dir, _remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta, ) +from core.voice_index import upsert_voice_in_index router = APIRouter() @@ -550,6 +551,7 @@ async def quick_import_voice(request: Request): meta["enabled"] = True meta["loudness"] = loudness _save_meta(wav_dest, meta) + await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) return {"voice_id": final_id, "loudness": loudness} diff --git a/routes/tts.py b/routes/tts.py index 5836f1a..eedfaf7 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -32,6 +32,7 @@ from core.voice import ( _AUDIO_EXTS, _voice_health, ) +from core.voice_index import indexed_voices, refresh_voice_index_background from core.validation import _validate_http_url from core.registry import _registry_put, TEMP_DIR from core.tts_helpers import ( @@ -224,6 +225,23 @@ def _voice_ids_from_payload(payload) -> list: def _active_library_voice_options(settings: dict) -> list[dict]: + try: + indexed = [] + for voice in indexed_voices(settings): + if voice.get("enabled", True) is False: + continue + indexed.append({ + "id": voice.get("id", ""), + "name": voice.get("name") or voice.get("id", ""), + "duration": voice.get("duration"), + "has_ref": bool(voice.get("has_ref")), + "has_transcript": bool(voice.get("transcript")), + "seed": voice.get("seed"), + }) + return [v for v in indexed if v["id"]] + except Exception: + pass + active_dir = _active_voices_dir(settings) voices = [] seen = set() @@ -290,6 +308,8 @@ def _clear_tts_restart_flags(settings: dict | None = None) -> int: if meta.pop("needs_tts_restart", None) is not None: _save_meta(audio, meta) cleared += 1 + if cleared: + refresh_voice_index_background(settings, force=True) return cleared diff --git a/server.py b/server.py index f33c7a9..59c367d 100644 --- a/server.py +++ b/server.py @@ -13,6 +13,8 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from core.constants import STATIC_DIR, _BufferHandler +from core.config import _load_settings +from core.voice_index import refresh_voice_index_background from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db logger = logging.getLogger("uvicorn.error") @@ -29,6 +31,10 @@ async def lifespan(app: FastAPI): l.addHandler(_buf_handler) if "_file_handler" in globals() and _file_handler not in l.handlers: l.addHandler(_file_handler) + try: + refresh_voice_index_background(_load_settings()) + except Exception as exc: + logger.warning("Could not start voice index refresh: %s", exc) yield app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan) diff --git a/static/index.html b/static/index.html index af7206c..ca0340d 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/voice-library.js b/static/js/voice-library.js index f52800c..32756ce 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -639,7 +639,8 @@ function shouldRenderVoiceLibrary() { return !section || section.classList.contains('is-active'); } -async function loadVoiceLibrary() { +async function loadVoiceLibrary(options = {}) { + const forceRefresh = !!(options && options.refresh); if (_libraryLoadPromise) { return _libraryLoadPromise.then(() => { if (shouldRenderVoiceLibrary() && _voices.length) renderVoiceList(); @@ -666,7 +667,7 @@ async function loadVoiceLibrary() { if (list && !silent && renderVisibleList) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8); if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); } try { - const r = await fetch('/api/voices'); + const r = await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : '')); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const fresh = await r.json(); _vlCacheWrite(fresh); @@ -693,7 +694,7 @@ async function loadVoiceLibrary() { return _libraryLoadPromise; } -$('refresh-voices-btn').addEventListener('click', () => { _vlCacheClear(); loadVoiceLibrary(); }); +$('refresh-voices-btn').addEventListener('click', () => { _vlCacheClear(); loadVoiceLibrary({ refresh: true }); }); $('sync-voice-folders-btn').addEventListener('click', async () => { $('sync-voice-folders-btn').disabled = true; status('Syncing active_voices and hidden_voices…');