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) {
-
+