feat: Integrate Supertonic TTS provider with configuration options and UI updates; enhance voice handling and settings management

This commit is contained in:
JB
2025-12-20 06:20:33 -08:00
parent 08ebedc177
commit 95d5921e67
15 changed files with 418 additions and 117 deletions
+48 -10
View File
@@ -44,6 +44,7 @@ from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
from .service import Job, JobStatus
@@ -52,6 +53,20 @@ SPLIT_PATTERN = r"\n+"
SAMPLE_RATE = 24000
def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
raw = str(spec or "").strip()
if not raw:
raw = str(fallback or "").strip()
if not raw:
return "M1"
if "*" in raw or "+" in raw:
raw = str(fallback or "").strip() or "M1"
upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES:
return upper
return str(fallback or "").strip() or "M1"
class _JobCancelled(Exception):
"""Raised internally to abort a conversion when the client cancels."""
@@ -1371,7 +1386,8 @@ def run_conversion_job(job: Job) -> None:
override_token_map: Dict[str, str] = {}
try:
pipeline = _load_pipeline(job)
_initialize_voice_cache(job)
if getattr(job, "tts_provider", "kokoro") == "kokoro":
_initialize_voice_cache(job)
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
@@ -1491,8 +1507,9 @@ def run_conversion_job(job: Job) -> None:
base_voice_spec = _job_voice_fallback(job)
voice_cache: Dict[str, Any] = {}
if base_voice_spec and "*" not in base_voice_spec:
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
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)
processed_chars = 0
subtitle_index = 1
current_time = 0.0
@@ -1543,12 +1560,25 @@ def run_conversion_job(job: Job) -> None:
raise
local_segments = 0
for segment in pipeline(
normalized,
voice=voice_choice,
speed=job.speed,
split_pattern=split_pattern,
):
provider = getattr(job, "tts_provider", "kokoro")
if provider == "supertonic":
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
segment_iter = pipeline(
normalized,
voice=voice_name,
speed=job.speed,
split_pattern=split_pattern,
total_steps=getattr(job, "supertonic_total_steps", 5),
)
else:
segment_iter = pipeline(
normalized,
voice=voice_choice,
speed=job.speed,
split_pattern=split_pattern,
)
for segment in segment_iter:
canceller()
graphemes_raw = getattr(segment, "graphemes", "") or ""
graphemes = graphemes_raw.strip()
@@ -2066,7 +2096,7 @@ def run_conversion_job(job: Job) -> None:
pipeline = None
gc.collect()
try:
import torch
import torch # type: ignore[import-not-found]
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
@@ -2093,6 +2123,14 @@ def run_conversion_job(job: Job) -> None:
def _load_pipeline(job: Job):
cfg = load_config()
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower()
if provider == "supertonic":
return SupertonicPipeline(
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
)
device = "cpu"
if not disable_gpu:
device = _select_device()
+5
View File
@@ -71,6 +71,8 @@ def api_speaker_preview() -> ResponseReturnValue:
voice = payload.get("voice", "af_heart")
language = payload.get("language", "a")
speed = coerce_float(payload.get("speed"), 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)
@@ -82,6 +84,9 @@ def api_speaker_preview() -> ResponseReturnValue:
language=language,
speed=speed,
use_gpu=use_gpu
,
tts_provider=tts_provider or str(settings.get("tts_provider") or "kokoro"),
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
)
except Exception as e:
return jsonify({"error": str(e)}), 500
+10
View File
@@ -37,7 +37,17 @@ 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_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):
pass
try:
current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0)))))
except (TypeError, ValueError):
pass
current["output_format"] = (form.get("output_format") or "mp3").strip()
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip()
+8 -1
View File
@@ -5,12 +5,19 @@ def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]:
text = str(value or "").strip()
if not text:
return "", None
if text.lower().startswith("profile:"):
lowered = text.lower()
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
_, _, remainder = text.partition(":")
name = remainder.strip()
return "", name or None
return text, None
def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]:
"""Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:')."""
return split_profile_spec(value)
def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]:
if not paths:
return []
+77 -45
View File
@@ -574,54 +574,86 @@ def apply_book_step_form(
except ValueError:
pass
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()
provider_value = (form.get("tts_provider") or getattr(pending, "tts_provider", None) or settings.get("tts_provider") or "kokoro").strip().lower()
if provider_value not in {"kokoro", "supertonic"}:
provider_value = "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 settings.get("supertonic_total_steps")
or 5
)
except (TypeError, ValueError):
pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5)
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
narrator_voice_raw,
profiles=profiles_map,
)
if profile_selection in {"__standard", "", None} and inferred_profile:
profile_selection = inferred_profile
if profile_selection == "__formula":
profile_name = ""
custom_formula = custom_formula_raw
elif profile_selection in {"__standard", "", None}:
profile_name = ""
custom_formula = ""
else:
profile_name = profile_selection
custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL:
base_voice_spec = VOICES_INTERNAL[0]
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language,
base_voice_spec,
profile_name,
custom_formula,
profiles_map,
)
if resolved_language:
pending.language = resolved_language
if profile_selection == "__formula" and custom_formula_raw:
pending.voice = custom_formula_raw
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
elif profile_selection not in {"__standard", "", None, "__formula"}:
pending.voice_profile = selected_profile or profile_selection
pending.voice = voice_choice
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
else:
pending.voice_profile = None
fallback_voice = base_voice_spec or narrator_voice_raw
pending.voice = voice_choice or fallback_voice
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 {})
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
narrator_voice_raw,
profiles=profiles_map,
)
if profile_selection in {"__standard", "", None} and inferred_profile:
profile_selection = inferred_profile
if profile_selection == "__formula":
profile_name = ""
custom_formula = custom_formula_raw
elif profile_selection in {"__standard", "", None}:
profile_name = ""
custom_formula = ""
else:
profile_name = profile_selection
custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL:
base_voice_spec = VOICES_INTERNAL[0]
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language,
base_voice_spec,
profile_name,
custom_formula,
profiles_map,
)
if resolved_language:
pending.language = resolved_language
if profile_selection == "__formula" and custom_formula_raw:
pending.voice = custom_formula_raw
pending.voice_profile = None
elif profile_selection not in {"__standard", "", None, "__formula"}:
pending.voice_profile = selected_profile or profile_selection
pending.voice = voice_choice
else:
pending.voice_profile = None
fallback_voice = base_voice_spec or narrator_voice_raw
pending.voice = voice_choice or fallback_voice
pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
+41 -21
View File
@@ -31,26 +31,14 @@ def generate_preview_audio(
language: str,
speed: float,
use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0,
) -> bytes:
if not text.strip():
raise ValueError("Preview text is required")
device = "cpu"
if use_gpu:
try:
device = _select_device()
except Exception:
device = "cpu"
use_gpu = False
pipeline = get_preview_pipeline(language, device)
if pipeline is None:
raise RuntimeError("Preview pipeline is unavailable")
voice_choice: Any = voice_spec
if voice_spec and "*" in voice_spec:
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
provider = (tts_provider or "kokoro").strip().lower()
try:
normalized_text = normalize_for_pipeline(text)
@@ -58,12 +46,40 @@ def generate_preview_audio(
current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = text
segments = pipeline(
normalized_text,
voice=voice_choice,
speed=speed,
split_pattern=SPLIT_PATTERN,
)
if provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
segments = pipeline(
normalized_text,
voice=voice_spec,
speed=speed,
split_pattern=SPLIT_PATTERN,
total_steps=supertonic_total_steps,
)
else:
device = "cpu"
if use_gpu:
try:
device = _select_device()
except Exception:
device = "cpu"
use_gpu = False
pipeline = get_preview_pipeline(language, device)
if pipeline is None:
raise RuntimeError("Preview pipeline is unavailable")
voice_choice: Any = voice_spec
if voice_spec and "*" in voice_spec:
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
segments = pipeline(
normalized_text,
voice=voice_choice,
speed=speed,
split_pattern=SPLIT_PATTERN,
)
audio_chunks: List[np.ndarray] = []
accumulated = 0
@@ -100,6 +116,8 @@ def synthesize_preview(
language: str,
speed: float,
use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0,
) -> ResponseReturnValue:
try:
@@ -109,6 +127,8 @@ def synthesize_preview(
language=language,
speed=speed,
use_gpu=use_gpu,
tts_provider=tts_provider,
supertonic_total_steps=supertonic_total_steps,
max_seconds=max_seconds,
)
except Exception as e:
+2
View File
@@ -22,8 +22,10 @@ def submit_job(pending: PendingJob) -> str:
original_filename=pending.original_filename,
stored_path=pending.stored_path,
language=pending.language,
tts_provider=getattr(pending, "tts_provider", "kokoro"),
voice=pending.voice,
speed=pending.speed,
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5),
use_gpu=pending.use_gpu,
subtitle_mode=pending.subtitle_mode,
output_format=pending.output_format,
+24
View File
@@ -170,10 +170,14 @@ 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_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
"supertonic_default_voice": "M1",
"supertonic_total_steps": 5,
"supertonic_speed": 1.0,
"replace_single_newlines": False,
"use_gpu": True,
"save_chapters_separately": False,
@@ -340,6 +344,26 @@ 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)
except (TypeError, ValueError):
return defaults.get(key, 5)
return max(2, min(15, steps))
if key == "supertonic_speed":
try:
speed = float(value)
except (TypeError, ValueError):
return defaults.get(key, 1.0)
return max(0.7, min(2.0, speed))
return value if value is not None else defaults.get(key)
+14
View File
@@ -110,6 +110,8 @@ class Job:
replace_single_newlines: bool
subtitle_format: str
created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 5
save_chapters_separately: bool = False
merge_chapters_at_end: bool = True
separate_chapters_format: str = "wav"
@@ -201,6 +203,8 @@ class Job:
},
"queue_position": self.queue_position,
"options": {
"tts_provider": getattr(self, "tts_provider", "kokoro"),
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 5),
"save_chapters_separately": self.save_chapters_separately,
"merge_chapters_at_end": self.merge_chapters_at_end,
"separate_chapters_format": self.separate_chapters_format,
@@ -547,6 +551,8 @@ class PendingJob:
chapters: List[Dict[str, Any]]
normalization_overrides: Dict[str, Any]
created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 5
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5
@@ -614,6 +620,8 @@ class ConversionService:
language: str,
voice: str,
speed: float,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
use_gpu: bool,
subtitle_mode: str,
output_format: str,
@@ -665,6 +673,8 @@ class ConversionService:
language=language,
voice=voice,
speed=speed,
tts_provider=tts_provider,
supertonic_total_steps=int(supertonic_total_steps or 5),
use_gpu=use_gpu,
subtitle_mode=subtitle_mode,
output_format=output_format,
@@ -1134,8 +1144,10 @@ class ConversionService:
"original_filename": job.original_filename,
"stored_path": str(job.stored_path),
"language": job.language,
"tts_provider": getattr(job, "tts_provider", "kokoro"),
"voice": job.voice,
"speed": job.speed,
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 5),
"use_gpu": job.use_gpu,
"subtitle_mode": job.subtitle_mode,
"output_format": job.output_format,
@@ -1252,6 +1264,7 @@ class ConversionService:
original_filename=payload["original_filename"],
stored_path=stored_path,
language=payload.get("language", "a"),
tts_provider=str(payload.get("tts_provider") or "kokoro"),
voice=payload.get("voice", ""),
speed=float(payload.get("speed", 1.0)),
use_gpu=bool(payload.get("use_gpu", True)),
@@ -1262,6 +1275,7 @@ class ConversionService:
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
subtitle_format=payload.get("subtitle_format", "srt"),
created_at=float(payload.get("created_at", time.time())),
supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)),
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
+1 -1
View File
@@ -18,7 +18,7 @@
<nav class="top-actions">
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('main.index') }}" class="btn{% if endpoint == 'main.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Speaker Studio</a>
<a href="{{ url_for('entities.entities_page') }}" class="btn{% if endpoint == 'entities.entities_page' %} is-active{% endif %}">TTS Overrides</a>
<a href="{{ url_for('books.find_books_page') }}" class="btn{% if endpoint == 'books.find_books_page' %} is-active{% endif %}">Find Books</a>
<a href="{{ url_for('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.job_detail'] %} is-active{% endif %}">Queue</a>
+37 -3
View File
@@ -35,6 +35,18 @@
<section class="settings-panel is-active" data-section="narration">
<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>
</select>
<p class="hint">Applies to new jobs unless overridden in the job wizard.</p>
</div>
<div class="field field--wide">
<p class="tag">Kokoro settings</p>
</div>
<div class="field">
<label for="default_voice">Narrator Voice</label>
<select id="default_voice" name="default_voice">
@@ -44,9 +56,9 @@
{% endfor %}
</optgroup>
{% if options.voice_profile_options %}
<optgroup label="Saved mixes">
<optgroup label="Saved speakers">
{% for profile in options.voice_profile_options %}
{% set profile_value = 'profile:' ~ profile.name %}
{% set profile_value = 'speaker:' ~ profile.name %}
<option value="{{ profile_value }}" {% if settings.default_voice == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
@@ -58,7 +70,7 @@
{% set known_default.value = True %}
{% else %}
{% for profile in options.voice_profile_options %}
{% if current_default == 'profile:' ~ profile.name %}
{% if current_default == 'profile:' ~ profile.name or current_default == 'speaker:' ~ profile.name %}
{% set known_default.value = True %}
{% endif %}
{% endfor %}
@@ -70,6 +82,28 @@
</select>
<p class="hint">Used whenever “Standard voice” is selected for a new job.</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>
</div>
<div class="field">
<label for="supertonic_total_steps">Supertonic Quality (total steps)</label>
<input type="number" id="supertonic_total_steps" name="supertonic_total_steps" min="2" max="15" value="{{ settings.supertonic_total_steps }}">
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
</div>
<div class="field">
<label for="supertonic_speed">Supertonic Speed</label>
<input type="number" id="supertonic_speed" name="supertonic_speed" min="0.7" max="2.0" step="0.05" value="{{ '%.2f'|format(settings.supertonic_speed) }}">
</div>
<div class="field">
<label for="chunk_level_default">Chunk Granularity</label>
<select id="chunk_level_default" name="chunk_level">
+3 -3
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}abogen · Voice mixer{% endblock %}
{% block title %}abogen · Speaker Studio{% endblock %}
{% block content %}
<section class="card voice-mixer">
<div class="voice-mixer__header">
<div>
<h1 class="card__title">Voice mixer</h1>
<h1 class="card__title">Speaker Studio</h1>
<p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
</div>
<div class="voice-mixer__header-actions">
@@ -21,7 +21,7 @@
</aside>
<section class="voice-mixer__editor" data-role="editor">
<noscript>
<p class="tag">JavaScript is required for the mixer. Please enable it to edit profiles.</p>
<p class="tag">JavaScript is required for the studio. Please enable it to edit speakers.</p>
</noscript>
<form id="voice-profile-form" class="voice-editor" autocomplete="off">
<div class="voice-status" data-role="status"></div>