Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""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 _picture_backup_path(picture: Path) -> Path:
|
||
return picture.with_name(f".{picture.stem}.original{picture.suffix}.bak")
|
||
|
||
|
||
def _backup_existing_picture(wav: Path) -> None:
|
||
# A voice's picture had no equivalent to _backup_original_voice's
|
||
# copy-before-overwrite protection — confirmed live as real, unrecoverable
|
||
# data loss: a shared library voice cloned from a real person's own
|
||
# reference photo got silently overwritten (the previous file just
|
||
# unlink()'d, nothing copied first) the moment an unrelated feature
|
||
# elsewhere pushed a different picture onto it. Best-effort and silent by
|
||
# design, same as the audio backup — this must never block/break the
|
||
# actual upload it's protecting.
|
||
existing = _picture_path(wav)
|
||
if not existing:
|
||
return
|
||
backup = _picture_backup_path(existing)
|
||
if not backup.exists():
|
||
try:
|
||
shutil.copy2(str(existing), str(backup))
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
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
|
||
has_ref, _transcript = _read_reference_text(audio)
|
||
meta = _load_meta(audio)
|
||
is_designed = meta.get("origin") == "designed" or not has_ref
|
||
runs: list[dict] = []
|
||
for label, text in sentences:
|
||
try:
|
||
runs.append(_tts_benchmark_request(text, audio.stem, settings, label, is_designed=is_designed))
|
||
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 {}),
|
||
}
|