mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +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.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,7 +1386,8 @@ 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)
|
||||||
_initialize_voice_cache(job)
|
if getattr(job, "tts_provider", "kokoro") == "kokoro":
|
||||||
|
_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)
|
||||||
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
|
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)
|
base_voice_spec = _job_voice_fallback(job)
|
||||||
voice_cache: Dict[str, Any] = {}
|
voice_cache: Dict[str, Any] = {}
|
||||||
if base_voice_spec and "*" not in base_voice_spec:
|
if getattr(job, "tts_provider", "kokoro") == "kokoro":
|
||||||
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
|
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
|
processed_chars = 0
|
||||||
subtitle_index = 1
|
subtitle_index = 1
|
||||||
current_time = 0.0
|
current_time = 0.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")
|
||||||
normalized,
|
if provider == "supertonic":
|
||||||
voice=voice_choice,
|
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
|
||||||
speed=job.speed,
|
segment_iter = pipeline(
|
||||||
split_pattern=split_pattern,
|
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()
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -574,54 +574,86 @@ def apply_book_step_form(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
provider_value = (form.get("tts_provider") or getattr(pending, "tts_provider", None) or settings.get("tts_provider") or "kokoro").strip().lower()
|
||||||
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
if provider_value not in {"kokoro", "supertonic"}:
|
||||||
narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip()
|
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 {})
|
if provider_value == "supertonic":
|
||||||
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
|
narrator_voice_raw = (
|
||||||
narrator_voice_raw,
|
form.get("voice")
|
||||||
profiles=profiles_map,
|
or pending.voice
|
||||||
)
|
or settings.get("supertonic_default_voice")
|
||||||
|
or "M1"
|
||||||
if profile_selection in {"__standard", "", None} and inferred_profile:
|
).strip()
|
||||||
profile_selection = inferred_profile
|
# Supertonic does not support Abogen voice mixing.
|
||||||
|
|
||||||
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
|
pending.voice_profile = None
|
||||||
elif profile_selection not in {"__standard", "", None, "__formula"}:
|
pending.voice = narrator_voice_raw
|
||||||
pending.voice_profile = selected_profile or profile_selection
|
|
||||||
pending.voice = voice_choice
|
# 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:
|
else:
|
||||||
pending.voice_profile = None
|
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||||
fallback_voice = base_voice_spec or narrator_voice_raw
|
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
||||||
pending.voice = voice_choice or fallback_voice
|
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
|
pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
|
||||||
|
|
||||||
|
|||||||
@@ -31,26 +31,14 @@ 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")
|
||||||
|
|
||||||
device = "cpu"
|
provider = (tts_provider or "kokoro").strip().lower()
|
||||||
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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
normalized_text = normalize_for_pipeline(text)
|
normalized_text = normalize_for_pipeline(text)
|
||||||
@@ -58,12 +46,40 @@ def generate_preview_audio(
|
|||||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||||
normalized_text = text
|
normalized_text = text
|
||||||
|
|
||||||
segments = pipeline(
|
if provider == "supertonic":
|
||||||
normalized_text,
|
from abogen.tts_supertonic import SupertonicPipeline
|
||||||
voice=voice_choice,
|
|
||||||
speed=speed,
|
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||||
split_pattern=SPLIT_PATTERN,
|
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] = []
|
audio_chunks: List[np.ndarray] = []
|
||||||
accumulated = 0
|
accumulated = 0
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+1
-33
@@ -287,36 +287,4 @@ 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
|
|
||||||
Reference in New Issue
Block a user