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
### 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,
_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")

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

View File

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

View File

@ -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:
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 not isinstance(routes, list):
result: list[dict] = []
else:
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 ──────────────────────────────────────────────────────────

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,
_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}

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<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="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<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) ── -->
@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- 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>
</html>

View File

@ -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…');