Back user state with SQLite voice index
This commit is contained in:
parent
bc6c4842b5
commit
dd697590ef
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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")
|
||||
|
||||
192
core/database.py
192
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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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
198
core/voice_index.py
Normal 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
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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…');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user