mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add provider selection modal and enhance voice profile handling for Supertonic integration
This commit is contained in:
+168
-18
@@ -42,6 +42,7 @@ from abogen.utils import (
|
||||
)
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||
from abogen.voice_profiles import load_profiles, normalize_profile_entry
|
||||
from abogen.pronunciation_store import increment_usage
|
||||
from abogen.llm_client import LLMClientError
|
||||
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
|
||||
@@ -67,6 +68,58 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
||||
return str(fallback or "").strip() or "M1"
|
||||
|
||||
|
||||
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
||||
raw = str(value or "").strip()
|
||||
if not raw or ":" not in raw:
|
||||
return None, raw
|
||||
prefix, remainder = raw.split(":", 1)
|
||||
prefix = prefix.strip().lower()
|
||||
if prefix not in {"speaker", "profile"}:
|
||||
return None, raw
|
||||
name = remainder.strip()
|
||||
return (name or None), raw
|
||||
|
||||
|
||||
def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return ""
|
||||
total = 0.0
|
||||
parts: list[tuple[str, float]] = []
|
||||
for item in voices:
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
name = str(item[0] or "").strip()
|
||||
try:
|
||||
weight = float(item[1])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not name or weight <= 0:
|
||||
continue
|
||||
parts.append((name, weight))
|
||||
total += weight
|
||||
if total <= 0 or not parts:
|
||||
return ""
|
||||
|
||||
def _format_weight(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
return "+".join(f"{name}*{_format_weight(weight)}" for name, weight in parts)
|
||||
|
||||
|
||||
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
upper = raw.upper()
|
||||
if upper in DEFAULT_SUPERTONIC_VOICES:
|
||||
return "supertonic"
|
||||
if "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
return fallback
|
||||
|
||||
|
||||
class _JobCancelled(Exception):
|
||||
"""Raised internally to abort a conversion when the client cancels."""
|
||||
|
||||
@@ -1380,14 +1433,76 @@ def run_conversion_job(job: Job) -> None:
|
||||
audio_output_path: Optional[Path] = None
|
||||
extraction: Optional[Any] = None
|
||||
pipeline: Any = None
|
||||
pipelines: Dict[str, Any] = {}
|
||||
kokoro_cache_ready = False
|
||||
normalized_profiles: Dict[str, Dict[str, Any]] = {}
|
||||
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
|
||||
active_chapter_configs: List[Dict[str, Any]] = []
|
||||
usage_counter: Dict[str, int] = defaultdict(int)
|
||||
override_token_map: Dict[str, str] = {}
|
||||
try:
|
||||
pipeline = _load_pipeline(job)
|
||||
if getattr(job, "tts_provider", "kokoro") == "kokoro":
|
||||
# Load saved speakers once so we can resolve speaker: references during conversion.
|
||||
try:
|
||||
profiles = load_profiles()
|
||||
except Exception:
|
||||
profiles = {}
|
||||
for name, entry in (profiles or {}).items():
|
||||
normalized = normalize_profile_entry(entry)
|
||||
if normalized:
|
||||
normalized_profiles[str(name)] = normalized
|
||||
|
||||
def get_pipeline(provider: str) -> Any:
|
||||
nonlocal kokoro_cache_ready
|
||||
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||
if provider_norm not in {"kokoro", "supertonic"}:
|
||||
provider_norm = "kokoro"
|
||||
|
||||
existing = pipelines.get(provider_norm)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
if provider_norm == "supertonic":
|
||||
pipelines[provider_norm] = SupertonicPipeline(
|
||||
sample_rate=SAMPLE_RATE,
|
||||
auto_download=True,
|
||||
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||
)
|
||||
return pipelines[provider_norm]
|
||||
|
||||
# Kokoro
|
||||
cfg = load_config()
|
||||
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
||||
device = "cpu"
|
||||
if not disable_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
if not kokoro_cache_ready:
|
||||
_initialize_voice_cache(job)
|
||||
kokoro_cache_ready = True
|
||||
return pipelines[provider_norm]
|
||||
|
||||
def resolve_voice_target(raw_spec: str) -> tuple[str, str, Optional[float], Optional[int]]:
|
||||
"""Return (provider, voice_spec, speed_override, steps_override)."""
|
||||
spec = str(raw_spec or "").strip()
|
||||
speaker_name, _ = _split_speaker_reference(spec)
|
||||
if speaker_name and speaker_name in normalized_profiles:
|
||||
entry = normalized_profiles[speaker_name]
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
||||
if provider == "supertonic":
|
||||
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
||||
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5)
|
||||
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
||||
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
||||
formula = _formula_from_kokoro_entry(entry)
|
||||
return "kokoro", formula or spec, None, None
|
||||
|
||||
fallback_provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
|
||||
inferred = _infer_provider_from_spec(spec, fallback=fallback_provider)
|
||||
if inferred == "supertonic":
|
||||
return "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None
|
||||
return "kokoro", spec, None, None
|
||||
|
||||
extraction = extract_from_path(job.stored_path)
|
||||
file_type = _infer_file_type(job.stored_path)
|
||||
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
|
||||
@@ -1507,9 +1622,10 @@ def run_conversion_job(job: Job) -> None:
|
||||
|
||||
base_voice_spec = _job_voice_fallback(job)
|
||||
voice_cache: Dict[str, Any] = {}
|
||||
if getattr(job, "tts_provider", "kokoro") == "kokoro":
|
||||
if base_voice_spec and "*" not in base_voice_spec:
|
||||
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
|
||||
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
||||
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
||||
kokoro_pipeline = get_pipeline("kokoro")
|
||||
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
|
||||
processed_chars = 0
|
||||
subtitle_index = 1
|
||||
current_time = 0.0
|
||||
@@ -1538,6 +1654,9 @@ def run_conversion_job(job: Job) -> None:
|
||||
chapter_sink: Optional[AudioSink],
|
||||
preview_prefix: Optional[str] = None,
|
||||
split_pattern: Optional[str] = SPLIT_PATTERN,
|
||||
tts_provider: Optional[str] = None,
|
||||
speed_override: Optional[float] = None,
|
||||
supertonic_steps_override: Optional[int] = None,
|
||||
) -> int:
|
||||
nonlocal processed_chars, subtitle_index, current_time
|
||||
source_text = str(text or "")
|
||||
@@ -1560,21 +1679,23 @@ def run_conversion_job(job: Job) -> None:
|
||||
raise
|
||||
local_segments = 0
|
||||
|
||||
provider = getattr(job, "tts_provider", "kokoro")
|
||||
provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
|
||||
if provider == "supertonic":
|
||||
supertonic_pipeline = get_pipeline("supertonic")
|
||||
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
|
||||
segment_iter = pipeline(
|
||||
segment_iter = supertonic_pipeline(
|
||||
normalized,
|
||||
voice=voice_name,
|
||||
speed=job.speed,
|
||||
speed=float(speed_override if speed_override is not None else job.speed),
|
||||
split_pattern=split_pattern,
|
||||
total_steps=getattr(job, "supertonic_total_steps", 5),
|
||||
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
||||
)
|
||||
else:
|
||||
segment_iter = pipeline(
|
||||
kokoro_pipeline = get_pipeline("kokoro")
|
||||
segment_iter = kokoro_pipeline(
|
||||
normalized,
|
||||
voice=voice_choice,
|
||||
speed=job.speed,
|
||||
speed=float(speed_override if speed_override is not None else job.speed),
|
||||
split_pattern=split_pattern,
|
||||
)
|
||||
|
||||
@@ -1655,10 +1776,16 @@ def run_conversion_job(job: Job) -> None:
|
||||
if not chapter_voice_spec:
|
||||
chapter_voice_spec = base_voice_spec
|
||||
|
||||
voice_choice = voice_cache.get(chapter_voice_spec)
|
||||
chapter_provider, chapter_voice_resolved, chapter_speed, chapter_steps = resolve_voice_target(chapter_voice_spec)
|
||||
chapter_cache_key = f"{chapter_provider}:{chapter_voice_resolved}" if chapter_voice_resolved else chapter_provider
|
||||
if chapter_provider == "kokoro":
|
||||
voice_choice = voice_cache.get(chapter_cache_key)
|
||||
if voice_choice is None:
|
||||
voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu)
|
||||
voice_cache[chapter_voice_spec] = voice_choice
|
||||
kokoro_pipeline = get_pipeline("kokoro")
|
||||
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
|
||||
voice_cache[chapter_cache_key] = voice_choice
|
||||
else:
|
||||
voice_choice = chapter_voice_resolved
|
||||
|
||||
chapter_audio_path: Optional[Path] = None
|
||||
segments_emitted = 0
|
||||
@@ -1694,6 +1821,9 @@ def run_conversion_job(job: Job) -> None:
|
||||
voice_choice=voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_prefix="Book intro",
|
||||
tts_provider=chapter_provider,
|
||||
speed_override=chapter_speed,
|
||||
supertonic_steps_override=chapter_steps,
|
||||
)
|
||||
intro_emitted = True
|
||||
if intro_segments > 0 and job.chapter_intro_delay > 0:
|
||||
@@ -1710,6 +1840,9 @@ def run_conversion_job(job: Job) -> None:
|
||||
chapter_sink=chapter_sink,
|
||||
preview_prefix=f"Chapter {idx} title",
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
tts_provider=chapter_provider,
|
||||
speed_override=chapter_speed,
|
||||
supertonic_steps_override=chapter_steps,
|
||||
)
|
||||
segments_emitted += heading_segments
|
||||
if heading_segments > 0 and job.chapter_intro_delay > 0:
|
||||
@@ -1782,16 +1915,26 @@ def run_conversion_job(job: Job) -> None:
|
||||
chunk_voice_spec = chapter_voice_spec or base_voice_spec
|
||||
|
||||
if chunk_voice_spec == chapter_voice_spec:
|
||||
chunk_provider = chapter_provider
|
||||
chunk_voice_resolved = chapter_voice_resolved
|
||||
chunk_speed_use = chapter_speed
|
||||
chunk_steps_use = chapter_steps
|
||||
chunk_voice_choice = voice_choice
|
||||
else:
|
||||
chunk_voice_choice = voice_cache.get(chunk_voice_spec)
|
||||
chunk_provider, chunk_voice_resolved, chunk_speed_use, chunk_steps_use = resolve_voice_target(chunk_voice_spec)
|
||||
chunk_cache_key = f"{chunk_provider}:{chunk_voice_resolved}" if chunk_voice_resolved else chunk_provider
|
||||
if chunk_provider == "kokoro":
|
||||
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
||||
if chunk_voice_choice is None:
|
||||
kokoro_pipeline = get_pipeline("kokoro")
|
||||
chunk_voice_choice = _resolve_voice(
|
||||
pipeline,
|
||||
chunk_voice_spec,
|
||||
kokoro_pipeline,
|
||||
chunk_voice_resolved,
|
||||
job.use_gpu,
|
||||
)
|
||||
voice_cache[chunk_voice_spec] = chunk_voice_choice
|
||||
voice_cache[chunk_cache_key] = chunk_voice_choice
|
||||
else:
|
||||
chunk_voice_choice = chunk_voice_resolved
|
||||
|
||||
chunk_start = current_time
|
||||
emitted = emit_text(
|
||||
@@ -1799,6 +1942,9 @@ def run_conversion_job(job: Job) -> None:
|
||||
voice_choice=chunk_voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}",
|
||||
tts_provider=chunk_provider,
|
||||
speed_override=chunk_speed_use,
|
||||
supertonic_steps_override=chunk_steps_use,
|
||||
)
|
||||
if emitted <= 0:
|
||||
continue
|
||||
@@ -1851,6 +1997,9 @@ def run_conversion_job(job: Job) -> None:
|
||||
chapter_text,
|
||||
voice_choice=voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
tts_provider=chapter_provider,
|
||||
speed_override=chapter_speed,
|
||||
supertonic_steps_override=chapter_steps,
|
||||
)
|
||||
if emitted > 0:
|
||||
segments_emitted += emitted
|
||||
@@ -2093,6 +2242,7 @@ def run_conversion_job(job: Job) -> None:
|
||||
|
||||
# Explicitly release the pipeline and force garbage collection to prevent
|
||||
# memory accumulation in the worker process, which can lead to host lockups.
|
||||
pipelines.clear()
|
||||
pipeline = None
|
||||
gc.collect()
|
||||
try:
|
||||
|
||||
@@ -166,7 +166,12 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
|
||||
resolved_provider = provider or "kokoro"
|
||||
|
||||
profiles = load_profiles()
|
||||
if profile_name:
|
||||
if resolved_provider == "supertonic" and not profile_name:
|
||||
voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
|
||||
# Allow per-speaker overrides via payload.
|
||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
|
||||
speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
|
||||
elif profile_name:
|
||||
entry = profiles.get(profile_name)
|
||||
normalized_entry = normalize_profile_entry(entry)
|
||||
if not normalized_entry:
|
||||
|
||||
@@ -6,20 +6,47 @@ import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
from abogen.web.conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
|
||||
_, KPipeline = load_numpy_kpipeline()
|
||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
@@ -40,7 +67,11 @@ def generate_preview_audio(
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
normalized_text = text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
@@ -72,6 +103,8 @@ def generate_preview_audio(
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
|
||||
+134
-8
@@ -24,6 +24,7 @@ const setupVoiceMixer = () => {
|
||||
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
||||
const nameInput = document.getElementById("profile-name");
|
||||
const languageSelect = document.getElementById("profile-language");
|
||||
const languageField = app.querySelector(".voice-editor__language");
|
||||
const providerSelect = document.getElementById("profile-provider");
|
||||
const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]');
|
||||
const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]');
|
||||
@@ -40,6 +41,13 @@ const setupVoiceMixer = () => {
|
||||
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
|
||||
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
|
||||
|
||||
const providerPickerModal = document.querySelector('[data-role="provider-picker-modal"]');
|
||||
const providerPickerOverlay = document.querySelector('[data-role="provider-picker-overlay"]');
|
||||
const providerPickerClose = document.querySelector('[data-role="provider-picker-close"]');
|
||||
const providerPickerCancel = document.querySelector('[data-role="provider-picker-cancel"]');
|
||||
const providerPickerConfirm = document.querySelector('[data-role="provider-picker-confirm"]');
|
||||
const providerPickerOptions = document.querySelector('[data-role="provider-picker-options"]');
|
||||
|
||||
if (previewBtn && !previewBtn.dataset.label) {
|
||||
previewBtn.dataset.label = previewBtn.textContent.trim();
|
||||
}
|
||||
@@ -143,12 +151,123 @@ const setupVoiceMixer = () => {
|
||||
return candidate === "supertonic" ? "supertonic" : "kokoro";
|
||||
};
|
||||
|
||||
const getProviderCatalog = () => {
|
||||
if (!providerSelect) {
|
||||
return [
|
||||
{ id: "kokoro", label: "Kokoro" },
|
||||
{ id: "supertonic", label: "Supertonic" },
|
||||
];
|
||||
}
|
||||
return Array.from(providerSelect.options || []).map((option) => ({
|
||||
id: normalizeProvider(option.value),
|
||||
label: option.textContent?.trim() || option.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const providerDescription = (providerId) => {
|
||||
const provider = normalizeProvider(providerId);
|
||||
if (provider === "supertonic") {
|
||||
return "Voice selection + quality/speed per speaker.";
|
||||
}
|
||||
return "Voice mixing supported via the Kokoro mixer.";
|
||||
};
|
||||
|
||||
const openProviderPicker = (defaultProvider = "kokoro") => {
|
||||
if (!providerPickerModal || !providerPickerOptions || !providerPickerConfirm) {
|
||||
return Promise.resolve(normalizeProvider(defaultProvider));
|
||||
}
|
||||
|
||||
providerPickerOptions.innerHTML = "";
|
||||
const catalog = getProviderCatalog();
|
||||
const normalizedDefault = normalizeProvider(defaultProvider);
|
||||
|
||||
catalog.forEach((item) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "toggle-pill";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "provider-picker";
|
||||
input.value = item.id;
|
||||
input.checked = item.id === normalizedDefault;
|
||||
|
||||
const span = document.createElement("span");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.label;
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "muted";
|
||||
detail.textContent = ` — ${providerDescription(item.id)}`;
|
||||
span.appendChild(title);
|
||||
span.appendChild(detail);
|
||||
|
||||
label.appendChild(input);
|
||||
label.appendChild(span);
|
||||
providerPickerOptions.appendChild(label);
|
||||
});
|
||||
|
||||
const selectedRadio = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||
providerPickerConfirm.disabled = !selectedRadio;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const teardown = () => {
|
||||
providerPickerModal.hidden = true;
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
providerPickerOptions.removeEventListener("change", onChange);
|
||||
providerPickerOverlay?.removeEventListener("click", onCancel);
|
||||
providerPickerClose?.removeEventListener("click", onCancel);
|
||||
providerPickerCancel?.removeEventListener("click", onCancel);
|
||||
providerPickerConfirm?.removeEventListener("click", onConfirm);
|
||||
};
|
||||
|
||||
const finish = (value) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
teardown();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onCancel = () => finish(null);
|
||||
const onConfirm = () => {
|
||||
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||
finish(selected ? normalizeProvider(selected.value) : null);
|
||||
};
|
||||
const onChange = () => {
|
||||
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||
providerPickerConfirm.disabled = !selected;
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
providerPickerModal.hidden = false;
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
providerPickerOptions.addEventListener("change", onChange);
|
||||
providerPickerOverlay?.addEventListener("click", onCancel);
|
||||
providerPickerClose?.addEventListener("click", onCancel);
|
||||
providerPickerCancel?.addEventListener("click", onCancel);
|
||||
providerPickerConfirm?.addEventListener("click", onConfirm);
|
||||
|
||||
const focusTarget = providerPickerOptions.querySelector('input[name="provider-picker"]:checked')
|
||||
|| providerPickerOptions.querySelector('input[name="provider-picker"]');
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyProviderToUI = () => {
|
||||
const provider = normalizeProvider(state.draft.provider);
|
||||
const isSupertonic = provider === "supertonic";
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
}
|
||||
if (languageField) {
|
||||
languageField.hidden = isSupertonic;
|
||||
}
|
||||
if (kokoroMixerEl) {
|
||||
kokoroMixerEl.hidden = isSupertonic;
|
||||
}
|
||||
@@ -588,13 +707,12 @@ const setupVoiceMixer = () => {
|
||||
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
|
||||
};
|
||||
|
||||
const createNewProfile = () => {
|
||||
const isSupertonic = window.confirm("Create a Supertonic speaker?\n\nOK = Supertonic\nCancel = Kokoro");
|
||||
const startNewProfile = (provider = "kokoro") => {
|
||||
state.selectedProfile = null;
|
||||
state.originalName = null;
|
||||
state.draft = {
|
||||
name: "",
|
||||
provider: isSupertonic ? "supertonic" : "kokoro",
|
||||
provider: normalizeProvider(provider),
|
||||
language: languageSelect ? languageSelect.value || "a" : "a",
|
||||
voices: new Map(),
|
||||
supertonic: {
|
||||
@@ -608,6 +726,15 @@ const setupVoiceMixer = () => {
|
||||
loadSampleText();
|
||||
};
|
||||
|
||||
const requestNewProfile = async () => {
|
||||
const chosen = await openProviderPicker(normalizeProvider(state.draft.provider));
|
||||
if (!chosen) {
|
||||
return;
|
||||
}
|
||||
startNewProfile(chosen);
|
||||
setStatus("New speaker ready.", "info");
|
||||
};
|
||||
|
||||
const refreshProfiles = (nextProfiles, selectedName = null) => {
|
||||
profiles = nextProfiles || {};
|
||||
renderProfileList();
|
||||
@@ -620,7 +747,7 @@ const setupVoiceMixer = () => {
|
||||
if (names.length) {
|
||||
selectProfile(names[0]);
|
||||
} else {
|
||||
createNewProfile();
|
||||
startNewProfile("kokoro");
|
||||
}
|
||||
}
|
||||
updateActionButtons();
|
||||
@@ -657,7 +784,7 @@ const setupVoiceMixer = () => {
|
||||
name,
|
||||
originalName: state.originalName,
|
||||
provider: normalizeProvider(state.draft.provider),
|
||||
language: languageSelect ? languageSelect.value : "a",
|
||||
language: normalizeProvider(state.draft.provider) === "kokoro" ? (languageSelect ? languageSelect.value : "a") : "a",
|
||||
voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [],
|
||||
voice: state.draft.supertonic?.voice,
|
||||
total_steps: state.draft.supertonic?.total_steps,
|
||||
@@ -964,8 +1091,7 @@ const setupVoiceMixer = () => {
|
||||
const action = target.dataset.action;
|
||||
if (!action) return;
|
||||
if (action === "new-profile") {
|
||||
createNewProfile();
|
||||
setStatus("New profile ready.", "info");
|
||||
requestNewProfile();
|
||||
} else if (action === "import-profiles") {
|
||||
importInput?.click();
|
||||
} else if (action === "export-profiles") {
|
||||
@@ -1023,7 +1149,7 @@ const setupVoiceMixer = () => {
|
||||
|
||||
renderAvailableVoices();
|
||||
renderProfileList();
|
||||
createNewProfile();
|
||||
startNewProfile("kokoro");
|
||||
|
||||
if (Object.keys(profiles).length) {
|
||||
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
|
||||
|
||||
@@ -146,6 +146,25 @@
|
||||
</section>
|
||||
</div>
|
||||
<input id="voice-import-input" type="file" accept="application/json" hidden>
|
||||
|
||||
<div class="modal" data-role="provider-picker-modal" hidden>
|
||||
<div class="modal__overlay" data-role="provider-picker-overlay" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="provider-picker-title">
|
||||
<header class="modal__header">
|
||||
<p class="modal__eyebrow">Speaker Studio</p>
|
||||
<h2 class="modal__title" id="provider-picker-title">Choose a TTS provider</h2>
|
||||
<button type="button" class="button button--ghost button--small" data-role="provider-picker-close" aria-label="Close provider picker">Close</button>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<p class="hint">Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.</p>
|
||||
<div class="field field--choices" data-role="provider-picker-options"></div>
|
||||
</div>
|
||||
<footer class="modal__footer">
|
||||
<button type="button" class="button button--ghost" data-role="provider-picker-cancel">Cancel</button>
|
||||
<button type="button" class="button" data-role="provider-picker-confirm">Continue</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user