mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Integrate Supertonic TTS provider with configuration options and UI updates; enhance voice handling and settings management
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
|
||||
dependencies = [
|
||||
"kokoro>=0.9.4",
|
||||
"misaki[zh]>=0.9.4",
|
||||
"supertonic>=0.1.0",
|
||||
"ebooklib>=0.19",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"spacy>=3.5,<4.0",
|
||||
|
||||
+1
-33
@@ -287,36 +287,4 @@ def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
|
||||
|
||||
metadata = build_audiobookshelf_metadata(job)
|
||||
|
||||
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
|
||||
assert metadata["seriesSequence"] == "4.5"
|
||||
Reference in New Issue
Block a user