Back user state with SQLite voice index

This commit is contained in:
mARTin-B78 2026-06-30 15:06:43 +02:00
parent bc6c4842b5
commit dd697590ef
14 changed files with 547 additions and 63 deletions

View File

@ -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 ## [1.12.54] — 2026-06-30
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.54 1.12.55

View File

@ -18,6 +18,7 @@ from core.constants import (
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT, _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT,
_TTS_STREAM_DEFAULT, _TTS_STREAM_DEFAULT,
) )
from core.database import state_get, state_put, state_updated
# ── Settings key whitelist ──────────────────────────────────────────────────── # ── Settings key whitelist ────────────────────────────────────────────────────
@ -225,12 +226,18 @@ def _normalize_settings(s: dict) -> dict:
_settings_cache: dict | None = None _settings_cache: dict | None = None
_settings_cache_mtime: float = -1.0 _settings_cache_mtime: float = -1.0
_settings_cache_db_updated: str = ""
def _load_settings() -> dict: 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 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) return dict(_settings_cache)
defaults = { defaults = {
"whisper_url": _WHISPER_DEFAULT, "whisper_url": _WHISPER_DEFAULT,
@ -285,21 +292,36 @@ def _load_settings() -> dict:
"seed_finder_dir": "", "seed_finder_dir": "",
"pt_dir": "", "pt_dir": "",
} }
file_saved = None
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
try: try:
saved = json.loads(CONFIG_FILE.read_text()) file_saved = json.loads(CONFIG_FILE.read_text())
defaults.update({k: v for k, v in saved.items() if k in _SETTINGS_KEYS}) if isinstance(file_saved, dict):
defaults.update({k: v for k, v in file_saved.items() if k in _SETTINGS_KEYS})
except Exception: except Exception:
pass 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) 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 = result
_settings_cache_mtime = mtime _settings_cache_mtime = mtime
_settings_cache_db_updated = db_updated
return dict(result) return dict(result)
def _save_settings(s: dict) -> None: 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_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(s, indent=2)) clean = _normalize_settings({k: v for k, v in s.items() if k in _SETTINGS_KEYS})
_settings_cache = s 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_mtime = CONFIG_FILE.stat().st_mtime
_settings_cache_db_updated = state_updated("settings")

View File

@ -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. Single-file database at CONFIG_DIR/tts_creator.db.
Designed for easy migration to PostgreSQL / Supabase later: Designed for easy migration to PostgreSQL / Supabase later:
@ -10,6 +10,7 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -48,6 +49,29 @@ CREATE TABLE IF NOT EXISTS rehearsals (
updated TEXT updated TEXT
); );
CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); 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) 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: def _row_to_char(row: sqlite3.Row) -> dict:
d = dict(row) d = dict(row)
d["sheet"] = json.loads(d.get("sheet") or "{}") 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.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,))
conn.commit() conn.commit()
return True 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

View File

@ -5,6 +5,7 @@ import json
import re import re
from core.constants import CONFIG_DIR, DESIGN_PRESETS_FILE from core.constants import CONFIG_DIR, DESIGN_PRESETS_FILE
from core.database import state_get, state_put
_DEFAULT_DESIGN_PRESETS = { _DEFAULT_DESIGN_PRESETS = {
"EN_M_Young_Energetic": { "EN_M_Young_Energetic": {
@ -61,6 +62,19 @@ def _load_design_presets() -> dict:
} }
except Exception: except Exception:
pass 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 return presets
@ -77,6 +91,7 @@ def _save_design_presets(presets: dict) -> None:
"gender": str(preset.get("gender", "N")), "gender": str(preset.get("gender", "N")),
} }
CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
state_put("voice_design_presets", cleaned)
DESIGN_PRESETS_FILE.write_text(json.dumps(cleaned, indent=2)) DESIGN_PRESETS_FILE.write_text(json.dumps(cleaned, indent=2))

View File

@ -7,6 +7,7 @@ import re
from fastapi import Request from fastapi import Request
from core.constants import TTS_ROUTES_FILE, _routing_log_add from core.constants import TTS_ROUTES_FILE, _routing_log_add
from core.database import state_get, state_put, state_updated
# ── Route token / backend validation ───────────────────────────────────────── # ── 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: list[dict] | None = None
_routes_cache_mtime: float = -1.0 _routes_cache_mtime: float = -1.0
_routes_cache_db_updated: str = ""
def _load_tts_routes() -> list[dict]: 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 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) return list(_routes_cache)
if not TTS_ROUTES_FILE.exists(): result: list[dict] = []
_routes_cache = []
_routes_cache_mtime = 0.0
return []
try: try:
if TTS_ROUTES_FILE.exists():
raw = json.loads(TTS_ROUTES_FILE.read_text()) raw = json.loads(TTS_ROUTES_FILE.read_text())
routes = raw.get("routes", raw) if isinstance(raw, dict) else raw routes = raw.get("routes", raw) if isinstance(raw, dict) else raw
if not isinstance(routes, list): if isinstance(routes, list):
result: list[dict] = []
else:
result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)] result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)]
except Exception: except Exception:
result = [] 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 = result
_routes_cache_mtime = mtime _routes_cache_mtime = mtime
_routes_cache_db_updated = db_updated
return list(result) return list(result)
def _save_tts_routes(routes: list[dict]) -> None: 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) TTS_ROUTES_FILE.parent.mkdir(parents=True, exist_ok=True)
clean = [_normalize_route(r, i) for i, r in enumerate(routes)] 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)) TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2))
_routes_cache = clean _routes_cache = clean
_routes_cache_mtime = TTS_ROUTES_FILE.stat().st_mtime _routes_cache_mtime = TTS_ROUTES_FILE.stat().st_mtime
_routes_cache_db_updated = state_updated("tts_routes")
# ── App name helpers ────────────────────────────────────────────────────────── # ── App name helpers ──────────────────────────────────────────────────────────

198
core/voice_index.py Normal file
View File

@ -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

View File

@ -28,6 +28,7 @@ from core.voice import (
_find_voice_audio, _load_meta, _active_voices_dir, _find_voice_audio, _load_meta, _active_voices_dir,
_voice_audio_files, _is_internal_voice_file, _voice_audio_files, _is_internal_voice_file,
) )
from core.voice_index import rebuild_voice_index
from core.tts_helpers import _preview_request_audio from core.tts_helpers import _preview_request_audio
from routes.stt import _transcribe_audio, _clean_stt_backend from routes.stt import _transcribe_audio, _clean_stt_backend
@ -985,6 +986,7 @@ async def voices_import(file: UploadFile = File(...)):
except Exception as e: except Exception as e:
raise HTTPException(500, f"Import failed: {e}") raise HTTPException(500, f"Import failed: {e}")
await asyncio.to_thread(rebuild_voice_index, settings)
return {"ok": True, "imported": imported} return {"ok": True, "imported": imported}

View File

@ -28,6 +28,14 @@ from core.voice import (
_move_voice_package, _read_reference_text, _voice_health, _move_voice_package, _read_reference_text, _voice_health,
_is_internal_voice_file, _benchmark_voice, _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() router = APIRouter()
@ -207,6 +215,7 @@ async def save_voice(request: Request):
meta["enabled"] = True meta["enabled"] = True
meta["loudness"] = loudness meta["loudness"] = loudness
_save_meta(wav_dest, meta) _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} 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)) shutil.copy2(str(old_pic), str(new_pic))
keep.add(new_pic) keep.add(new_pic)
_remove_voice_package(current, keep) _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), return {"voice_id": voice_id, "wav": str(wav_dest), "txt": str(txt_dest),
"duration": _duration(wav_dest), "file_type": "wav", "loudness": loudness, "duration": _duration(wav_dest), "file_type": "wav", "loudness": loudness,
"backup": str(backup) if backup else None} "backup": str(backup) if backup else None}
@router.get("/api/voices") @router.get("/api/voices")
async def list_voices(): async def list_voices(refresh: bool = False):
settings = _load_settings() settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) return await asyncio.to_thread(indexed_voices, settings, refresh=refresh)
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))
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): @router.get("/api/voices/index")
if p.stem in seen: async def get_voice_index_info():
continue return voice_index_info()
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.post("/api/voice/meta") @router.post("/api/voice/meta")
@ -368,6 +349,7 @@ async def update_voice_meta(request: Request):
_save_meta(wav, meta) _save_meta(wav, meta)
if "transcript" in data: if "transcript" in data:
wav.with_suffix(".reference.txt").write_text(data.get("transcript", "").strip(), encoding="utf-8") 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), return {"ok": True, "path": str(wav), "enabled": meta.get("enabled", True),
"transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip() "transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip()
if wav.with_suffix(".reference.txt").exists() else ""} if wav.with_suffix(".reference.txt").exists() else ""}
@ -404,6 +386,7 @@ async def sync_voice_folders():
except HTTPException as e: except HTTPException as e:
conflicts.append({"voice_id": p.stem, "detail": e.detail}) conflicts.append({"voice_id": p.stem, "detail": e.detail})
await asyncio.to_thread(rebuild_voice_index, settings)
return {"moved": moved, "conflicts": conflicts, return {"moved": moved, "conflicts": conflicts,
"active_dir": str(active_dir), "hidden_dir": str(hidden_dir)} "active_dir": str(active_dir), "hidden_dir": str(hidden_dir)}
@ -429,6 +412,7 @@ async def calculate_voice_db():
meta = _load_meta(audio) meta = _load_meta(audio)
meta["loudness"] = loudness meta["loudness"] = loudness
_save_meta(audio, meta) _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}) results.append({"voice_id": audio.stem, "path": str(audio), "loudness": loudness})
except Exception as e: except Exception as e:
errors.append({"voice_id": audio.stem, "detail": str(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 = _load_meta(audio)
meta["benchmark"] = benchmark meta["benchmark"] = benchmark
_save_meta(audio, meta) _save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as e: except Exception as e:
errors.append({"voice_id": audio.stem, "detail": f"Could not save benchmark: {e}"}) errors.append({"voice_id": audio.stem, "detail": f"Could not save benchmark: {e}"})
item = {"voice_id": audio.stem, "path": str(audio), "benchmark": benchmark} item = {"voice_id": audio.stem, "path": str(audio), "benchmark": benchmark}
@ -518,6 +503,7 @@ async def normalize_active_voices():
meta["enabled"] = True meta["enabled"] = True
meta["loudness"] = loudness meta["loudness"] = loudness
_save_meta(wav, meta) _save_meta(wav, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav)
normalized.append({"voice_id": wav.stem, **loudness}) normalized.append({"voice_id": wav.stem, **loudness})
except Exception as e: except Exception as e:
errors.append({"voice_id": wav.stem, "detail": str(e)}) errors.append({"voice_id": wav.stem, "detail": str(e)})
@ -561,6 +547,7 @@ async def normalize_voice(request: Request):
meta["loudness"] = loudness meta["loudness"] = loudness
meta["needs_tts_restart"] = True meta["needs_tts_restart"] = True
_save_meta(audio, meta) _save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return {"ok": True, "voice_id": audio.stem, "path": str(audio), return {"ok": True, "voice_id": audio.stem, "path": str(audio),
"duration": _duration(audio), "file_type": "wav", "loudness": loudness} "duration": _duration(audio), "file_type": "wav", "loudness": loudness}
except Exception as e: except Exception as e:
@ -593,6 +580,7 @@ async def undo_voice_edit(request: Request):
meta.pop("loudness", None) meta.pop("loudness", None)
meta["needs_tts_restart"] = True meta["needs_tts_restart"] = True
_save_meta(restored, meta) _save_meta(restored, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, restored)
return { return {
"ok": True, "ok": True,
"voice_id": restored.stem, "voice_id": restored.stem,
@ -658,6 +646,8 @@ async def rename_voice(request: Request):
new_meta["original_backup"] = str(new_backup) new_meta["original_backup"] = str(new_backup)
new_meta["needs_tts_restart"] = True new_meta["needs_tts_restart"] = True
_save_meta(new_audio, new_meta) _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(".")} 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(): if f.exists():
f.unlink() f.unlink()
deleted_ids.append(p.stem) deleted_ids.append(p.stem)
remove_voice_from_index(p.stem)
return {"deleted": deleted_ids, "count": len(deleted_ids)} return {"deleted": deleted_ids, "count": len(deleted_ids)}
@ -698,6 +689,7 @@ async def delete_voice(voice_id: str):
if f.exists(): if f.exists():
f.unlink() f.unlink()
deleted.append(f.name) deleted.append(f.name)
remove_voice_from_index(voice_id)
return {"deleted": deleted} return {"deleted": deleted}
@ -724,6 +716,7 @@ async def upload_picture(voice_id: str = Form(...), file: UploadFile = File(...)
with dest.open("wb") as f: with dest.open("wb") as f:
_copy_limited(file.file, f, _MAX_PICTURE_BYTES) _copy_limited(file.file, f, _MAX_PICTURE_BYTES)
await asyncio.to_thread(upsert_voice_in_index, settings, wav)
return {"ok": True, "path": str(dest)} return {"ok": True, "path": str(dest)}
@ -777,6 +770,7 @@ async def upload_picture_url(request: Request):
raise raise
except Exception as e: except Exception as e:
raise HTTPException(400, f"Image import failed: {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)} return {"ok": True, "voice_id": voice_id, "path": str(dest)}

View File

@ -24,6 +24,7 @@ from core.voice import (
_AUDIO_EXTS, _active_voices_dir, _AUDIO_EXTS, _active_voices_dir,
_remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta, _remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta,
) )
from core.voice_index import upsert_voice_in_index
router = APIRouter() router = APIRouter()
@ -550,6 +551,7 @@ async def quick_import_voice(request: Request):
meta["enabled"] = True meta["enabled"] = True
meta["loudness"] = loudness meta["loudness"] = loudness
_save_meta(wav_dest, meta) _save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
return {"voice_id": final_id, "loudness": loudness} return {"voice_id": final_id, "loudness": loudness}

View File

@ -32,6 +32,7 @@ from core.voice import (
_AUDIO_EXTS, _AUDIO_EXTS,
_voice_health, _voice_health,
) )
from core.voice_index import indexed_voices, refresh_voice_index_background
from core.validation import _validate_http_url from core.validation import _validate_http_url
from core.registry import _registry_put, TEMP_DIR from core.registry import _registry_put, TEMP_DIR
from core.tts_helpers import ( 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]: 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) active_dir = _active_voices_dir(settings)
voices = [] voices = []
seen = set() 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: if meta.pop("needs_tts_restart", None) is not None:
_save_meta(audio, meta) _save_meta(audio, meta)
cleared += 1 cleared += 1
if cleared:
refresh_voice_index_background(settings, force=True)
return cleared return cleared

View File

@ -13,6 +13,8 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from core.constants import STATIC_DIR, _BufferHandler 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 from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@ -29,6 +31,10 @@ async def lifespan(app: FastAPI):
l.addHandler(_buf_handler) l.addHandler(_buf_handler)
if "_file_handler" in globals() and _file_handler not in l.handlers: if "_file_handler" in globals() and _file_handler not in l.handlers:
l.addHandler(_file_handler) 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 yield
app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan) app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan)

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.54"> <meta name="app-version" content="1.12.55">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.54"> <link rel="stylesheet" href="/static/style.css?v=1.12.55">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.54"></script> <script src="/static/loader.js?v=1.12.55"></script>
</body> </body>
</html> </html>

View File

@ -639,7 +639,8 @@ function shouldRenderVoiceLibrary() {
return !section || section.classList.contains('is-active'); return !section || section.classList.contains('is-active');
} }
async function loadVoiceLibrary() { async function loadVoiceLibrary(options = {}) {
const forceRefresh = !!(options && options.refresh);
if (_libraryLoadPromise) { if (_libraryLoadPromise) {
return _libraryLoadPromise.then(() => { return _libraryLoadPromise.then(() => {
if (shouldRenderVoiceLibrary() && _voices.length) renderVoiceList(); 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 (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…'); } if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
try { 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); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const fresh = await r.json(); const fresh = await r.json();
_vlCacheWrite(fresh); _vlCacheWrite(fresh);
@ -693,7 +694,7 @@ async function loadVoiceLibrary() {
return _libraryLoadPromise; 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').addEventListener('click', async () => {
$('sync-voice-folders-btn').disabled = true; $('sync-voice-folders-btn').disabled = true;
status('Syncing active_voices and hidden_voices…'); status('Syncing active_voices and hidden_voices…');