From 888c7372938240516f403a1b649b3fd3808eda3d Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 20 Dec 2025 07:12:19 -0800 Subject: [PATCH] feat: Add provider selection modal and enhance voice profile handling for Supertonic integration --- abogen/web/conversion_runner.py | 198 +++++++++++++++++++++++++---- abogen/web/routes/api.py | 7 +- abogen/web/routes/utils/preview.py | 51 ++++++-- abogen/web/static/voices.js | 142 +++++++++++++++++++-- abogen/web/templates/voices.html | 19 +++ 5 files changed, 375 insertions(+), 42 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 339d02f..27067d9 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -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": - _initialize_voice_cache(job) + # 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) - if voice_choice is None: - voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu) - voice_cache[chapter_voice_spec] = voice_choice + 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: + 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) - if chunk_voice_choice is None: - chunk_voice_choice = _resolve_voice( - pipeline, - chunk_voice_spec, - job.use_gpu, - ) - voice_cache[chunk_voice_spec] = chunk_voice_choice + 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( + kokoro_pipeline, + chunk_voice_resolved, + job.use_gpu, + ) + 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: diff --git a/abogen/web/routes/api.py b/abogen/web/routes/api.py index c99e148..54a30fc 100644 --- a/abogen/web/routes/api.py +++ b/abogen/web/routes/api.py @@ -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: diff --git a/abogen/web/routes/utils/preview.py b/abogen/web/routes/utils/preview.py index 2fe7f2d..b8eed4e 100644 --- a/abogen/web/routes/utils/preview.py +++ b/abogen/web/routes/utils/preview.py @@ -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,11 +67,15 @@ def generate_preview_audio( provider = (tts_provider or "kokoro").strip().lower() - try: - normalized_text = normalize_for_pipeline(text) - except Exception: - current_app.logger.exception("Preview normalization failed; using raw text") - normalized_text = text + 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") + normalized_text = text if provider == "supertonic": from abogen.tts_supertonic import SupertonicPipeline @@ -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( diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 70eec76..44b1ea1 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -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]; diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index f50b6bb..4f2f2bd 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -146,6 +146,25 @@ + + {% endblock %}