mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
Refactor voice profiles to support Supertonic integration
- Introduced normalization functions for Supertonic voice profiles. - Updated profile serialization to include provider information. - Enhanced API endpoints to handle Supertonic profiles, including import/export functionality. - Modified settings to allow selection of default speakers and removed deprecated options. - Updated front-end to manage speaker profiles, including UI changes for Supertonic settings. - Improved handling of voice mixing and preview functionalities for both Kokoro and Supertonic providers.
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Dict, Iterable, List, Tuple
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||||
from abogen.utils import get_user_config_path
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,63 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
|||||||
return load_profiles()
|
return load_profiles()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_supertonic_voice(value: Any) -> str:
|
||||||
|
raw = str(value or "").strip().upper()
|
||||||
|
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_supertonic_steps(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
steps = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5
|
||||||
|
return max(2, min(15, steps))
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_supertonic_speed(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
speed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1.0
|
||||||
|
return max(0.7, min(2.0, speed))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||||
|
"""Normalize a stored profile entry.
|
||||||
|
|
||||||
|
Backwards compatible:
|
||||||
|
- Legacy Kokoro-only entries: {language, voices}
|
||||||
|
- New entries: include provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||||
|
if provider not in {"kokoro", "supertonic"}:
|
||||||
|
provider = "kokoro"
|
||||||
|
|
||||||
|
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||||
|
|
||||||
|
if provider == "supertonic":
|
||||||
|
return {
|
||||||
|
"provider": "supertonic",
|
||||||
|
"language": language,
|
||||||
|
"voice": _normalize_supertonic_voice(entry.get("voice") or entry.get("voice_name") or entry.get("name")),
|
||||||
|
"total_steps": _coerce_supertonic_steps(entry.get("total_steps") or entry.get("supertonic_total_steps") or entry.get("quality")),
|
||||||
|
"speed": _coerce_supertonic_speed(entry.get("speed") or entry.get("supertonic_speed")),
|
||||||
|
}
|
||||||
|
|
||||||
|
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||||
|
if not voices:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": language,
|
||||||
|
"voices": voices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||||
normalized: List[Tuple[str, float]] = []
|
normalized: List[Tuple[str, float]] = []
|
||||||
for item in entries or []:
|
for item in entries or []:
|
||||||
@@ -112,7 +170,7 @@ def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
|||||||
language = "a"
|
language = "a"
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
profiles[name] = {"language": language, "voices": normalized}
|
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,16 +196,13 @@ def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[
|
|||||||
current = load_profiles()
|
current = load_profiles()
|
||||||
updated: List[str] = []
|
updated: List[str] = []
|
||||||
for name, entry in data.items():
|
for name, entry in data.items():
|
||||||
if not isinstance(entry, dict):
|
normalized = normalize_profile_entry(entry)
|
||||||
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
|
||||||
if not voices:
|
|
||||||
continue
|
|
||||||
language = entry.get("language", "a")
|
|
||||||
if name in current and not replace_existing:
|
if name in current and not replace_existing:
|
||||||
# skip duplicates unless explicit replacement is requested
|
# skip duplicates unless explicit replacement is requested
|
||||||
continue
|
continue
|
||||||
current[name] = {"language": language, "voices": voices}
|
current[name] = normalized
|
||||||
updated.append(name)
|
updated.append(name)
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
|
|||||||
+168
-6
@@ -16,9 +16,15 @@ from abogen.voice_profiles import (
|
|||||||
load_profiles,
|
load_profiles,
|
||||||
save_profiles,
|
save_profiles,
|
||||||
delete_profile,
|
delete_profile,
|
||||||
|
duplicate_profile,
|
||||||
serialize_profiles,
|
serialize_profiles,
|
||||||
|
import_profiles_data,
|
||||||
|
export_profiles_payload,
|
||||||
|
normalize_profile_entry,
|
||||||
)
|
)
|
||||||
|
from abogen.web.routes.utils.common import split_profile_spec
|
||||||
from abogen.web.routes.utils.preview import synthesize_preview, generate_preview_audio
|
from abogen.web.routes.utils.preview import synthesize_preview, generate_preview_audio
|
||||||
|
from abogen.web.routes.utils.voice import formula_from_profile
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
build_llm_configuration,
|
build_llm_configuration,
|
||||||
build_apostrophe_config,
|
build_apostrophe_config,
|
||||||
@@ -48,21 +54,158 @@ def api_get_voice_profiles() -> ResponseReturnValue:
|
|||||||
@api_bp.post("/voice-profiles")
|
@api_bp.post("/voice-profiles")
|
||||||
def api_save_voice_profile() -> ResponseReturnValue:
|
def api_save_voice_profile() -> ResponseReturnValue:
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
name = payload.get("name")
|
name = str(payload.get("name") or "").strip()
|
||||||
|
original_name = str(payload.get("originalName") or "").strip() or None
|
||||||
|
|
||||||
profile = payload.get("profile")
|
profile = payload.get("profile")
|
||||||
|
if profile is None:
|
||||||
|
# Speaker Studio payload format
|
||||||
|
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
||||||
|
if provider not in {"kokoro", "supertonic"}:
|
||||||
|
provider = "kokoro"
|
||||||
|
if provider == "supertonic":
|
||||||
|
profile = {
|
||||||
|
"provider": "supertonic",
|
||||||
|
"language": str(payload.get("language") or "a").strip().lower() or "a",
|
||||||
|
"voice": payload.get("voice"),
|
||||||
|
"total_steps": payload.get("total_steps") or payload.get("supertonic_total_steps"),
|
||||||
|
"speed": payload.get("speed") or payload.get("supertonic_speed"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
profile = {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": str(payload.get("language") or "a").strip().lower() or "a",
|
||||||
|
"voices": payload.get("voices") or [],
|
||||||
|
}
|
||||||
|
|
||||||
if not name or not profile:
|
if not name or not profile:
|
||||||
return jsonify({"error": "Name and profile are required"}), 400
|
return jsonify({"error": "Name and profile are required"}), 400
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
profiles[name] = profile
|
|
||||||
|
normalized = normalize_profile_entry(profile)
|
||||||
|
if not normalized:
|
||||||
|
return jsonify({"error": "Invalid profile payload"}), 400
|
||||||
|
|
||||||
|
if original_name and original_name in profiles and original_name != name:
|
||||||
|
del profiles[original_name]
|
||||||
|
|
||||||
|
profiles[name] = normalized
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
return jsonify({"success": True})
|
|
||||||
|
return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()})
|
||||||
|
|
||||||
@api_bp.delete("/voice-profiles/<path:name>")
|
@api_bp.delete("/voice-profiles/<path:name>")
|
||||||
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
||||||
delete_profile(name)
|
delete_profile(name)
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True, "profiles": serialize_profiles()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/voice-profiles/<path:name>/duplicate")
|
||||||
|
def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
new_name = str(payload.get("name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
return jsonify({"error": "Name is required"}), 400
|
||||||
|
duplicate_profile(name, new_name)
|
||||||
|
return jsonify({"success": True, "profile": new_name, "profiles": serialize_profiles()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/voice-profiles/import")
|
||||||
|
def api_import_voice_profiles() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
data = payload.get("data")
|
||||||
|
replace_existing = bool(payload.get("replace_existing"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({"error": "Invalid profile payload"}), 400
|
||||||
|
try:
|
||||||
|
imported = import_profiles_data(data, replace_existing=replace_existing)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify({"success": True, "imported": imported, "profiles": serialize_profiles()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.get("/voice-profiles/export")
|
||||||
|
def api_export_voice_profiles() -> ResponseReturnValue:
|
||||||
|
names_param = request.args.get("names")
|
||||||
|
names = None
|
||||||
|
if names_param:
|
||||||
|
names = [item.strip() for item in names_param.split(",") if item.strip()]
|
||||||
|
payload = export_profiles_payload(names)
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||||
|
filename = "voice_profiles.json" if not names else "voice_profiles_export.json"
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(data),
|
||||||
|
mimetype="application/json",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/voice-profiles/preview")
|
||||||
|
def api_voice_profiles_preview() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
text = str(payload.get("text") or "").strip() or "Hello world"
|
||||||
|
language = str(payload.get("language") or "a").strip().lower() or "a"
|
||||||
|
speed = coerce_float(payload.get("speed"), 1.0)
|
||||||
|
max_seconds = coerce_float(payload.get("max_seconds"), 8.0)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
use_gpu = settings.get("use_gpu", False)
|
||||||
|
|
||||||
|
# Accept a direct formula string or a full profile entry.
|
||||||
|
formula = str(payload.get("formula") or "").strip()
|
||||||
|
profile_name = str(payload.get("profile") or "").strip()
|
||||||
|
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
||||||
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
|
||||||
|
|
||||||
|
voice_spec = ""
|
||||||
|
resolved_provider = provider or "kokoro"
|
||||||
|
|
||||||
|
profiles = load_profiles()
|
||||||
|
if profile_name:
|
||||||
|
entry = profiles.get(profile_name)
|
||||||
|
normalized_entry = normalize_profile_entry(entry)
|
||||||
|
if not normalized_entry:
|
||||||
|
return jsonify({"error": "Unknown profile"}), 404
|
||||||
|
resolved_provider = str(normalized_entry.get("provider") or "kokoro")
|
||||||
|
if resolved_provider == "supertonic":
|
||||||
|
voice_spec = str(normalized_entry.get("voice") or "M1")
|
||||||
|
supertonic_total_steps = int(normalized_entry.get("total_steps") or supertonic_total_steps)
|
||||||
|
speed = float(normalized_entry.get("speed") or speed)
|
||||||
|
else:
|
||||||
|
voice_spec = formula_from_profile(normalized_entry) or ""
|
||||||
|
language = str(normalized_entry.get("language") or language)
|
||||||
|
elif formula:
|
||||||
|
voice_spec = formula
|
||||||
|
resolved_provider = "kokoro"
|
||||||
|
else:
|
||||||
|
# Raw voices payload -> Kokoro mix.
|
||||||
|
voices = payload.get("voices") or []
|
||||||
|
pseudo = {"provider": "kokoro", "language": language, "voices": voices}
|
||||||
|
normalized_entry = normalize_profile_entry(pseudo)
|
||||||
|
voice_spec = formula_from_profile(normalized_entry) or ""
|
||||||
|
resolved_provider = "kokoro"
|
||||||
|
|
||||||
|
if not voice_spec:
|
||||||
|
return jsonify({"error": "Unable to resolve preview voice"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
return synthesize_preview(
|
||||||
|
text=text,
|
||||||
|
voice_spec=voice_spec,
|
||||||
|
language=language,
|
||||||
|
speed=speed,
|
||||||
|
use_gpu=use_gpu,
|
||||||
|
tts_provider=resolved_provider,
|
||||||
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
|
max_seconds=max_seconds,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
@api_bp.post("/speaker-preview")
|
@api_bp.post("/speaker-preview")
|
||||||
def api_speaker_preview() -> ResponseReturnValue:
|
def api_speaker_preview() -> ResponseReturnValue:
|
||||||
@@ -70,12 +213,31 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
text = payload.get("text", "Hello world")
|
text = payload.get("text", "Hello world")
|
||||||
voice = payload.get("voice", "af_heart")
|
voice = payload.get("voice", "af_heart")
|
||||||
language = payload.get("language", "a")
|
language = payload.get("language", "a")
|
||||||
speed = coerce_float(payload.get("speed"), 1.0)
|
speed_value = payload.get("speed")
|
||||||
|
speed = coerce_float(speed_value, 1.0)
|
||||||
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
||||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
use_gpu = settings.get("use_gpu", False)
|
use_gpu = settings.get("use_gpu", False)
|
||||||
|
|
||||||
|
base_spec, speaker_name = split_profile_spec(voice)
|
||||||
|
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
|
||||||
|
|
||||||
|
if speaker_name:
|
||||||
|
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
||||||
|
if entry:
|
||||||
|
resolved_provider = str(entry.get("provider") or resolved_provider or "")
|
||||||
|
if resolved_provider == "supertonic":
|
||||||
|
voice = str(entry.get("voice") or "M1")
|
||||||
|
supertonic_total_steps = int(entry.get("total_steps") or supertonic_total_steps)
|
||||||
|
if speed_value is None:
|
||||||
|
speed = coerce_float(entry.get("speed"), speed)
|
||||||
|
elif resolved_provider == "kokoro":
|
||||||
|
voice = formula_from_profile(entry) or (base_spec or voice)
|
||||||
|
|
||||||
|
if not resolved_provider:
|
||||||
|
resolved_provider = "supertonic" if str(base_spec or "").strip() in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return synthesize_preview(
|
return synthesize_preview(
|
||||||
@@ -85,7 +247,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
speed=speed,
|
speed=speed,
|
||||||
use_gpu=use_gpu
|
use_gpu=use_gpu
|
||||||
,
|
,
|
||||||
tts_provider=tts_provider or str(settings.get("tts_provider") or "kokoro"),
|
tts_provider=resolved_provider,
|
||||||
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
|
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ def update_settings() -> ResponseReturnValue:
|
|||||||
|
|
||||||
# General settings
|
# General settings
|
||||||
current["language"] = (form.get("language") or "en").strip()
|
current["language"] = (form.get("language") or "en").strip()
|
||||||
current["tts_provider"] = (form.get("tts_provider") or current.get("tts_provider") or "kokoro").strip().lower()
|
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
||||||
current["default_voice"] = (form.get("default_voice") or "").strip()
|
current["default_voice"] = (form.get("default_voice") or "").strip()
|
||||||
current["supertonic_default_voice"] = (form.get("supertonic_default_voice") or current.get("supertonic_default_voice") or "M1").strip()
|
|
||||||
try:
|
try:
|
||||||
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from abogen.web.routes.utils.entity import sync_pronunciation_overrides
|
|||||||
from abogen.web.routes.utils.epub import job_download_flags
|
from abogen.web.routes.utils.epub import job_download_flags
|
||||||
from abogen.web.routes.utils.common import split_profile_spec
|
from abogen.web.routes.utils.common import split_profile_spec
|
||||||
from abogen.utils import calculate_text_length
|
from abogen.utils import calculate_text_length
|
||||||
from abogen.voice_profiles import serialize_profiles
|
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
||||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||||
from abogen.constants import VOICES_INTERNAL
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.speaker_configs import get_config
|
from abogen.speaker_configs import get_config
|
||||||
@@ -574,14 +574,40 @@ def apply_book_step_form(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
provider_value = (form.get("tts_provider") or getattr(pending, "tts_provider", None) or settings.get("tts_provider") or "kokoro").strip().lower()
|
provider_value = str(form.get("tts_provider") or getattr(pending, "tts_provider", None) or "").strip().lower()
|
||||||
if provider_value not in {"kokoro", "supertonic"}:
|
if provider_value not in {"kokoro", "supertonic"}:
|
||||||
provider_value = "kokoro"
|
provider_value = ""
|
||||||
|
|
||||||
|
# Determine the base speaker selection (saved speaker or raw voice).
|
||||||
|
narrator_voice_raw = (
|
||||||
|
form.get("voice")
|
||||||
|
or pending.voice
|
||||||
|
or settings.get("default_speaker")
|
||||||
|
or settings.get("default_voice")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
|
base_spec, selected_speaker_name = split_profile_spec(narrator_voice_raw)
|
||||||
|
selected_speaker_entry = None
|
||||||
|
if selected_speaker_name:
|
||||||
|
selected_speaker_entry = normalize_profile_entry(profiles_map.get(selected_speaker_name))
|
||||||
|
|
||||||
|
# If a saved speaker is selected, prefer its provider.
|
||||||
|
if selected_speaker_entry and selected_speaker_entry.get("provider") in {"kokoro", "supertonic"}:
|
||||||
|
provider_value = str(selected_speaker_entry.get("provider"))
|
||||||
|
|
||||||
|
if not provider_value:
|
||||||
|
# Fall back to inferring provider from the raw voice spec.
|
||||||
|
provider_value = "supertonic" if base_spec in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
|
||||||
|
|
||||||
pending.tts_provider = provider_value
|
pending.tts_provider = provider_value
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pending.supertonic_total_steps = int(
|
pending.supertonic_total_steps = int(
|
||||||
form.get("supertonic_total_steps")
|
form.get("supertonic_total_steps")
|
||||||
or getattr(pending, "supertonic_total_steps", None)
|
or getattr(pending, "supertonic_total_steps", None)
|
||||||
|
or (selected_speaker_entry or {}).get("total_steps")
|
||||||
or settings.get("supertonic_total_steps")
|
or settings.get("supertonic_total_steps")
|
||||||
or 5
|
or 5
|
||||||
)
|
)
|
||||||
@@ -589,28 +615,32 @@ def apply_book_step_form(
|
|||||||
pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5)
|
pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5)
|
||||||
|
|
||||||
if provider_value == "supertonic":
|
if provider_value == "supertonic":
|
||||||
narrator_voice_raw = (
|
# If the user picked a saved Supertonic speaker, use its config.
|
||||||
form.get("voice")
|
if selected_speaker_entry and selected_speaker_entry.get("provider") == "supertonic":
|
||||||
or pending.voice
|
pending.voice_profile = None
|
||||||
or settings.get("supertonic_default_voice")
|
pending.voice = str(selected_speaker_entry.get("voice") or "M1")
|
||||||
or "M1"
|
pending.supertonic_total_steps = int(selected_speaker_entry.get("total_steps") or pending.supertonic_total_steps)
|
||||||
).strip()
|
if speed_value is None:
|
||||||
# Supertonic does not support Abogen voice mixing.
|
try:
|
||||||
pending.voice_profile = None
|
pending.speed = float(selected_speaker_entry.get("speed") or settings.get("supertonic_speed") or 1.0)
|
||||||
pending.voice = narrator_voice_raw
|
except (TypeError, ValueError):
|
||||||
|
pending.speed = 1.0
|
||||||
# Provider-specific speed default.
|
pending.language = str(selected_speaker_entry.get("language") or "a")
|
||||||
if speed_value is None:
|
else:
|
||||||
try:
|
# Supertonic does not support Abogen voice mixing.
|
||||||
pending.speed = float(settings.get("supertonic_speed") or 1.0)
|
pending.voice_profile = None
|
||||||
except (TypeError, ValueError):
|
pending.voice = (base_spec or "M1").strip() or "M1"
|
||||||
pending.speed = 1.0
|
# Provider-specific speed default.
|
||||||
|
if speed_value is None:
|
||||||
|
try:
|
||||||
|
pending.speed = float(settings.get("supertonic_speed") or 1.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pending.speed = 1.0
|
||||||
|
pending.language = "a"
|
||||||
else:
|
else:
|
||||||
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||||
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
||||||
narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip()
|
narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip()
|
||||||
|
|
||||||
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
|
||||||
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
|
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
|
||||||
narrator_voice_raw,
|
narrator_voice_raw,
|
||||||
profiles=profiles_map,
|
profiles=profiles_map,
|
||||||
|
|||||||
@@ -170,12 +170,11 @@ def has_output_override() -> bool:
|
|||||||
def settings_defaults() -> Dict[str, Any]:
|
def settings_defaults() -> Dict[str, Any]:
|
||||||
llm_env_defaults = environment_llm_defaults()
|
llm_env_defaults = environment_llm_defaults()
|
||||||
return {
|
return {
|
||||||
"tts_provider": "kokoro",
|
|
||||||
"output_format": "wav",
|
"output_format": "wav",
|
||||||
"subtitle_format": "srt",
|
"subtitle_format": "srt",
|
||||||
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||||
|
"default_speaker": "",
|
||||||
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||||
"supertonic_default_voice": "M1",
|
|
||||||
"supertonic_total_steps": 5,
|
"supertonic_total_steps": 5,
|
||||||
"supertonic_speed": 1.0,
|
"supertonic_speed": 1.0,
|
||||||
"replace_single_newlines": False,
|
"replace_single_newlines": False,
|
||||||
@@ -307,9 +306,19 @@ def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> A
|
|||||||
return defaults[key]
|
return defaults[key]
|
||||||
spec, profile_name = split_profile_spec(text)
|
spec, profile_name = split_profile_spec(text)
|
||||||
if profile_name:
|
if profile_name:
|
||||||
return f"profile:{profile_name}"
|
return f"speaker:{profile_name}"
|
||||||
return spec
|
return spec
|
||||||
return defaults[key]
|
return defaults[key]
|
||||||
|
if key == "default_speaker":
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
spec, profile_name = split_profile_spec(text)
|
||||||
|
if profile_name:
|
||||||
|
return f"speaker:{profile_name}"
|
||||||
|
return spec
|
||||||
|
return ""
|
||||||
if key == "chunk_level":
|
if key == "chunk_level":
|
||||||
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
||||||
return value
|
return value
|
||||||
@@ -344,14 +353,6 @@ def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> A
|
|||||||
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
||||||
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
||||||
return defaults.get(key, [])
|
return defaults.get(key, [])
|
||||||
if key == "tts_provider":
|
|
||||||
if isinstance(value, str):
|
|
||||||
candidate = value.strip().lower()
|
|
||||||
if candidate in {"kokoro", "supertonic"}:
|
|
||||||
return candidate
|
|
||||||
return defaults.get(key, "kokoro")
|
|
||||||
if key == "supertonic_default_voice":
|
|
||||||
return str(value or "").strip() or defaults.get(key, "M1")
|
|
||||||
if key == "supertonic_total_steps":
|
if key == "supertonic_total_steps":
|
||||||
try:
|
try:
|
||||||
steps = int(value)
|
steps = int(value)
|
||||||
|
|||||||
@@ -575,11 +575,16 @@ def template_options() -> Dict[str, Any]:
|
|||||||
ordered_profiles = sorted(profiles.items())
|
ordered_profiles = sorted(profiles.items())
|
||||||
profile_options = []
|
profile_options = []
|
||||||
for name, entry in ordered_profiles:
|
for name, entry in ordered_profiles:
|
||||||
|
provider = str((entry or {}).get("provider") or "kokoro").strip().lower()
|
||||||
profile_options.append(
|
profile_options.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"language": (entry or {}).get("language", ""),
|
"language": (entry or {}).get("language", ""),
|
||||||
|
"provider": provider,
|
||||||
"formula": formula_from_profile(entry or {}) or "",
|
"formula": formula_from_profile(entry or {}) or "",
|
||||||
|
"voice": (entry or {}).get("voice", ""),
|
||||||
|
"total_steps": (entry or {}).get("total_steps"),
|
||||||
|
"speed": (entry or {}).get("speed"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
voice_catalog = build_voice_catalog()
|
voice_catalog = build_voice_catalog()
|
||||||
|
|||||||
+168
-22
@@ -24,6 +24,12 @@ const setupVoiceMixer = () => {
|
|||||||
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
||||||
const nameInput = document.getElementById("profile-name");
|
const nameInput = document.getElementById("profile-name");
|
||||||
const languageSelect = document.getElementById("profile-language");
|
const languageSelect = document.getElementById("profile-language");
|
||||||
|
const providerSelect = document.getElementById("profile-provider");
|
||||||
|
const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]');
|
||||||
|
const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]');
|
||||||
|
const supertonicVoiceSelect = app.querySelector('[data-role="supertonic-voice"]');
|
||||||
|
const supertonicStepsInput = app.querySelector('[data-role="supertonic-steps"]');
|
||||||
|
const supertonicSpeedInput = app.querySelector('[data-role="supertonic-speed"]');
|
||||||
const speedInput = document.getElementById("preview-speed");
|
const speedInput = document.getElementById("preview-speed");
|
||||||
const importInput = document.getElementById("voice-import-input");
|
const importInput = document.getElementById("voice-import-input");
|
||||||
const headerActions = document.querySelector(".voice-mixer__header-actions");
|
const headerActions = document.querySelector(".voice-mixer__header-actions");
|
||||||
@@ -59,8 +65,14 @@ const setupVoiceMixer = () => {
|
|||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
draft: {
|
draft: {
|
||||||
name: "",
|
name: "",
|
||||||
|
provider: "kokoro",
|
||||||
language: "a",
|
language: "a",
|
||||||
voices: new Map(),
|
voices: new Map(),
|
||||||
|
supertonic: {
|
||||||
|
voice: "M1",
|
||||||
|
total_steps: 5,
|
||||||
|
speed: 1.0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
|
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
|
||||||
genderFilter: "",
|
genderFilter: "",
|
||||||
@@ -126,17 +138,56 @@ const setupVoiceMixer = () => {
|
|||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMixSummary = () => {
|
const normalizeProvider = (value) => {
|
||||||
|
const candidate = String(value || "").trim().toLowerCase();
|
||||||
|
return candidate === "supertonic" ? "supertonic" : "kokoro";
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyProviderToUI = () => {
|
||||||
|
const provider = normalizeProvider(state.draft.provider);
|
||||||
|
const isSupertonic = provider === "supertonic";
|
||||||
|
if (providerSelect) {
|
||||||
|
providerSelect.value = provider;
|
||||||
|
}
|
||||||
|
if (kokoroMixerEl) {
|
||||||
|
kokoroMixerEl.hidden = isSupertonic;
|
||||||
|
}
|
||||||
|
if (supertonicPanelEl) {
|
||||||
|
supertonicPanelEl.hidden = !isSupertonic;
|
||||||
|
}
|
||||||
if (mixTotalEl) {
|
if (mixTotalEl) {
|
||||||
|
mixTotalEl.hidden = isSupertonic;
|
||||||
|
}
|
||||||
|
if (previewBtn) {
|
||||||
|
previewBtn.dataset.label = isSupertonic ? "Preview speaker" : (previewBtn.dataset.label || "Preview speaker");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep preview speed aligned with the Supertonic speaker speed.
|
||||||
|
if (isSupertonic && speedInput) {
|
||||||
|
const desired = Number(state.draft.supertonic?.speed ?? 1.0);
|
||||||
|
if (!Number.isNaN(desired)) {
|
||||||
|
speedInput.value = String(desired);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMixSummary = () => {
|
||||||
|
const provider = normalizeProvider(state.draft.provider);
|
||||||
|
const isSupertonic = provider === "supertonic";
|
||||||
|
if (mixTotalEl && !isSupertonic) {
|
||||||
mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`;
|
mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`;
|
||||||
}
|
}
|
||||||
if (profileSummaryEl) {
|
if (profileSummaryEl) {
|
||||||
const voiceCount = state.draft.voices.size;
|
const voiceCount = state.draft.voices.size;
|
||||||
if (!state.draft.name && !voiceCount) {
|
if (!state.draft.name && !voiceCount) {
|
||||||
profileSummaryEl.textContent = "Select or create a profile to begin.";
|
profileSummaryEl.textContent = "Select or create a speaker to begin.";
|
||||||
} else {
|
} else {
|
||||||
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved profile";
|
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved speaker";
|
||||||
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
|
if (isSupertonic) {
|
||||||
|
profileSummaryEl.textContent = `${profileLabel} · Supertonic`;
|
||||||
|
} else {
|
||||||
|
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -412,6 +463,16 @@ const setupVoiceMixer = () => {
|
|||||||
if (languageSelect) {
|
if (languageSelect) {
|
||||||
languageSelect.value = state.draft.language || "a";
|
languageSelect.value = state.draft.language || "a";
|
||||||
}
|
}
|
||||||
|
if (supertonicVoiceSelect) {
|
||||||
|
supertonicVoiceSelect.value = state.draft.supertonic?.voice || "M1";
|
||||||
|
}
|
||||||
|
if (supertonicStepsInput) {
|
||||||
|
supertonicStepsInput.value = String(state.draft.supertonic?.total_steps ?? 5);
|
||||||
|
}
|
||||||
|
if (supertonicSpeedInput) {
|
||||||
|
supertonicSpeedInput.value = String(state.draft.supertonic?.speed ?? 1.0);
|
||||||
|
}
|
||||||
|
applyProviderToUI();
|
||||||
renderSelectedVoices();
|
renderSelectedVoices();
|
||||||
updateMixSummary();
|
updateMixSummary();
|
||||||
updateAvailableState();
|
updateAvailableState();
|
||||||
@@ -425,7 +486,7 @@ const setupVoiceMixer = () => {
|
|||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "voice-list__header";
|
header.className = "voice-list__header";
|
||||||
const heading = document.createElement("h2");
|
const heading = document.createElement("h2");
|
||||||
heading.textContent = "Saved profiles";
|
heading.textContent = "Saved speakers";
|
||||||
header.appendChild(heading);
|
header.appendChild(heading);
|
||||||
profileListEl.appendChild(header);
|
profileListEl.appendChild(header);
|
||||||
|
|
||||||
@@ -433,7 +494,7 @@ const setupVoiceMixer = () => {
|
|||||||
if (!names.length) {
|
if (!names.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "tag";
|
empty.className = "tag";
|
||||||
empty.textContent = "No profiles yet. Create one on the right.";
|
empty.textContent = "No speakers yet. Create one on the right.";
|
||||||
profileListEl.appendChild(empty);
|
profileListEl.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -453,9 +514,11 @@ const setupVoiceMixer = () => {
|
|||||||
selectBtn.className = "voice-list__select";
|
selectBtn.className = "voice-list__select";
|
||||||
selectBtn.dataset.name = name;
|
selectBtn.dataset.name = name;
|
||||||
const profile = profiles[name] || {};
|
const profile = profiles[name] || {};
|
||||||
|
const provider = normalizeProvider(profile.provider);
|
||||||
|
const providerLabel = provider === "supertonic" ? "Supertonic" : "Kokoro";
|
||||||
selectBtn.innerHTML = `
|
selectBtn.innerHTML = `
|
||||||
<span class="voice-list__name">${name}</span>
|
<span class="voice-list__name">${name}</span>
|
||||||
<span class="voice-list__meta">${voiceLanguageLabel(profile.language || "a")}</span>
|
<span class="voice-list__meta"><span class="tag">${providerLabel}</span> ${voiceLanguageLabel(profile.language || "a")}</span>
|
||||||
`;
|
`;
|
||||||
selectBtn.addEventListener("click", () => selectProfile(name));
|
selectBtn.addEventListener("click", () => selectProfile(name));
|
||||||
|
|
||||||
@@ -495,12 +558,19 @@ const setupVoiceMixer = () => {
|
|||||||
state.selectedProfile = name;
|
state.selectedProfile = name;
|
||||||
state.originalName = name;
|
state.originalName = name;
|
||||||
const profile = profiles[name];
|
const profile = profiles[name];
|
||||||
|
const provider = normalizeProvider(profile?.provider);
|
||||||
state.draft = {
|
state.draft = {
|
||||||
name,
|
name,
|
||||||
|
provider,
|
||||||
language: profile?.language || "a",
|
language: profile?.language || "a",
|
||||||
voices: new Map(),
|
voices: new Map(),
|
||||||
|
supertonic: {
|
||||||
|
voice: profile?.voice || "M1",
|
||||||
|
total_steps: Number(profile?.total_steps ?? 5),
|
||||||
|
speed: Number(profile?.speed ?? 1.0),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (Array.isArray(profile?.voices)) {
|
if (provider === "kokoro" && Array.isArray(profile?.voices)) {
|
||||||
profile.voices.forEach((entry) => {
|
profile.voices.forEach((entry) => {
|
||||||
if (Array.isArray(entry) && entry.length >= 2) {
|
if (Array.isArray(entry) && entry.length >= 2) {
|
||||||
const [voiceId, weight] = entry;
|
const [voiceId, weight] = entry;
|
||||||
@@ -515,16 +585,23 @@ const setupVoiceMixer = () => {
|
|||||||
applyDraftToControls();
|
applyDraftToControls();
|
||||||
renderProfileList();
|
renderProfileList();
|
||||||
loadSampleText();
|
loadSampleText();
|
||||||
setStatus(`Loaded profile “${name}”.`, "info", 2500);
|
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createNewProfile = () => {
|
const createNewProfile = () => {
|
||||||
|
const isSupertonic = window.confirm("Create a Supertonic speaker?\n\nOK = Supertonic\nCancel = Kokoro");
|
||||||
state.selectedProfile = null;
|
state.selectedProfile = null;
|
||||||
state.originalName = null;
|
state.originalName = null;
|
||||||
state.draft = {
|
state.draft = {
|
||||||
name: "",
|
name: "",
|
||||||
|
provider: isSupertonic ? "supertonic" : "kokoro",
|
||||||
language: languageSelect ? languageSelect.value || "a" : "a",
|
language: languageSelect ? languageSelect.value || "a" : "a",
|
||||||
voices: new Map(),
|
voices: new Map(),
|
||||||
|
supertonic: {
|
||||||
|
voice: "M1",
|
||||||
|
total_steps: 5,
|
||||||
|
speed: 1.0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
applyDraftToControls();
|
applyDraftToControls();
|
||||||
renderProfileList();
|
renderProfileList();
|
||||||
@@ -579,8 +656,12 @@ const setupVoiceMixer = () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
originalName: state.originalName,
|
originalName: state.originalName,
|
||||||
|
provider: normalizeProvider(state.draft.provider),
|
||||||
language: languageSelect ? languageSelect.value : "a",
|
language: languageSelect ? languageSelect.value : "a",
|
||||||
voices: buildProfilePayload(),
|
voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [],
|
||||||
|
voice: state.draft.supertonic?.voice,
|
||||||
|
total_steps: state.draft.supertonic?.total_steps,
|
||||||
|
speed: state.draft.supertonic?.speed,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/voice-profiles", {
|
const response = await fetch("/api/voice-profiles", {
|
||||||
@@ -591,7 +672,7 @@ const setupVoiceMixer = () => {
|
|||||||
const result = await withJson(response);
|
const result = await withJson(response);
|
||||||
refreshProfiles(result.profiles, result.profile);
|
refreshProfiles(result.profiles, result.profile);
|
||||||
resetDirty();
|
resetDirty();
|
||||||
setStatus(`Saved profile “${result.profile}”.`, "success");
|
setStatus(`Saved speaker “${result.profile}”.`, "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message || "Failed to save profile", "danger", 7000);
|
setStatus(error.message || "Failed to save profile", "danger", 7000);
|
||||||
}
|
}
|
||||||
@@ -603,7 +684,7 @@ const setupVoiceMixer = () => {
|
|||||||
setStatus("Select a profile to delete.", "warning");
|
setStatus("Select a profile to delete.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(`Delete profile “${name}”?`);
|
const confirmed = window.confirm(`Delete speaker “${name}”?`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, {
|
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, {
|
||||||
@@ -611,7 +692,7 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
const result = await withJson(response);
|
const result = await withJson(response);
|
||||||
refreshProfiles(result.profiles);
|
refreshProfiles(result.profiles);
|
||||||
setStatus(`Deleted profile “${name}”.`, "info");
|
setStatus(`Deleted speaker “${name}”.`, "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message || "Failed to delete profile", "danger", 7000);
|
setStatus(error.message || "Failed to delete profile", "danger", 7000);
|
||||||
}
|
}
|
||||||
@@ -623,7 +704,7 @@ const setupVoiceMixer = () => {
|
|||||||
setStatus("Select a profile to duplicate.", "warning");
|
setStatus("Select a profile to duplicate.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newName = window.prompt("Duplicate profile as…", `${name} copy`);
|
const newName = window.prompt("Duplicate speaker as…", `${name} copy`);
|
||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, {
|
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, {
|
||||||
@@ -643,7 +724,7 @@ const setupVoiceMixer = () => {
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
const replace = window.confirm("Replace existing profiles if duplicates are found?");
|
const replace = window.confirm("Replace existing speakers if duplicates are found?");
|
||||||
const response = await fetch("/api/voice-profiles/import", {
|
const response = await fetch("/api/voice-profiles/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -651,7 +732,7 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
const result = await withJson(response);
|
const result = await withJson(response);
|
||||||
refreshProfiles(result.profiles);
|
refreshProfiles(result.profiles);
|
||||||
setStatus(`Imported ${result.imported.length} profile${result.imported.length === 1 ? "" : "s"}.`, "success");
|
setStatus(`Imported ${result.imported.length} speaker${result.imported.length === 1 ? "" : "s"}.`, "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error.message || "Import failed", "danger", 7000);
|
setStatus(error.message || "Import failed", "danger", 7000);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -684,16 +765,30 @@ const setupVoiceMixer = () => {
|
|||||||
|
|
||||||
const runPreview = async () => {
|
const runPreview = async () => {
|
||||||
if (!previewBtn) return;
|
if (!previewBtn) return;
|
||||||
|
const provider = normalizeProvider(state.draft.provider);
|
||||||
const payload = {
|
const payload = {
|
||||||
|
provider,
|
||||||
language: languageSelect ? languageSelect.value : "a",
|
language: languageSelect ? languageSelect.value : "a",
|
||||||
voices: buildProfilePayload(),
|
voices: provider === "kokoro" ? buildProfilePayload() : [],
|
||||||
|
voice: state.draft.supertonic?.voice,
|
||||||
|
total_steps: state.draft.supertonic?.total_steps,
|
||||||
text: previewTextEl ? previewTextEl.value : "",
|
text: previewTextEl ? previewTextEl.value : "",
|
||||||
speed: speedInput ? parseFloat(speedInput.value || "1") : 1,
|
speed: speedInput ? parseFloat(speedInput.value || "1") : 1,
|
||||||
|
max_seconds: 8,
|
||||||
};
|
};
|
||||||
const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0);
|
if (provider === "kokoro") {
|
||||||
if (!enabledVoices.length) {
|
const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0);
|
||||||
setStatus("Enable at least one voice to preview.", "warning");
|
if (!enabledVoices.length) {
|
||||||
return;
|
setStatus("Enable at least one voice to preview.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!payload.voice) {
|
||||||
|
setStatus("Select a Supertonic voice to preview.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.supertonic_total_steps = payload.total_steps;
|
||||||
|
payload.tts_provider = "supertonic";
|
||||||
}
|
}
|
||||||
previewBtn.disabled = true;
|
previewBtn.disabled = true;
|
||||||
previewBtn.dataset.loading = "true";
|
previewBtn.dataset.loading = "true";
|
||||||
@@ -724,7 +819,7 @@ const setupVoiceMixer = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
previewBtn.disabled = false;
|
previewBtn.disabled = false;
|
||||||
previewBtn.dataset.loading = "false";
|
previewBtn.dataset.loading = "false";
|
||||||
previewBtn.textContent = previewBtn.dataset.label || "Preview mix";
|
previewBtn.textContent = previewBtn.dataset.label || "Preview speaker";
|
||||||
previewBtn.removeAttribute("aria-busy");
|
previewBtn.removeAttribute("aria-busy");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -763,6 +858,50 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerSelect) {
|
||||||
|
providerSelect.addEventListener("change", () => {
|
||||||
|
state.draft.provider = normalizeProvider(providerSelect.value);
|
||||||
|
// When switching to Supertonic, clear Kokoro mix.
|
||||||
|
if (state.draft.provider === "supertonic") {
|
||||||
|
state.draft.voices = new Map();
|
||||||
|
}
|
||||||
|
applyDraftToControls();
|
||||||
|
markDirty();
|
||||||
|
loadSampleText();
|
||||||
|
setStatus("Provider updated.", "info", 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supertonicVoiceSelect) {
|
||||||
|
supertonicVoiceSelect.addEventListener("change", () => {
|
||||||
|
state.draft.supertonic.voice = supertonicVoiceSelect.value;
|
||||||
|
markDirty();
|
||||||
|
updateMixSummary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supertonicStepsInput) {
|
||||||
|
supertonicStepsInput.addEventListener("input", () => {
|
||||||
|
const value = Number(supertonicStepsInput.value || "5");
|
||||||
|
state.draft.supertonic.total_steps = clamp(value, 2, 15);
|
||||||
|
supertonicStepsInput.value = String(Math.round(state.draft.supertonic.total_steps));
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supertonicSpeedInput) {
|
||||||
|
supertonicSpeedInput.addEventListener("input", () => {
|
||||||
|
const value = parseFloat(supertonicSpeedInput.value || "1");
|
||||||
|
const normalized = clamp(value, 0.7, 2.0);
|
||||||
|
state.draft.supertonic.speed = normalized;
|
||||||
|
supertonicSpeedInput.value = normalized.toFixed(2);
|
||||||
|
if (speedInput) {
|
||||||
|
speedInput.value = String(normalized);
|
||||||
|
}
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (voiceFilterSelect) {
|
if (voiceFilterSelect) {
|
||||||
voiceFilterSelect.addEventListener("change", () => {
|
voiceFilterSelect.addEventListener("change", () => {
|
||||||
state.languageFilter = voiceFilterSelect.value;
|
state.languageFilter = voiceFilterSelect.value;
|
||||||
@@ -777,6 +916,13 @@ const setupVoiceMixer = () => {
|
|||||||
previewSpeedLabel.textContent = `${speed.toFixed(2)}×`;
|
previewSpeedLabel.textContent = `${speed.toFixed(2)}×`;
|
||||||
}
|
}
|
||||||
setRangeFill(speedInput);
|
setRangeFill(speedInput);
|
||||||
|
|
||||||
|
if (normalizeProvider(state.draft.provider) === "supertonic") {
|
||||||
|
state.draft.supertonic.speed = clamp(speed, 0.7, 2.0);
|
||||||
|
if (supertonicSpeedInput) {
|
||||||
|
supertonicSpeedInput.value = state.draft.supertonic.speed.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
speedInput.addEventListener("input", updatePreviewSpeedLabel);
|
speedInput.addEventListener("input", updatePreviewSpeedLabel);
|
||||||
updatePreviewSpeedLabel();
|
updatePreviewSpeedLabel();
|
||||||
|
|||||||
@@ -36,19 +36,36 @@
|
|||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
<legend>Narration Defaults</legend>
|
<legend>Narration Defaults</legend>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tts_provider">Default TTS Provider</label>
|
<label for="default_speaker">Default Speaker</label>
|
||||||
<select id="tts_provider" name="tts_provider">
|
<select id="default_speaker" name="default_speaker">
|
||||||
<option value="kokoro" {% if (settings.tts_provider or 'kokoro') == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
<option value="" {% if not settings.default_speaker %}selected{% endif %}>Use fallback voice</option>
|
||||||
<option value="supertonic" {% if settings.tts_provider == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
{% if options.voice_profile_options %}
|
||||||
|
{% for profile in options.voice_profile_options %}
|
||||||
|
{% set profile_value = 'speaker:' ~ profile.name %}
|
||||||
|
<option value="{{ profile_value }}" {% if settings.default_speaker == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.provider %} · {{ profile.provider|capitalize }}{% endif %}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% set current_default = settings.default_speaker %}
|
||||||
|
{% if current_default %}
|
||||||
|
{% set known_default = namespace(value=False) %}
|
||||||
|
{% for profile in options.voice_profile_options %}
|
||||||
|
{% if current_default == 'speaker:' ~ profile.name or current_default == 'profile:' ~ profile.name %}
|
||||||
|
{% set known_default.value = True %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if not known_default.value %}
|
||||||
|
<option value="{{ current_default }}" selected>{{ current_default }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Applies to new jobs unless overridden in the job wizard.</p>
|
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field field--wide">
|
<div class="field field--wide">
|
||||||
<p class="tag">Kokoro settings</p>
|
<p class="tag">Kokoro settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="default_voice">Narrator Voice</label>
|
<label for="default_voice">Fallback Kokoro Voice</label>
|
||||||
<select id="default_voice" name="default_voice">
|
<select id="default_voice" name="default_voice">
|
||||||
<optgroup label="Standard voices">
|
<optgroup label="Standard voices">
|
||||||
{% for voice in options.voices %}
|
{% for voice in options.voices %}
|
||||||
@@ -80,20 +97,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
|
<p class="hint">Used when no default speaker is selected, and as a fallback when speaker analysis cannot resolve a speaker.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field field--wide">
|
<div class="field field--wide">
|
||||||
<p class="tag">Supertonic settings</p>
|
<p class="tag">Supertonic settings</p>
|
||||||
<p class="hint">Supertonic does not use voice mixing in Abogen right now.</p>
|
<p class="hint">These defaults apply when a Supertonic speaker does not override them.</p>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="supertonic_default_voice">Default Supertonic Voice</label>
|
|
||||||
<select id="supertonic_default_voice" name="supertonic_default_voice">
|
|
||||||
{% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %}
|
|
||||||
<option value="{{ voice }}" {% if settings.supertonic_default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="supertonic_total_steps">Supertonic Quality (total steps)</label>
|
<label for="supertonic_total_steps">Supertonic Quality (total steps)</label>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
<div class="voice-mixer__header">
|
<div class="voice-mixer__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="card__title">Speaker Studio</h1>
|
<h1 class="card__title">Speaker Studio</h1>
|
||||||
<p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
|
<p class="tag">Create and manage speakers for Kokoro and Supertonic.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-mixer__header-actions">
|
<div class="voice-mixer__header-actions">
|
||||||
<button type="button" class="button button--ghost" data-action="new-profile">New profile</button>
|
<button type="button" class="button button--ghost" data-action="new-profile">New speaker</button>
|
||||||
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
|
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
|
||||||
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
|
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
<div class="voice-editor__meta">
|
<div class="voice-editor__meta">
|
||||||
<div class="voice-editor__identity">
|
<div class="voice-editor__identity">
|
||||||
<div class="field voice-editor__name-field">
|
<div class="field voice-editor__name-field">
|
||||||
<label for="profile-name">Profile name</label>
|
<label for="profile-name">Speaker name</label>
|
||||||
<input id="profile-name" name="name" type="text" placeholder="Narrator blend" required>
|
<input id="profile-name" name="name" type="text" placeholder="Narrator" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-editor__toolbar" role="group" aria-label="Profile actions">
|
<div class="voice-editor__toolbar" role="group" aria-label="Profile actions">
|
||||||
<button type="submit" class="icon-button icon-button--primary" data-role="save-profile" disabled aria-label="Save profile" title="Save profile">
|
<button type="submit" class="icon-button icon-button--primary" data-role="save-profile" disabled aria-label="Save profile" title="Save profile">
|
||||||
@@ -49,6 +49,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field voice-editor__provider">
|
||||||
|
<label for="profile-provider">TTS provider</label>
|
||||||
|
<select id="profile-provider" name="provider" data-role="provider">
|
||||||
|
<option value="kokoro">Kokoro</option>
|
||||||
|
<option value="supertonic">Supertonic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="field voice-editor__language">
|
<div class="field voice-editor__language">
|
||||||
<label for="profile-language">Language</label>
|
<label for="profile-language">Language</label>
|
||||||
<select id="profile-language" name="language">
|
<select id="profile-language" name="language">
|
||||||
@@ -57,12 +64,32 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="voice-editor__provider-panel" data-role="supertonic-panel" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="supertonic-voice">Supertonic voice</label>
|
||||||
|
<select id="supertonic-voice" data-role="supertonic-voice">
|
||||||
|
{% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %}
|
||||||
|
<option value="{{ voice }}">{{ voice }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="supertonic-quality">Supertonic quality (total steps)</label>
|
||||||
|
<input type="number" id="supertonic-quality" data-role="supertonic-steps" min="2" max="15" value="5">
|
||||||
|
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="supertonic-speaker-speed">Supertonic speed</label>
|
||||||
|
<input type="number" id="supertonic-speaker-speed" data-role="supertonic-speed" min="0.7" max="2.0" step="0.05" value="1.0">
|
||||||
|
</div>
|
||||||
|
<p class="hint">Supertonic voice mixing is not implemented yet. Stub target: <a href="https://github.com/Topping1/Supertonic-Voice-Mixer" target="_blank" rel="noreferrer">Supertonic-Voice-Mixer</a>.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-editor__summary">
|
<div class="voice-editor__summary">
|
||||||
<span data-role="profile-summary">Select or create a profile to begin.</span>
|
<span data-role="profile-summary">Select or create a profile to begin.</span>
|
||||||
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
|
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-editor__canvas">
|
<div class="voice-editor__canvas" data-role="kokoro-mixer">
|
||||||
<section class="voice-available">
|
<section class="voice-available">
|
||||||
<header class="voice-available__header">
|
<header class="voice-available__header">
|
||||||
<div class="voice-available__title">
|
<div class="voice-available__title">
|
||||||
@@ -106,10 +133,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field field--slider">
|
<div class="field field--slider">
|
||||||
<label for="preview-speed">Preview speed <span class="tag" data-role="preview-speed-display">1.00×</span></label>
|
<label for="preview-speed">Preview speed <span class="tag" data-role="preview-speed-display">1.00×</span></label>
|
||||||
<input id="preview-speed" type="range" min="0.7" max="1.3" step="0.05" value="1.0" data-role="preview-speed">
|
<input id="preview-speed" type="range" min="0.7" max="2.0" step="0.05" value="1.0" data-role="preview-speed">
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-preview__controls">
|
<div class="voice-preview__controls">
|
||||||
<button type="button" class="button" data-role="preview-button">Preview mix</button>
|
<button type="button" class="button" data-role="preview-button">Preview speaker</button>
|
||||||
<button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button>
|
<button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button>
|
||||||
</div>
|
</div>
|
||||||
<audio data-role="preview-audio" controls preload="none"></audio>
|
<audio data-role="preview-audio" controls preload="none"></audio>
|
||||||
|
|||||||
Reference in New Issue
Block a user