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
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any, Iterable, Iterator, Optional
import numpy as np
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
@dataclass
class SupertonicSegment:
graphemes: str
audio: np.ndarray
def _ensure_float32_mono(wav: Any) -> np.ndarray:
arr = np.asarray(wav, dtype="float32")
if arr.ndim == 2:
# (n, 1) or (1, n) or (n, channels)
if arr.shape[0] == 1 and arr.shape[1] > 1:
arr = arr.reshape(-1)
else:
arr = arr[:, 0]
return arr.reshape(-1)
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
if src_rate == dst_rate:
return audio
if audio.size == 0:
return audio
ratio = dst_rate / float(src_rate)
new_len = int(round(audio.size * ratio))
if new_len <= 1:
return np.zeros(0, dtype="float32")
x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
return np.interp(x_new, x_old, audio).astype("float32", copy=False)
def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: int) -> list[str]:
stripped = (text or "").strip()
if not stripped:
return []
parts: list[str]
if split_pattern:
try:
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
except re.error:
parts = [stripped]
else:
parts = [stripped]
# Enforce max length by hard-splitting long parts.
result: list[str] = []
for part in parts:
if len(part) <= max_chunk_length:
result.append(part)
continue
start = 0
while start < len(part):
end = min(len(part), start + max_chunk_length)
# Try to split at whitespace.
if end < len(part):
ws = part.rfind(" ", start, end)
if ws > start + 40:
end = ws
chunk = part[start:end].strip()
if chunk:
result.append(chunk)
start = end
return result
class SupertonicPipeline:
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
def __init__(
self,
*,
sample_rate: int,
auto_download: bool = True,
total_steps: int = 5,
max_chunk_length: int = 300,
) -> None:
self.sample_rate = int(sample_rate)
self.total_steps = int(total_steps)
self.max_chunk_length = int(max_chunk_length)
try:
from supertonic import TTS # type: ignore[import-not-found]
except Exception as exc: # pragma: no cover
raise RuntimeError(
"Supertonic is not installed. Install it with `pip install supertonic`."
) from exc
self._tts = TTS(auto_download=auto_download)
def __call__(
self,
text: str,
*,
voice: str,
speed: float,
split_pattern: Optional[str] = None,
total_steps: Optional[int] = None,
) -> Iterator[SupertonicSegment]:
voice_name = (voice or "").strip() or "M1"
steps = int(total_steps) if total_steps is not None else self.total_steps
steps = max(2, min(15, steps))
speed_value = float(speed) if speed is not None else 1.0
speed_value = max(0.7, min(2.0, speed_value))
style = self._tts.get_voice_style(voice_name=voice_name)
chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length)
for chunk in chunks:
wav, duration = self._tts.synthesize(
text=chunk,
voice_style=style,
total_steps=steps,
speed=speed_value,
max_chunk_length=self.max_chunk_length,
silence_duration=0.0,
verbose=False,
)
audio = _ensure_float32_mono(wav)
# If duration is present, infer the source sample rate and resample if needed.
src_rate = self.sample_rate
try:
dur = float(duration)
if dur > 0 and audio.size > 0:
inferred = int(round(audio.size / dur))
if 8000 <= inferred <= 96000:
src_rate = inferred
except Exception:
pass
if src_rate != self.sample_rate:
audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk, audio=audio)
+41 -3
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.voice_formulas import extract_voice_ids, get_new_voice
from abogen.pronunciation_store import increment_usage from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError from abogen.llm_client import LLMClientError
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
from .service import Job, JobStatus from .service import Job, JobStatus
@@ -52,6 +53,20 @@ SPLIT_PATTERN = r"\n+"
SAMPLE_RATE = 24000 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): class _JobCancelled(Exception):
"""Raised internally to abort a conversion when the client cancels.""" """Raised internally to abort a conversion when the client cancels."""
@@ -1371,6 +1386,7 @@ def run_conversion_job(job: Job) -> None:
override_token_map: Dict[str, str] = {} override_token_map: Dict[str, str] = {}
try: try:
pipeline = _load_pipeline(job) pipeline = _load_pipeline(job)
if getattr(job, "tts_provider", "kokoro") == "kokoro":
_initialize_voice_cache(job) _initialize_voice_cache(job)
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path) file_type = _infer_file_type(job.stored_path)
@@ -1491,6 +1507,7 @@ def run_conversion_job(job: Job) -> None:
base_voice_spec = _job_voice_fallback(job) base_voice_spec = _job_voice_fallback(job)
voice_cache: Dict[str, Any] = {} voice_cache: Dict[str, Any] = {}
if getattr(job, "tts_provider", "kokoro") == "kokoro":
if base_voice_spec and "*" not in base_voice_spec: if base_voice_spec and "*" not in base_voice_spec:
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu) voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
processed_chars = 0 processed_chars = 0
@@ -1543,12 +1560,25 @@ def run_conversion_job(job: Job) -> None:
raise raise
local_segments = 0 local_segments = 0
for segment in pipeline( 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, normalized,
voice=voice_choice, voice=voice_choice,
speed=job.speed, speed=job.speed,
split_pattern=split_pattern, split_pattern=split_pattern,
): )
for segment in segment_iter:
canceller() canceller()
graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes_raw = getattr(segment, "graphemes", "") or ""
graphemes = graphemes_raw.strip() graphemes = graphemes_raw.strip()
@@ -2066,7 +2096,7 @@ def run_conversion_job(job: Job) -> None:
pipeline = None pipeline = None
gc.collect() gc.collect()
try: try:
import torch import torch # type: ignore[import-not-found]
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
except ImportError: except ImportError:
@@ -2093,6 +2123,14 @@ def run_conversion_job(job: Job) -> None:
def _load_pipeline(job: Job): def _load_pipeline(job: Job):
cfg = load_config() cfg = load_config()
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) 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" device = "cpu"
if not disable_gpu: if not disable_gpu:
device = _select_device() device = _select_device()
+5
View File
@@ -71,6 +71,8 @@ def api_speaker_preview() -> ResponseReturnValue:
voice = payload.get("voice", "af_heart") voice = payload.get("voice", "af_heart")
language = payload.get("language", "a") language = payload.get("language", "a")
speed = coerce_float(payload.get("speed"), 1.0) 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() settings = load_settings()
use_gpu = settings.get("use_gpu", False) use_gpu = settings.get("use_gpu", False)
@@ -82,6 +84,9 @@ def api_speaker_preview() -> ResponseReturnValue:
language=language, language=language,
speed=speed, speed=speed,
use_gpu=use_gpu 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: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
+10
View File
@@ -37,7 +37,17 @@ def update_settings() -> ResponseReturnValue:
# General settings # General settings
current["language"] = (form.get("language") or "en").strip() 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["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["output_format"] = (form.get("output_format") or "mp3").strip()
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip() current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
current["subtitle_format"] = (form.get("subtitle_format") or "srt").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() text = str(value or "").strip()
if not text: if not text:
return "", None return "", None
if text.lower().startswith("profile:"): lowered = text.lower()
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
_, _, remainder = text.partition(":") _, _, remainder = text.partition(":")
name = remainder.strip() name = remainder.strip()
return "", name or None return "", name or None
return text, 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]: def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]:
if not paths: if not paths:
return [] return []
+32
View File
@@ -574,6 +574,38 @@ def apply_book_step_form(
except ValueError: except ValueError:
pass pass
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)
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
else:
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
custom_formula_raw = (form.get("voice_formula") or "").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() narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip()
+26 -6
View File
@@ -31,11 +31,33 @@ def generate_preview_audio(
language: str, language: str,
speed: float, speed: float,
use_gpu: bool, use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0, max_seconds: float = 8.0,
) -> bytes: ) -> bytes:
if not text.strip(): if not text.strip():
raise ValueError("Preview text is required") raise ValueError("Preview text is required")
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
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" device = "cpu"
if use_gpu: if use_gpu:
try: try:
@@ -52,12 +74,6 @@ def generate_preview_audio(
if voice_spec and "*" in voice_spec: if voice_spec and "*" in voice_spec:
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
try:
normalized_text = normalize_for_pipeline(text)
except Exception:
current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = text
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_choice, voice=voice_choice,
@@ -100,6 +116,8 @@ def synthesize_preview(
language: str, language: str,
speed: float, speed: float,
use_gpu: bool, use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0, max_seconds: float = 8.0,
) -> ResponseReturnValue: ) -> ResponseReturnValue:
try: try:
@@ -109,6 +127,8 @@ def synthesize_preview(
language=language, language=language,
speed=speed, speed=speed,
use_gpu=use_gpu, use_gpu=use_gpu,
tts_provider=tts_provider,
supertonic_total_steps=supertonic_total_steps,
max_seconds=max_seconds, max_seconds=max_seconds,
) )
except Exception as e: except Exception as e:
+2
View File
@@ -22,8 +22,10 @@ def submit_job(pending: PendingJob) -> str:
original_filename=pending.original_filename, original_filename=pending.original_filename,
stored_path=pending.stored_path, stored_path=pending.stored_path,
language=pending.language, language=pending.language,
tts_provider=getattr(pending, "tts_provider", "kokoro"),
voice=pending.voice, voice=pending.voice,
speed=pending.speed, speed=pending.speed,
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5),
use_gpu=pending.use_gpu, use_gpu=pending.use_gpu,
subtitle_mode=pending.subtitle_mode, subtitle_mode=pending.subtitle_mode,
output_format=pending.output_format, output_format=pending.output_format,
+24
View File
@@ -170,10 +170,14 @@ def has_output_override() -> bool:
def settings_defaults() -> Dict[str, Any]: def settings_defaults() -> Dict[str, Any]:
llm_env_defaults = environment_llm_defaults() llm_env_defaults = environment_llm_defaults()
return { return {
"tts_provider": "kokoro",
"output_format": "wav", "output_format": "wav",
"subtitle_format": "srt", "subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input", "save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", "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, "replace_single_newlines": False,
"use_gpu": True, "use_gpu": True,
"save_chapters_separately": False, "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()] parts = [item.strip().lower() for item in value.split(",") if item.strip()]
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
return defaults.get(key, []) 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) return value if value is not None else defaults.get(key)
+14
View File
@@ -110,6 +110,8 @@ class Job:
replace_single_newlines: bool replace_single_newlines: bool
subtitle_format: str subtitle_format: str
created_at: float created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 5
save_chapters_separately: bool = False save_chapters_separately: bool = False
merge_chapters_at_end: bool = True merge_chapters_at_end: bool = True
separate_chapters_format: str = "wav" separate_chapters_format: str = "wav"
@@ -201,6 +203,8 @@ class Job:
}, },
"queue_position": self.queue_position, "queue_position": self.queue_position,
"options": { "options": {
"tts_provider": getattr(self, "tts_provider", "kokoro"),
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 5),
"save_chapters_separately": self.save_chapters_separately, "save_chapters_separately": self.save_chapters_separately,
"merge_chapters_at_end": self.merge_chapters_at_end, "merge_chapters_at_end": self.merge_chapters_at_end,
"separate_chapters_format": self.separate_chapters_format, "separate_chapters_format": self.separate_chapters_format,
@@ -547,6 +551,8 @@ class PendingJob:
chapters: List[Dict[str, Any]] chapters: List[Dict[str, Any]]
normalization_overrides: Dict[str, Any] normalization_overrides: Dict[str, Any]
created_at: float created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 5
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5 chapter_intro_delay: float = 0.5
@@ -614,6 +620,8 @@ class ConversionService:
language: str, language: str,
voice: str, voice: str,
speed: float, speed: float,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
use_gpu: bool, use_gpu: bool,
subtitle_mode: str, subtitle_mode: str,
output_format: str, output_format: str,
@@ -665,6 +673,8 @@ class ConversionService:
language=language, language=language,
voice=voice, voice=voice,
speed=speed, speed=speed,
tts_provider=tts_provider,
supertonic_total_steps=int(supertonic_total_steps or 5),
use_gpu=use_gpu, use_gpu=use_gpu,
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
output_format=output_format, output_format=output_format,
@@ -1134,8 +1144,10 @@ class ConversionService:
"original_filename": job.original_filename, "original_filename": job.original_filename,
"stored_path": str(job.stored_path), "stored_path": str(job.stored_path),
"language": job.language, "language": job.language,
"tts_provider": getattr(job, "tts_provider", "kokoro"),
"voice": job.voice, "voice": job.voice,
"speed": job.speed, "speed": job.speed,
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 5),
"use_gpu": job.use_gpu, "use_gpu": job.use_gpu,
"subtitle_mode": job.subtitle_mode, "subtitle_mode": job.subtitle_mode,
"output_format": job.output_format, "output_format": job.output_format,
@@ -1252,6 +1264,7 @@ class ConversionService:
original_filename=payload["original_filename"], original_filename=payload["original_filename"],
stored_path=stored_path, stored_path=stored_path,
language=payload.get("language", "a"), language=payload.get("language", "a"),
tts_provider=str(payload.get("tts_provider") or "kokoro"),
voice=payload.get("voice", ""), voice=payload.get("voice", ""),
speed=float(payload.get("speed", 1.0)), speed=float(payload.get("speed", 1.0)),
use_gpu=bool(payload.get("use_gpu", True)), use_gpu=bool(payload.get("use_gpu", True)),
@@ -1262,6 +1275,7 @@ class ConversionService:
replace_single_newlines=bool(payload.get("replace_single_newlines", False)), replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
subtitle_format=payload.get("subtitle_format", "srt"), subtitle_format=payload.get("subtitle_format", "srt"),
created_at=float(payload.get("created_at", time.time())), 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)), save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)), merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
separate_chapters_format=payload.get("separate_chapters_format", "wav"), separate_chapters_format=payload.get("separate_chapters_format", "wav"),
+1 -1
View File
@@ -18,7 +18,7 @@
<nav class="top-actions"> <nav class="top-actions">
{% set endpoint = request.endpoint or '' %} {% 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('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('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('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> <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"> <section class="settings-panel is-active" data-section="narration">
<fieldset class="settings__section"> <fieldset class="settings__section">
<legend>Narration Defaults</legend> <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"> <div class="field">
<label for="default_voice">Narrator Voice</label> <label for="default_voice">Narrator Voice</label>
<select id="default_voice" name="default_voice"> <select id="default_voice" name="default_voice">
@@ -44,9 +56,9 @@
{% endfor %} {% endfor %}
</optgroup> </optgroup>
{% if options.voice_profile_options %} {% if options.voice_profile_options %}
<optgroup label="Saved mixes"> <optgroup label="Saved speakers">
{% for profile in options.voice_profile_options %} {% 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> <option value="{{ profile_value }}" {% if settings.default_voice == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %} {% endfor %}
</optgroup> </optgroup>
@@ -58,7 +70,7 @@
{% set known_default.value = True %} {% set known_default.value = True %}
{% else %} {% else %}
{% for profile in options.voice_profile_options %} {% 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 %} {% set known_default.value = True %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -70,6 +82,28 @@
</select> </select>
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p> <p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
</div> </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"> <div class="field">
<label for="chunk_level_default">Chunk Granularity</label> <label for="chunk_level_default">Chunk Granularity</label>
<select id="chunk_level_default" name="chunk_level"> <select id="chunk_level_default" name="chunk_level">
+3 -3
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}abogen · Voice mixer{% endblock %} {% block title %}abogen · Speaker Studio{% endblock %}
{% block content %} {% block content %}
<section class="card voice-mixer"> <section class="card voice-mixer">
<div class="voice-mixer__header"> <div class="voice-mixer__header">
<div> <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> <p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
</div> </div>
<div class="voice-mixer__header-actions"> <div class="voice-mixer__header-actions">
@@ -21,7 +21,7 @@
</aside> </aside>
<section class="voice-mixer__editor" data-role="editor"> <section class="voice-mixer__editor" data-role="editor">
<noscript> <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> </noscript>
<form id="voice-profile-form" class="voice-editor" autocomplete="off"> <form id="voice-profile-form" class="voice-editor" autocomplete="off">
<div class="voice-status" data-role="status"></div> <div class="voice-status" data-role="status"></div>
+1
View File
@@ -15,6 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
dependencies = [ dependencies = [
"kokoro>=0.9.4", "kokoro>=0.9.4",
"misaki[zh]>=0.9.4", "misaki[zh]>=0.9.4",
"supertonic>=0.1.0",
"ebooklib>=0.19", "ebooklib>=0.19",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"spacy>=3.5,<4.0", "spacy>=3.5,<4.0",
-32
View File
@@ -288,35 +288,3 @@ def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
metadata = build_audiobookshelf_metadata(job) metadata = build_audiobookshelf_metadata(job)
assert metadata["seriesSequence"] == "4.5" assert metadata["seriesSequence"] == "4.5"
def test_audiobookshelf_metadata_ignores_author_series_collision(tmp_path):
source = tmp_path / "book.txt"
source.write_text("content", encoding="utf-8")
job = Job(
id="job-abs-author-series",
original_filename="book.txt",
stored_path=source,
language="en",
voice="af_alloy",
speed=1.0,
use_gpu=False,
subtitle_mode="Sentence",
output_format="mp3",
save_mode="Save next to input file",
output_folder=tmp_path,
replace_single_newlines=False,
subtitle_format="srt",
created_at=time.time(),
metadata_tags={
"series": "Jane Doe",
"series_index": "1",
"authors": "Jane Doe",
},
)
metadata = build_audiobookshelf_metadata(job)
assert "seriesName" not in metadata
assert "seriesSequence" not in metadata