mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +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 os
|
||||
from typing import Dict, Iterable, List, Tuple
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||
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()
|
||||
|
||||
|
||||
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]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
for item in entries or []:
|
||||
@@ -112,7 +170,7 @@ def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||
language = "a"
|
||||
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"language": language, "voices": normalized}
|
||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
@@ -138,16 +196,13 @@ def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[
|
||||
current = load_profiles()
|
||||
updated: List[str] = []
|
||||
for name, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
normalized = normalize_profile_entry(entry)
|
||||
if not normalized:
|
||||
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:
|
||||
# skip duplicates unless explicit replacement is requested
|
||||
continue
|
||||
current[name] = {"language": language, "voices": voices}
|
||||
current[name] = normalized
|
||||
updated.append(name)
|
||||
|
||||
if updated:
|
||||
|
||||
+168
-6
@@ -16,9 +16,15 @@ from abogen.voice_profiles import (
|
||||
load_profiles,
|
||||
save_profiles,
|
||||
delete_profile,
|
||||
duplicate_profile,
|
||||
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.voice import formula_from_profile
|
||||
from abogen.normalization_settings import (
|
||||
build_llm_configuration,
|
||||
build_apostrophe_config,
|
||||
@@ -48,21 +54,158 @@ def api_get_voice_profiles() -> ResponseReturnValue:
|
||||
@api_bp.post("/voice-profiles")
|
||||
def api_save_voice_profile() -> ResponseReturnValue:
|
||||
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")
|
||||
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:
|
||||
return jsonify({"error": "Name and profile are required"}), 400
|
||||
|
||||
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)
|
||||
return jsonify({"success": True})
|
||||
|
||||
return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()})
|
||||
|
||||
@api_bp.delete("/voice-profiles/<path:name>")
|
||||
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
||||
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")
|
||||
def api_speaker_preview() -> ResponseReturnValue:
|
||||
@@ -70,13 +213,32 @@ def api_speaker_preview() -> ResponseReturnValue:
|
||||
text = payload.get("text", "Hello world")
|
||||
voice = payload.get("voice", "af_heart")
|
||||
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()
|
||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
||||
|
||||
settings = load_settings()
|
||||
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:
|
||||
return synthesize_preview(
|
||||
text=text,
|
||||
@@ -85,7 +247,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
||||
speed=speed,
|
||||
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),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -37,9 +37,8 @@ def update_settings() -> ResponseReturnValue:
|
||||
|
||||
# General settings
|
||||
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["supertonic_default_voice"] = (form.get("supertonic_default_voice") or current.get("supertonic_default_voice") or "M1").strip()
|
||||
try:
|
||||
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
||||
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.common import split_profile_spec
|
||||
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.constants import VOICES_INTERNAL
|
||||
from abogen.speaker_configs import get_config
|
||||
@@ -574,14 +574,40 @@ def apply_book_step_form(
|
||||
except ValueError:
|
||||
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"}:
|
||||
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
|
||||
|
||||
try:
|
||||
pending.supertonic_total_steps = int(
|
||||
form.get("supertonic_total_steps")
|
||||
or getattr(pending, "supertonic_total_steps", None)
|
||||
or (selected_speaker_entry or {}).get("total_steps")
|
||||
or settings.get("supertonic_total_steps")
|
||||
or 5
|
||||
)
|
||||
@@ -589,28 +615,32 @@ def apply_book_step_form(
|
||||
pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5)
|
||||
|
||||
if provider_value == "supertonic":
|
||||
narrator_voice_raw = (
|
||||
form.get("voice")
|
||||
or pending.voice
|
||||
or settings.get("supertonic_default_voice")
|
||||
or "M1"
|
||||
).strip()
|
||||
# If the user picked a saved Supertonic speaker, use its config.
|
||||
if selected_speaker_entry and selected_speaker_entry.get("provider") == "supertonic":
|
||||
pending.voice_profile = None
|
||||
pending.voice = str(selected_speaker_entry.get("voice") or "M1")
|
||||
pending.supertonic_total_steps = int(selected_speaker_entry.get("total_steps") or pending.supertonic_total_steps)
|
||||
if speed_value is None:
|
||||
try:
|
||||
pending.speed = float(selected_speaker_entry.get("speed") or settings.get("supertonic_speed") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
pending.speed = 1.0
|
||||
pending.language = str(selected_speaker_entry.get("language") or "a")
|
||||
else:
|
||||
# Supertonic does not support Abogen voice mixing.
|
||||
pending.voice_profile = None
|
||||
pending.voice = narrator_voice_raw
|
||||
|
||||
pending.voice = (base_spec or "M1").strip() or "M1"
|
||||
# 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:
|
||||
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").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()
|
||||
|
||||
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||
narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip()
|
||||
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
|
||||
narrator_voice_raw,
|
||||
profiles=profiles_map,
|
||||
|
||||
@@ -170,12 +170,11 @@ def has_output_override() -> bool:
|
||||
def settings_defaults() -> Dict[str, Any]:
|
||||
llm_env_defaults = environment_llm_defaults()
|
||||
return {
|
||||
"tts_provider": "kokoro",
|
||||
"output_format": "wav",
|
||||
"subtitle_format": "srt",
|
||||
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||
"default_speaker": "",
|
||||
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||
"supertonic_default_voice": "M1",
|
||||
"supertonic_total_steps": 5,
|
||||
"supertonic_speed": 1.0,
|
||||
"replace_single_newlines": False,
|
||||
@@ -307,9 +306,19 @@ def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> A
|
||||
return defaults[key]
|
||||
spec, profile_name = split_profile_spec(text)
|
||||
if profile_name:
|
||||
return f"profile:{profile_name}"
|
||||
return f"speaker:{profile_name}"
|
||||
return spec
|
||||
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 isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
||||
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()]
|
||||
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
||||
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":
|
||||
try:
|
||||
steps = int(value)
|
||||
|
||||
@@ -575,11 +575,16 @@ def template_options() -> Dict[str, Any]:
|
||||
ordered_profiles = sorted(profiles.items())
|
||||
profile_options = []
|
||||
for name, entry in ordered_profiles:
|
||||
provider = str((entry or {}).get("provider") or "kokoro").strip().lower()
|
||||
profile_options.append(
|
||||
{
|
||||
"name": name,
|
||||
"language": (entry or {}).get("language", ""),
|
||||
"provider": provider,
|
||||
"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()
|
||||
|
||||
+163
-17
@@ -24,6 +24,12 @@ const setupVoiceMixer = () => {
|
||||
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
||||
const nameInput = document.getElementById("profile-name");
|
||||
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 importInput = document.getElementById("voice-import-input");
|
||||
const headerActions = document.querySelector(".voice-mixer__header-actions");
|
||||
@@ -59,8 +65,14 @@ const setupVoiceMixer = () => {
|
||||
previewUrl: null,
|
||||
draft: {
|
||||
name: "",
|
||||
provider: "kokoro",
|
||||
language: "a",
|
||||
voices: new Map(),
|
||||
supertonic: {
|
||||
voice: "M1",
|
||||
total_steps: 5,
|
||||
speed: 1.0,
|
||||
},
|
||||
},
|
||||
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
|
||||
genderFilter: "",
|
||||
@@ -126,19 +138,58 @@ const setupVoiceMixer = () => {
|
||||
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) {
|
||||
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())}`;
|
||||
}
|
||||
if (profileSummaryEl) {
|
||||
const voiceCount = state.draft.voices.size;
|
||||
if (!state.draft.name && !voiceCount) {
|
||||
profileSummaryEl.textContent = "Select or create a profile to begin.";
|
||||
profileSummaryEl.textContent = "Select or create a speaker to begin.";
|
||||
} else {
|
||||
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved speaker";
|
||||
if (isSupertonic) {
|
||||
profileSummaryEl.textContent = `${profileLabel} · Supertonic`;
|
||||
} else {
|
||||
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved profile";
|
||||
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markDirty = () => {
|
||||
@@ -412,6 +463,16 @@ const setupVoiceMixer = () => {
|
||||
if (languageSelect) {
|
||||
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();
|
||||
updateMixSummary();
|
||||
updateAvailableState();
|
||||
@@ -425,7 +486,7 @@ const setupVoiceMixer = () => {
|
||||
const header = document.createElement("div");
|
||||
header.className = "voice-list__header";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Saved profiles";
|
||||
heading.textContent = "Saved speakers";
|
||||
header.appendChild(heading);
|
||||
profileListEl.appendChild(header);
|
||||
|
||||
@@ -433,7 +494,7 @@ const setupVoiceMixer = () => {
|
||||
if (!names.length) {
|
||||
const empty = document.createElement("p");
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -453,9 +514,11 @@ const setupVoiceMixer = () => {
|
||||
selectBtn.className = "voice-list__select";
|
||||
selectBtn.dataset.name = name;
|
||||
const profile = profiles[name] || {};
|
||||
const provider = normalizeProvider(profile.provider);
|
||||
const providerLabel = provider === "supertonic" ? "Supertonic" : "Kokoro";
|
||||
selectBtn.innerHTML = `
|
||||
<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));
|
||||
|
||||
@@ -495,12 +558,19 @@ const setupVoiceMixer = () => {
|
||||
state.selectedProfile = name;
|
||||
state.originalName = name;
|
||||
const profile = profiles[name];
|
||||
const provider = normalizeProvider(profile?.provider);
|
||||
state.draft = {
|
||||
name,
|
||||
provider,
|
||||
language: profile?.language || "a",
|
||||
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) => {
|
||||
if (Array.isArray(entry) && entry.length >= 2) {
|
||||
const [voiceId, weight] = entry;
|
||||
@@ -515,16 +585,23 @@ const setupVoiceMixer = () => {
|
||||
applyDraftToControls();
|
||||
renderProfileList();
|
||||
loadSampleText();
|
||||
setStatus(`Loaded profile “${name}”.`, "info", 2500);
|
||||
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
|
||||
};
|
||||
|
||||
const createNewProfile = () => {
|
||||
const isSupertonic = window.confirm("Create a Supertonic speaker?\n\nOK = Supertonic\nCancel = Kokoro");
|
||||
state.selectedProfile = null;
|
||||
state.originalName = null;
|
||||
state.draft = {
|
||||
name: "",
|
||||
provider: isSupertonic ? "supertonic" : "kokoro",
|
||||
language: languageSelect ? languageSelect.value || "a" : "a",
|
||||
voices: new Map(),
|
||||
supertonic: {
|
||||
voice: "M1",
|
||||
total_steps: 5,
|
||||
speed: 1.0,
|
||||
},
|
||||
};
|
||||
applyDraftToControls();
|
||||
renderProfileList();
|
||||
@@ -579,8 +656,12 @@ const setupVoiceMixer = () => {
|
||||
const payload = {
|
||||
name,
|
||||
originalName: state.originalName,
|
||||
provider: normalizeProvider(state.draft.provider),
|
||||
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 {
|
||||
const response = await fetch("/api/voice-profiles", {
|
||||
@@ -591,7 +672,7 @@ const setupVoiceMixer = () => {
|
||||
const result = await withJson(response);
|
||||
refreshProfiles(result.profiles, result.profile);
|
||||
resetDirty();
|
||||
setStatus(`Saved profile “${result.profile}”.`, "success");
|
||||
setStatus(`Saved speaker “${result.profile}”.`, "success");
|
||||
} catch (error) {
|
||||
setStatus(error.message || "Failed to save profile", "danger", 7000);
|
||||
}
|
||||
@@ -603,7 +684,7 @@ const setupVoiceMixer = () => {
|
||||
setStatus("Select a profile to delete.", "warning");
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Delete profile “${name}”?`);
|
||||
const confirmed = window.confirm(`Delete speaker “${name}”?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, {
|
||||
@@ -611,7 +692,7 @@ const setupVoiceMixer = () => {
|
||||
});
|
||||
const result = await withJson(response);
|
||||
refreshProfiles(result.profiles);
|
||||
setStatus(`Deleted profile “${name}”.`, "info");
|
||||
setStatus(`Deleted speaker “${name}”.`, "info");
|
||||
} catch (error) {
|
||||
setStatus(error.message || "Failed to delete profile", "danger", 7000);
|
||||
}
|
||||
@@ -623,7 +704,7 @@ const setupVoiceMixer = () => {
|
||||
setStatus("Select a profile to duplicate.", "warning");
|
||||
return;
|
||||
}
|
||||
const newName = window.prompt("Duplicate profile as…", `${name} copy`);
|
||||
const newName = window.prompt("Duplicate speaker as…", `${name} copy`);
|
||||
if (!newName) return;
|
||||
try {
|
||||
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, {
|
||||
@@ -643,7 +724,7 @@ const setupVoiceMixer = () => {
|
||||
try {
|
||||
const text = await file.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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -651,7 +732,7 @@ const setupVoiceMixer = () => {
|
||||
});
|
||||
const result = await withJson(response);
|
||||
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) {
|
||||
setStatus(error.message || "Import failed", "danger", 7000);
|
||||
} finally {
|
||||
@@ -684,17 +765,31 @@ const setupVoiceMixer = () => {
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!previewBtn) return;
|
||||
const provider = normalizeProvider(state.draft.provider);
|
||||
const payload = {
|
||||
provider,
|
||||
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 : "",
|
||||
speed: speedInput ? parseFloat(speedInput.value || "1") : 1,
|
||||
max_seconds: 8,
|
||||
};
|
||||
if (provider === "kokoro") {
|
||||
const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0);
|
||||
if (!enabledVoices.length) {
|
||||
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.dataset.loading = "true";
|
||||
previewBtn.setAttribute("aria-busy", "true");
|
||||
@@ -724,7 +819,7 @@ const setupVoiceMixer = () => {
|
||||
} finally {
|
||||
previewBtn.disabled = false;
|
||||
previewBtn.dataset.loading = "false";
|
||||
previewBtn.textContent = previewBtn.dataset.label || "Preview mix";
|
||||
previewBtn.textContent = previewBtn.dataset.label || "Preview speaker";
|
||||
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) {
|
||||
voiceFilterSelect.addEventListener("change", () => {
|
||||
state.languageFilter = voiceFilterSelect.value;
|
||||
@@ -777,6 +916,13 @@ const setupVoiceMixer = () => {
|
||||
previewSpeedLabel.textContent = `${speed.toFixed(2)}×`;
|
||||
}
|
||||
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);
|
||||
updatePreviewSpeedLabel();
|
||||
|
||||
@@ -36,19 +36,36 @@
|
||||
<fieldset class="settings__section">
|
||||
<legend>Narration Defaults</legend>
|
||||
<div class="field">
|
||||
<label for="tts_provider">Default TTS Provider</label>
|
||||
<select id="tts_provider" name="tts_provider">
|
||||
<option value="kokoro" {% if (settings.tts_provider or 'kokoro') == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
||||
<option value="supertonic" {% if settings.tts_provider == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
||||
<label for="default_speaker">Default Speaker</label>
|
||||
<select id="default_speaker" name="default_speaker">
|
||||
<option value="" {% if not settings.default_speaker %}selected{% endif %}>Use fallback voice</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>
|
||||
<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 class="field field--wide">
|
||||
<p class="tag">Kokoro settings</p>
|
||||
</div>
|
||||
<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">
|
||||
<optgroup label="Standard voices">
|
||||
{% for voice in options.voices %}
|
||||
@@ -80,20 +97,12 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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 class="field field--wide">
|
||||
<p class="tag">Supertonic settings</p>
|
||||
<p class="hint">Supertonic does not use voice mixing in Abogen right now.</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>
|
||||
<p class="hint">These defaults apply when a Supertonic speaker does not override them.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="supertonic_total_steps">Supertonic Quality (total steps)</label>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<div class="voice-mixer__header">
|
||||
<div>
|
||||
<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 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="export-profiles">Export</button>
|
||||
</div>
|
||||
@@ -28,8 +28,8 @@
|
||||
<div class="voice-editor__meta">
|
||||
<div class="voice-editor__identity">
|
||||
<div class="field voice-editor__name-field">
|
||||
<label for="profile-name">Profile name</label>
|
||||
<input id="profile-name" name="name" type="text" placeholder="Narrator blend" required>
|
||||
<label for="profile-name">Speaker name</label>
|
||||
<input id="profile-name" name="name" type="text" placeholder="Narrator" required>
|
||||
</div>
|
||||
<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">
|
||||
@@ -49,6 +49,13 @@
|
||||
</button>
|
||||
</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">
|
||||
<label for="profile-language">Language</label>
|
||||
<select id="profile-language" name="language">
|
||||
@@ -57,12 +64,32 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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 class="voice-editor__summary">
|
||||
<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>
|
||||
</div>
|
||||
<div class="voice-editor__canvas">
|
||||
<div class="voice-editor__canvas" data-role="kokoro-mixer">
|
||||
<section class="voice-available">
|
||||
<header class="voice-available__header">
|
||||
<div class="voice-available__title">
|
||||
@@ -106,10 +133,10 @@
|
||||
</div>
|
||||
<div class="field field--slider">
|
||||
<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 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>
|
||||
</div>
|
||||
<audio data-role="preview-audio" controls preload="none"></audio>
|
||||
|
||||
Reference in New Issue
Block a user