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:
JB
2025-12-20 06:49:13 -08:00
parent 95d5921e67
commit 77eb58bdff
9 changed files with 528 additions and 94 deletions
+63 -8
View File
@@ -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
View File
@@ -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:
+1 -2
View File
@@ -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):
+52 -22
View File
@@ -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()
# Supertonic does not support Abogen voice mixing.
pending.voice_profile = None
pending.voice = narrator_voice_raw
# 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
# 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 = (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,
+12 -11
View File
@@ -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)
+5
View File
@@ -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()
+168 -22
View File
@@ -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,17 +138,56 @@ 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 profile";
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved speaker";
if (isSupertonic) {
profileSummaryEl.textContent = `${profileLabel} · Supertonic`;
} else {
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
}
}
}
};
@@ -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,16 +765,30 @@ 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,
};
const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0);
if (!enabledVoices.length) {
setStatus("Enable at least one voice to preview.", "warning");
return;
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";
@@ -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();
+25 -16
View File
@@ -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>
+34 -7
View File
@@ -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>