From 77eb58bdfffe4e77bfa622a5b96c5e88cd8a6bf4 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 20 Dec 2025 06:49:13 -0800 Subject: [PATCH] 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. --- abogen/voice_profiles.py | 71 +++++++++-- abogen/web/routes/api.py | 174 ++++++++++++++++++++++++- abogen/web/routes/settings.py | 3 +- abogen/web/routes/utils/form.py | 74 +++++++---- abogen/web/routes/utils/settings.py | 23 ++-- abogen/web/routes/utils/voice.py | 5 + abogen/web/static/voices.js | 190 ++++++++++++++++++++++++---- abogen/web/templates/settings.html | 41 +++--- abogen/web/templates/voices.html | 41 +++++- 9 files changed, 528 insertions(+), 94 deletions(-) diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 4f87820..dede121 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -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: diff --git a/abogen/web/routes/api.py b/abogen/web/routes/api.py index bffc507..c99e148 100644 --- a/abogen/web/routes/api.py +++ b/abogen/web/routes/api.py @@ -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/") 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//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,12 +213,31 @@ 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( @@ -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: diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index b1e0b44..a04faf1 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -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): diff --git a/abogen/web/routes/utils/form.py b/abogen/web/routes/utils/form.py index e9cb8ab..95e1ee2 100644 --- a/abogen/web/routes/utils/form.py +++ b/abogen/web/routes/utils/form.py @@ -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, diff --git a/abogen/web/routes/utils/settings.py b/abogen/web/routes/utils/settings.py index f428191..9b15845 100644 --- a/abogen/web/routes/utils/settings.py +++ b/abogen/web/routes/utils/settings.py @@ -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) diff --git a/abogen/web/routes/utils/voice.py b/abogen/web/routes/utils/voice.py index 4407864..b3f56fe 100644 --- a/abogen/web/routes/utils/voice.py +++ b/abogen/web/routes/utils/voice.py @@ -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() diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 1176a0a..70eec76 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -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 = ` ${name} - ${voiceLanguageLabel(profile.language || "a")} + ${providerLabel} ${voiceLanguageLabel(profile.language || "a")} `; 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(); diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 98e53cb..7981834 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -36,19 +36,36 @@
Narration Defaults
- - + + {% if options.voice_profile_options %} + {% for profile in options.voice_profile_options %} + {% set profile_value = 'speaker:' ~ profile.name %} + + {% 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 %} + + {% endif %} + {% endif %} -

Applies to new jobs unless overridden in the job wizard.

+

Pick a saved speaker from Speaker Studio to use by default for new jobs.

Kokoro settings

- + -

Used whenever “Standard voice” is selected for a new job.

+

Used when no default speaker is selected, and as a fallback when speaker analysis cannot resolve a speaker.

Supertonic settings

-

Supertonic does not use voice mixing in Abogen right now.

-
-
- - +

These defaults apply when a Supertonic speaker does not override them.

diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index b85995a..f50b6bb 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -7,10 +7,10 @@

Speaker Studio

-

Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.

+

Create and manage speakers for Kokoro and Supertonic.

- +
@@ -28,8 +28,8 @@
- - + +
+
+ + +
+
Select or create a profile to begin. Total weight: 0.00
-
+
@@ -106,10 +133,10 @@
- +
- +