tts-voice-creator-clone-and.../core/voice.py
mARTin-B78 ce0f4c43bc fix: restore My Voices after infinite recursion + settings crash (v1.12.18-19)
Root cause 1 (v1.12.18): `window.loadVoiceLibrary = () => loadVoiceLibrary()`
overwrites the global binding the arrow function references, causing immediate
RangeError: Maximum call stack size exceeded on every call. Changed to direct
assignment `window.loadVoiceLibrary = loadVoiceLibrary`.

Root cause 2 (v1.12.18): `loadSettings()` called `renderSettingsAbout()` which
lives in conversation.js (batch E), loaded after init.js. Guard added with
typeof check; nav.js already calls it safely when the About section opens.

Also (v1.12.18): s-library.html duplicated cl-book-filter / cl-search / cl-grid
from s-characters.html, breaking getElementById. Characters panel in Library
now redirects to s-characters instead of duplicating its DOM nodes.

Also (v1.12.19): engines.js triggers a second loadVoiceLibrary() after nav.js
already rendered voices, blanking the list briefly. Second call now silently
re-fetches without clearing the list when voices are already present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 01:57:49 +02:00

411 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Voice meta helpers, file utilities, and benchmark helpers."""
from __future__ import annotations
import json
import re
import shutil
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
import requests
from fastapi import HTTPException
from core.constants import (
_VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT,
_VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS,
_BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS,
)
from core.audio import _duration, _export_normalized_wav
# ── File type constants ───────────────────────────────────────────────────────
_PICTURE_EXTS = [".jpg", ".jpeg", ".png", ".webp"]
_SOUND_ASSET_DIRS = {"sound", "sounds", "sfx", "effects", "sound_effects", "route_sounds"}
_SOUND_ASSET_PREFIXES = ("computerbeep", "beep", "ding", "chime", "notification")
_AUDIO_EXTS = [".wav", ".mp3", ".m4a", ".flac", ".ogg", ".opus"]
_UPLOAD_EXTS = _AUDIO_EXTS + [".mp4", ".mkv", ".webm", ".mov", ".avi"]
_AUDIO_MIME = {
".wav": "audio/wav",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".flac": "audio/flac",
".ogg": "audio/ogg",
".opus": "audio/ogg",
}
# Default flag (ISO country code) for each language code
_LANG_FLAG_DEFAULT = {
"EN": "GB", "DE": "DE", "ZH": "CN", "FR": "FR", "ES": "ES",
"JA": "JP", "KO": "KR", "IT": "IT", "PT": "BR", "RU": "RU",
"AR": "SA", "PL": "PL", "NL": "NL", "SV": "SE", "TR": "TR", "HI": "IN",
}
# ── Voice audio discovery helpers ─────────────────────────────────────────────
def _is_internal_voice_file(path: Path) -> bool:
stem = path.stem.lower()
name = path.name.lower()
return (
stem.endswith(".original")
or ".normalized.tmp" in stem
or name.startswith(".")
or name.endswith(".bak")
)
def _is_sound_asset_file(path: Path) -> bool:
stem = path.stem.lower()
parent_names = {part.lower() for part in path.parts}
return (
any(part in _SOUND_ASSET_DIRS for part in parent_names)
or stem.startswith(_SOUND_ASSET_PREFIXES)
)
_AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS)
def _voice_audio_files(root: Path):
# Single rglob pass instead of one per extension (6× faster directory scan)
for p in root.rglob("*"):
if p.suffix.lower() in _AUDIO_EXTS_SET and not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
yield p
def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
for ext in _AUDIO_EXTS:
for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")):
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
return p
return None
# ── Voice meta helpers ────────────────────────────────────────────────────────
def _meta_path(wav: Path) -> Path:
return wav.with_suffix(".meta.json")
def _load_meta(wav: Path) -> dict:
mp = _meta_path(wav)
if mp.exists():
try:
return json.loads(mp.read_text())
except Exception:
pass
# Auto-detect flag and gender from voice_id
parts = wav.stem.split("_", 2)
lang = parts[0].upper() if parts else ""
gender = parts[1].upper() if len(parts) >= 2 and parts[1].upper() in ("F", "M", "N") else ""
return {
"note": "",
"rating": 0,
"flag": _LANG_FLAG_DEFAULT.get(lang, ""),
"gender": gender,
"enabled": True,
}
def _save_meta(wav: Path, meta: dict) -> None:
_meta_path(wav).write_text(json.dumps(meta, indent=2))
def _picture_path(wav: Path) -> Path | None:
for ext in _PICTURE_EXTS:
p = wav.with_suffix(ext)
if p.exists():
return p
return None
def _picture_mime(path: Path) -> str:
return {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp"}.get(path.suffix.lower(), "image/jpeg")
def _hidden_voices_dir(settings: dict) -> Path:
return Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) / "hidden_voices"
def _active_voices_dir(settings: dict) -> Path:
return Path(settings.get("output_dir", _OUTPUT_DIR_DEFAULT))
def _backup_path(audio: Path) -> Path:
return audio.with_name(f".{audio.stem}.original{audio.suffix}.bak")
def _legacy_backup_path(audio: Path) -> Path:
return audio.with_name(f"{audio.stem}.original{audio.suffix}")
def _backup_candidates(audio: Path, meta: dict | None = None) -> list[Path]:
candidates = [_backup_path(audio), _legacy_backup_path(audio)]
if meta and meta.get("original_backup"):
candidates.insert(0, Path(str(meta["original_backup"])))
seen: set[Path] = set()
unique = []
for candidate in candidates:
try:
key = candidate.resolve() if candidate.exists() else candidate
except Exception:
key = candidate
if key not in seen:
seen.add(key)
unique.append(candidate)
return unique
def _backup_audio_suffix(backup: Path, voice_id: str) -> str | None:
name = backup.name
for ext in _AUDIO_EXTS:
if name == f".{voice_id}.original{ext}.bak" or name == f"{voice_id}.original{ext}":
return ext
return None
def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None) -> None:
keep_resolved = keep.resolve() if keep and keep.exists() else None
for ext in _AUDIO_EXTS:
p = parent / f"{voice_id}{ext}"
if p.exists() and not _is_internal_voice_file(p) and (keep_resolved is None or p.resolve() != keep_resolved):
p.unlink()
def _voice_package_paths(audio: Path) -> list[Path]:
parent = audio.parent
paths = []
for sfx in _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS:
p = parent / f"{audio.stem}{sfx}"
if p.exists():
paths.append(p)
paths.extend(
p for p in _backup_candidates(audio, _load_meta(audio))
if p.exists() and p.parent.resolve() == parent.resolve()
)
return paths
def _remove_voice_package(audio: Path, keep: set[Path] | None = None) -> None:
keep_resolved = {p.resolve() for p in (keep or set()) if p.exists()}
for p in _voice_package_paths(audio):
if p.exists() and p.resolve() not in keep_resolved:
p.unlink()
def _backup_original_voice(audio: Path) -> Path | None:
if not audio.exists() or audio.suffix.lower() not in _AUDIO_EXTS:
return None
backup = _backup_path(audio)
if not backup.exists():
legacy = _legacy_backup_path(audio)
if legacy.exists():
shutil.move(str(legacy), str(backup))
else:
shutil.copy2(str(audio), str(backup))
return backup
def _voice_audio_from_request(data: dict, scan_dir: Path) -> Path | None:
from core.validation import _safe_child_path
requested_path = data.get("path")
if requested_path:
p = _safe_child_path(scan_dir, Path(requested_path))
if not p.exists() or not p.is_file() or p.suffix.lower() not in _AUDIO_EXTS:
raise HTTPException(404, "Voice file not found")
return p
voice_id = data.get("voice_id", "")
if not voice_id:
raise HTTPException(400, "voice_id or path is required")
return _find_voice_audio(voice_id, scan_dir)
def _move_voice_package(audio: Path, target_dir: Path) -> Path:
if audio.parent.resolve() == target_dir.resolve():
return audio
target_dir.mkdir(parents=True, exist_ok=True)
paths = _voice_package_paths(audio)
for src in paths:
dest = target_dir / src.name
if dest.exists() and dest.resolve() != src.resolve():
raise HTTPException(409, f"Target file already exists: {dest}")
moved_audio = target_dir / audio.name
for src in paths:
dest = target_dir / src.name
if dest.resolve() != src.resolve():
shutil.move(str(src), str(dest))
return moved_audio
def _read_reference_text(audio: Path) -> tuple[bool, str]:
ref = audio.with_suffix(".reference.txt")
if not ref.exists():
return False, ""
return True, ref.read_text(encoding="utf-8").strip()
# ── Voice health and entry ────────────────────────────────────────────────────
def _voice_health(
p: Path,
*,
meta: dict | None = None,
duration: float | None = None,
transcript: str | None = None,
) -> dict:
if transcript is None:
_has_ref, transcript = _read_reference_text(p)
if duration is None:
try:
duration = float(_duration(p))
except Exception:
duration = 0.0
else:
duration = float(duration)
if meta is None:
meta = _load_meta(p)
words = re.findall(r"\b[\w'-]+\b", transcript, flags=re.UNICODE)
word_count = len(words)
words_per_sec = (word_count / duration) if duration > 0 else 0.0
warnings: list[str] = []
if not transcript:
warnings.append("Missing reference transcript")
if duration > 25:
warnings.append("Reference audio is longer than the recommended 10-20 seconds")
if duration > 0 and word_count and words_per_sec > 4.5:
warnings.append("Reference transcript is too long for the audio; re-transcribe or shorten it")
from core.audio import _loudness_info as _li
loudness = meta.get("loudness", {})
peak = loudness.get("peak_dbfs")
if peak is not None and float(peak) > -0.1:
warnings.append("Reference audio is clipping or too loud")
return {
"ok": not warnings,
"warnings": warnings,
"duration": round(duration, 2) if duration else None,
"word_count": word_count,
"words_per_sec": round(words_per_sec, 2) if words_per_sec else 0,
"loudness": loudness,
}
def _voice_entry(p: Path) -> dict:
meta = _load_meta(p)
parts = p.stem.split("_", 2)
lang = parts[0].upper() if parts else ""
try:
duration = round(_duration(p), 2)
except Exception:
duration = None
has_ref, transcript = _read_reference_text(p)
loudness = meta.get("loudness", {})
benchmark = meta.get("benchmark", {})
if not meta.get("flag") and lang in _LANG_FLAG_DEFAULT:
meta["flag"] = _LANG_FLAG_DEFAULT[lang]
health = _voice_health(p, meta=meta, duration=duration, transcript=transcript)
return {
"id": p.stem,
"path": str(p),
"file_type": p.suffix.lower().lstrip("."),
"duration": duration,
"loudness": loudness,
"benchmark": benchmark,
"has_ref": has_ref,
"transcript": transcript,
"has_picture": _picture_path(p) is not None,
"health": health,
"lang": lang,
**meta,
}
# ── Benchmark helpers ─────────────────────────────────────────────────────────
def _benchmark_advice(audio: Path, elapsed_sec: float | None, audio_sec: float | None,
clipped: bool = False, error: str = "") -> tuple[bool, list[str]]:
advice: list[str] = []
try:
ref_duration = float(_duration(audio))
except Exception:
ref_duration = 0.0
name = audio.stem.lower()
if ref_duration >= 40 or "privat_" in name or "privat-" in name:
advice.append("Avoid for real-time assistants; long clone samples often benchmark slowly. Trim or remake as a 10-20 second voice.")
elif ref_duration > 25:
advice.append("Reference is longer than recommended. Trim to a clean 10-20 second sample.")
if clipped:
advice.append("Generated output hit the max-duration guard. Re-transcribe the reference exactly or remake this clone.")
if elapsed_sec is not None and elapsed_sec > 12:
advice.append("Slow synthesis. Prefer a shorter optimized voice for Open WebUI or Home Assistant.")
if audio_sec and elapsed_sec:
rtf = elapsed_sec / max(audio_sec, 0.01)
if rtf > 2.0:
advice.append("High real-time factor. Use a shorter reference, normalize volume, and remove silence/noise.")
if error:
advice.append("Benchmark failed. Check that Qwen3-TTS has rescanned this voice and that the reference files are valid.")
realtime_ok = not error and not clipped and (elapsed_sec or 999) <= 8 and ref_duration <= 25
return realtime_ok, advice
def _benchmark_summary(runs: list[dict]) -> dict:
ok_runs = [r for r in runs if r.get("ok")]
if not ok_runs:
return {}
def avg(key: str) -> float | None:
vals = [float(r[key]) for r in ok_runs if r.get(key) is not None]
return round(sum(vals) / len(vals), 3) if vals else None
return {
"avg_ttfa_ms": avg("ttfa_ms"),
"avg_total_sec": avg("total_sec"),
"avg_audio_sec": avg("audio_sec"),
"avg_rtf": avg("rtf"),
"avg_speed": avg("speed"),
}
def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str]]) -> dict:
from core.tts_helpers import _tts_benchmark_request
runs: list[dict] = []
for label, text in sentences:
try:
runs.append(_tts_benchmark_request(text, audio.stem, settings, label))
except Exception as e:
runs.append({"ok": False, "label": label, "text": text, "error": str(e)})
ok_runs = [r for r in runs if r.get("ok")]
summary = _benchmark_summary(runs)
elapsed = summary.get("avg_total_sec") if summary else None
audio_sec = summary.get("avg_audio_sec") if summary else None
errors = [r.get("error", "Benchmark failed") for r in runs if not r.get("ok")]
realtime_ok, advice = _benchmark_advice(audio, elapsed, audio_sec, error="; ".join(errors))
if summary.get("avg_rtf") is not None and summary["avg_rtf"] > 2:
advice.append("RTF is above 2.0. This voice is likely too slow for real-time assistants.")
return {
"ok": bool(ok_runs) and not errors,
"realtime_ok": realtime_ok and not errors,
"elapsed_sec": round(elapsed, 2) if elapsed is not None else None,
"audio_sec": round(audio_sec, 2) if audio_sec is not None else None,
"rtf": round(summary.get("avg_rtf"), 2) if summary.get("avg_rtf") is not None else None,
"speed": round(summary.get("avg_speed"), 2) if summary.get("avg_speed") is not None else None,
"ttfa_ms": round(summary.get("avg_ttfa_ms"), 0) if summary.get("avg_ttfa_ms") is not None else None,
"bytes": sum(int(r.get("bytes") or 0) for r in ok_runs),
"clipped": False,
"benchmarked_at": datetime.now(timezone.utc).isoformat(),
"text": sentences[0][1] if len(sentences) == 1 else "short / medium / long",
"runs": runs,
"summary": summary,
"advice": advice,
**({"error": "; ".join(errors)} if errors else {}),
}