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
|
## [1.12.54] — 2026-06-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
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.
|
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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
raw = json.loads(TTS_ROUTES_FILE.read_text())
|
if TTS_ROUTES_FILE.exists():
|
||||||
routes = raw.get("routes", raw) if isinstance(raw, dict) else raw
|
raw = json.loads(TTS_ROUTES_FILE.read_text())
|
||||||
if not isinstance(routes, list):
|
routes = raw.get("routes", raw) if isinstance(raw, dict) else raw
|
||||||
result: list[dict] = []
|
if isinstance(routes, list):
|
||||||
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
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,
|
_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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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…');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user