mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add provider selection modal and enhance voice profile handling for Supertonic integration
This commit is contained in:
+168
-18
@@ -42,6 +42,7 @@ from abogen.utils import (
|
|||||||
)
|
)
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
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.voice_profiles import load_profiles, normalize_profile_entry
|
||||||
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 abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
|
||||||
@@ -67,6 +68,58 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
|||||||
return str(fallback or "").strip() or "M1"
|
return str(fallback or "").strip() or "M1"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw or ":" not in raw:
|
||||||
|
return None, raw
|
||||||
|
prefix, remainder = raw.split(":", 1)
|
||||||
|
prefix = prefix.strip().lower()
|
||||||
|
if prefix not in {"speaker", "profile"}:
|
||||||
|
return None, raw
|
||||||
|
name = remainder.strip()
|
||||||
|
return (name or None), raw
|
||||||
|
|
||||||
|
|
||||||
|
def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
||||||
|
voices = entry.get("voices") or []
|
||||||
|
if not voices:
|
||||||
|
return ""
|
||||||
|
total = 0.0
|
||||||
|
parts: list[tuple[str, float]] = []
|
||||||
|
for item in voices:
|
||||||
|
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
name = str(item[0] or "").strip()
|
||||||
|
try:
|
||||||
|
weight = float(item[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not name or weight <= 0:
|
||||||
|
continue
|
||||||
|
parts.append((name, weight))
|
||||||
|
total += weight
|
||||||
|
if total <= 0 or not parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _format_weight(value: float) -> str:
|
||||||
|
normalized = value / total if total else 0.0
|
||||||
|
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
return "+".join(f"{name}*{_format_weight(weight)}" for name, weight in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
upper = raw.upper()
|
||||||
|
if upper in DEFAULT_SUPERTONIC_VOICES:
|
||||||
|
return "supertonic"
|
||||||
|
if "*" in raw or "+" in raw:
|
||||||
|
return "kokoro"
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class _JobCancelled(Exception):
|
class _JobCancelled(Exception):
|
||||||
"""Raised internally to abort a conversion when the client cancels."""
|
"""Raised internally to abort a conversion when the client cancels."""
|
||||||
|
|
||||||
@@ -1380,14 +1433,76 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
audio_output_path: Optional[Path] = None
|
audio_output_path: Optional[Path] = None
|
||||||
extraction: Optional[Any] = None
|
extraction: Optional[Any] = None
|
||||||
pipeline: Any = None
|
pipeline: Any = None
|
||||||
|
pipelines: Dict[str, Any] = {}
|
||||||
|
kokoro_cache_ready = False
|
||||||
|
normalized_profiles: Dict[str, Dict[str, Any]] = {}
|
||||||
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
|
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
active_chapter_configs: List[Dict[str, Any]] = []
|
active_chapter_configs: List[Dict[str, Any]] = []
|
||||||
usage_counter: Dict[str, int] = defaultdict(int)
|
usage_counter: Dict[str, int] = defaultdict(int)
|
||||||
override_token_map: Dict[str, str] = {}
|
override_token_map: Dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
pipeline = _load_pipeline(job)
|
# Load saved speakers once so we can resolve speaker: references during conversion.
|
||||||
if getattr(job, "tts_provider", "kokoro") == "kokoro":
|
try:
|
||||||
|
profiles = load_profiles()
|
||||||
|
except Exception:
|
||||||
|
profiles = {}
|
||||||
|
for name, entry in (profiles or {}).items():
|
||||||
|
normalized = normalize_profile_entry(entry)
|
||||||
|
if normalized:
|
||||||
|
normalized_profiles[str(name)] = normalized
|
||||||
|
|
||||||
|
def get_pipeline(provider: str) -> Any:
|
||||||
|
nonlocal kokoro_cache_ready
|
||||||
|
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||||
|
if provider_norm not in {"kokoro", "supertonic"}:
|
||||||
|
provider_norm = "kokoro"
|
||||||
|
|
||||||
|
existing = pipelines.get(provider_norm)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if provider_norm == "supertonic":
|
||||||
|
pipelines[provider_norm] = SupertonicPipeline(
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
auto_download=True,
|
||||||
|
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||||
|
)
|
||||||
|
return pipelines[provider_norm]
|
||||||
|
|
||||||
|
# Kokoro
|
||||||
|
cfg = load_config()
|
||||||
|
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
||||||
|
device = "cpu"
|
||||||
|
if not disable_gpu:
|
||||||
|
device = _select_device()
|
||||||
|
_np, KPipeline = load_numpy_kpipeline()
|
||||||
|
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
|
if not kokoro_cache_ready:
|
||||||
_initialize_voice_cache(job)
|
_initialize_voice_cache(job)
|
||||||
|
kokoro_cache_ready = True
|
||||||
|
return pipelines[provider_norm]
|
||||||
|
|
||||||
|
def resolve_voice_target(raw_spec: str) -> tuple[str, str, Optional[float], Optional[int]]:
|
||||||
|
"""Return (provider, voice_spec, speed_override, steps_override)."""
|
||||||
|
spec = str(raw_spec or "").strip()
|
||||||
|
speaker_name, _ = _split_speaker_reference(spec)
|
||||||
|
if speaker_name and speaker_name in normalized_profiles:
|
||||||
|
entry = normalized_profiles[speaker_name]
|
||||||
|
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
||||||
|
if provider == "supertonic":
|
||||||
|
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
||||||
|
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5)
|
||||||
|
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
||||||
|
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
||||||
|
formula = _formula_from_kokoro_entry(entry)
|
||||||
|
return "kokoro", formula or spec, None, None
|
||||||
|
|
||||||
|
fallback_provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
|
||||||
|
inferred = _infer_provider_from_spec(spec, fallback=fallback_provider)
|
||||||
|
if inferred == "supertonic":
|
||||||
|
return "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None
|
||||||
|
return "kokoro", spec, None, None
|
||||||
|
|
||||||
extraction = extract_from_path(job.stored_path)
|
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)
|
||||||
@@ -1507,9 +1622,10 @@ 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":
|
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
||||||
if base_voice_spec and "*" not in base_voice_spec:
|
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
||||||
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
|
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
|
||||||
processed_chars = 0
|
processed_chars = 0
|
||||||
subtitle_index = 1
|
subtitle_index = 1
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
@@ -1538,6 +1654,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
chapter_sink: Optional[AudioSink],
|
chapter_sink: Optional[AudioSink],
|
||||||
preview_prefix: Optional[str] = None,
|
preview_prefix: Optional[str] = None,
|
||||||
split_pattern: Optional[str] = SPLIT_PATTERN,
|
split_pattern: Optional[str] = SPLIT_PATTERN,
|
||||||
|
tts_provider: Optional[str] = None,
|
||||||
|
speed_override: Optional[float] = None,
|
||||||
|
supertonic_steps_override: Optional[int] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
nonlocal processed_chars, subtitle_index, current_time
|
nonlocal processed_chars, subtitle_index, current_time
|
||||||
source_text = str(text or "")
|
source_text = str(text or "")
|
||||||
@@ -1560,21 +1679,23 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
raise
|
raise
|
||||||
local_segments = 0
|
local_segments = 0
|
||||||
|
|
||||||
provider = getattr(job, "tts_provider", "kokoro")
|
provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
|
supertonic_pipeline = get_pipeline("supertonic")
|
||||||
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
|
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
|
||||||
segment_iter = pipeline(
|
segment_iter = supertonic_pipeline(
|
||||||
normalized,
|
normalized,
|
||||||
voice=voice_name,
|
voice=voice_name,
|
||||||
speed=job.speed,
|
speed=float(speed_override if speed_override is not None else job.speed),
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
total_steps=getattr(job, "supertonic_total_steps", 5),
|
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
segment_iter = pipeline(
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
|
segment_iter = kokoro_pipeline(
|
||||||
normalized,
|
normalized,
|
||||||
voice=voice_choice,
|
voice=voice_choice,
|
||||||
speed=job.speed,
|
speed=float(speed_override if speed_override is not None else job.speed),
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1655,10 +1776,16 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if not chapter_voice_spec:
|
if not chapter_voice_spec:
|
||||||
chapter_voice_spec = base_voice_spec
|
chapter_voice_spec = base_voice_spec
|
||||||
|
|
||||||
voice_choice = voice_cache.get(chapter_voice_spec)
|
chapter_provider, chapter_voice_resolved, chapter_speed, chapter_steps = resolve_voice_target(chapter_voice_spec)
|
||||||
|
chapter_cache_key = f"{chapter_provider}:{chapter_voice_resolved}" if chapter_voice_resolved else chapter_provider
|
||||||
|
if chapter_provider == "kokoro":
|
||||||
|
voice_choice = voice_cache.get(chapter_cache_key)
|
||||||
if voice_choice is None:
|
if voice_choice is None:
|
||||||
voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu)
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
voice_cache[chapter_voice_spec] = voice_choice
|
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
|
||||||
|
voice_cache[chapter_cache_key] = voice_choice
|
||||||
|
else:
|
||||||
|
voice_choice = chapter_voice_resolved
|
||||||
|
|
||||||
chapter_audio_path: Optional[Path] = None
|
chapter_audio_path: Optional[Path] = None
|
||||||
segments_emitted = 0
|
segments_emitted = 0
|
||||||
@@ -1694,6 +1821,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice_choice=voice_choice,
|
voice_choice=voice_choice,
|
||||||
chapter_sink=chapter_sink,
|
chapter_sink=chapter_sink,
|
||||||
preview_prefix="Book intro",
|
preview_prefix="Book intro",
|
||||||
|
tts_provider=chapter_provider,
|
||||||
|
speed_override=chapter_speed,
|
||||||
|
supertonic_steps_override=chapter_steps,
|
||||||
)
|
)
|
||||||
intro_emitted = True
|
intro_emitted = True
|
||||||
if intro_segments > 0 and job.chapter_intro_delay > 0:
|
if intro_segments > 0 and job.chapter_intro_delay > 0:
|
||||||
@@ -1710,6 +1840,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
chapter_sink=chapter_sink,
|
chapter_sink=chapter_sink,
|
||||||
preview_prefix=f"Chapter {idx} title",
|
preview_prefix=f"Chapter {idx} title",
|
||||||
split_pattern=SPLIT_PATTERN,
|
split_pattern=SPLIT_PATTERN,
|
||||||
|
tts_provider=chapter_provider,
|
||||||
|
speed_override=chapter_speed,
|
||||||
|
supertonic_steps_override=chapter_steps,
|
||||||
)
|
)
|
||||||
segments_emitted += heading_segments
|
segments_emitted += heading_segments
|
||||||
if heading_segments > 0 and job.chapter_intro_delay > 0:
|
if heading_segments > 0 and job.chapter_intro_delay > 0:
|
||||||
@@ -1782,16 +1915,26 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
chunk_voice_spec = chapter_voice_spec or base_voice_spec
|
chunk_voice_spec = chapter_voice_spec or base_voice_spec
|
||||||
|
|
||||||
if chunk_voice_spec == chapter_voice_spec:
|
if chunk_voice_spec == chapter_voice_spec:
|
||||||
|
chunk_provider = chapter_provider
|
||||||
|
chunk_voice_resolved = chapter_voice_resolved
|
||||||
|
chunk_speed_use = chapter_speed
|
||||||
|
chunk_steps_use = chapter_steps
|
||||||
chunk_voice_choice = voice_choice
|
chunk_voice_choice = voice_choice
|
||||||
else:
|
else:
|
||||||
chunk_voice_choice = voice_cache.get(chunk_voice_spec)
|
chunk_provider, chunk_voice_resolved, chunk_speed_use, chunk_steps_use = resolve_voice_target(chunk_voice_spec)
|
||||||
|
chunk_cache_key = f"{chunk_provider}:{chunk_voice_resolved}" if chunk_voice_resolved else chunk_provider
|
||||||
|
if chunk_provider == "kokoro":
|
||||||
|
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
||||||
if chunk_voice_choice is None:
|
if chunk_voice_choice is None:
|
||||||
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
chunk_voice_choice = _resolve_voice(
|
chunk_voice_choice = _resolve_voice(
|
||||||
pipeline,
|
kokoro_pipeline,
|
||||||
chunk_voice_spec,
|
chunk_voice_resolved,
|
||||||
job.use_gpu,
|
job.use_gpu,
|
||||||
)
|
)
|
||||||
voice_cache[chunk_voice_spec] = chunk_voice_choice
|
voice_cache[chunk_cache_key] = chunk_voice_choice
|
||||||
|
else:
|
||||||
|
chunk_voice_choice = chunk_voice_resolved
|
||||||
|
|
||||||
chunk_start = current_time
|
chunk_start = current_time
|
||||||
emitted = emit_text(
|
emitted = emit_text(
|
||||||
@@ -1799,6 +1942,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice_choice=chunk_voice_choice,
|
voice_choice=chunk_voice_choice,
|
||||||
chapter_sink=chapter_sink,
|
chapter_sink=chapter_sink,
|
||||||
preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}",
|
preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}",
|
||||||
|
tts_provider=chunk_provider,
|
||||||
|
speed_override=chunk_speed_use,
|
||||||
|
supertonic_steps_override=chunk_steps_use,
|
||||||
)
|
)
|
||||||
if emitted <= 0:
|
if emitted <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -1851,6 +1997,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
chapter_text,
|
chapter_text,
|
||||||
voice_choice=voice_choice,
|
voice_choice=voice_choice,
|
||||||
chapter_sink=chapter_sink,
|
chapter_sink=chapter_sink,
|
||||||
|
tts_provider=chapter_provider,
|
||||||
|
speed_override=chapter_speed,
|
||||||
|
supertonic_steps_override=chapter_steps,
|
||||||
)
|
)
|
||||||
if emitted > 0:
|
if emitted > 0:
|
||||||
segments_emitted += emitted
|
segments_emitted += emitted
|
||||||
@@ -2093,6 +2242,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
|
|
||||||
# Explicitly release the pipeline and force garbage collection to prevent
|
# Explicitly release the pipeline and force garbage collection to prevent
|
||||||
# memory accumulation in the worker process, which can lead to host lockups.
|
# memory accumulation in the worker process, which can lead to host lockups.
|
||||||
|
pipelines.clear()
|
||||||
pipeline = None
|
pipeline = None
|
||||||
gc.collect()
|
gc.collect()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -166,7 +166,12 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
|
|||||||
resolved_provider = provider or "kokoro"
|
resolved_provider = provider or "kokoro"
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if profile_name:
|
if resolved_provider == "supertonic" and not profile_name:
|
||||||
|
voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
|
||||||
|
# Allow per-speaker overrides via payload.
|
||||||
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
|
||||||
|
speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
|
||||||
|
elif profile_name:
|
||||||
entry = profiles.get(profile_name)
|
entry = profiles.get(profile_name)
|
||||||
normalized_entry = normalize_profile_entry(entry)
|
normalized_entry = normalize_profile_entry(entry)
|
||||||
if not normalized_entry:
|
if not normalized_entry:
|
||||||
|
|||||||
@@ -6,20 +6,47 @@ import soundfile as sf
|
|||||||
from flask import current_app, send_file
|
from flask import current_app, send_file
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
from abogen.utils import load_numpy_kpipeline
|
|
||||||
from abogen.voice_formulas import get_new_voice
|
SPLIT_PATTERN = r"\n+"
|
||||||
from abogen.web.conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
SAMPLE_RATE = 24000
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
|
||||||
|
|
||||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||||
_preview_pipeline_lock = threading.Lock()
|
_preview_pipeline_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _select_device() -> str:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Darwin" and platform.processor() == "arm":
|
||||||
|
return "mps"
|
||||||
|
return "cuda"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float32(audio_segment) -> np.ndarray:
|
||||||
|
if audio_segment is None:
|
||||||
|
return np.zeros(0, dtype="float32")
|
||||||
|
|
||||||
|
tensor = audio_segment
|
||||||
|
if hasattr(tensor, "detach"):
|
||||||
|
tensor = tensor.detach()
|
||||||
|
if hasattr(tensor, "cpu"):
|
||||||
|
try:
|
||||||
|
tensor = tensor.cpu()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(tensor, "numpy"):
|
||||||
|
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||||
|
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||||
|
|
||||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||||
key = (language, device)
|
key = (language, device)
|
||||||
with _preview_pipeline_lock:
|
with _preview_pipeline_lock:
|
||||||
pipeline = _preview_pipelines.get(key)
|
pipeline = _preview_pipelines.get(key)
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
return pipeline
|
return pipeline
|
||||||
|
from abogen.utils import load_numpy_kpipeline
|
||||||
|
|
||||||
_, KPipeline = load_numpy_kpipeline()
|
_, KPipeline = load_numpy_kpipeline()
|
||||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
_preview_pipelines[key] = pipeline
|
_preview_pipelines[key] = pipeline
|
||||||
@@ -40,7 +67,11 @@ def generate_preview_audio(
|
|||||||
|
|
||||||
provider = (tts_provider or "kokoro").strip().lower()
|
provider = (tts_provider or "kokoro").strip().lower()
|
||||||
|
|
||||||
|
normalized_text = text
|
||||||
|
if provider != "supertonic":
|
||||||
try:
|
try:
|
||||||
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
|
|
||||||
normalized_text = normalize_for_pipeline(text)
|
normalized_text = normalize_for_pipeline(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||||
@@ -72,6 +103,8 @@ def generate_preview_audio(
|
|||||||
|
|
||||||
voice_choice: Any = voice_spec
|
voice_choice: Any = voice_spec
|
||||||
if voice_spec and "*" in voice_spec:
|
if voice_spec and "*" in voice_spec:
|
||||||
|
from abogen.voice_formulas import get_new_voice
|
||||||
|
|
||||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||||
|
|
||||||
segments = pipeline(
|
segments = pipeline(
|
||||||
|
|||||||
+134
-8
@@ -24,6 +24,7 @@ const setupVoiceMixer = () => {
|
|||||||
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
|
||||||
const nameInput = document.getElementById("profile-name");
|
const nameInput = document.getElementById("profile-name");
|
||||||
const languageSelect = document.getElementById("profile-language");
|
const languageSelect = document.getElementById("profile-language");
|
||||||
|
const languageField = app.querySelector(".voice-editor__language");
|
||||||
const providerSelect = document.getElementById("profile-provider");
|
const providerSelect = document.getElementById("profile-provider");
|
||||||
const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]');
|
const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]');
|
||||||
const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]');
|
const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]');
|
||||||
@@ -40,6 +41,13 @@ const setupVoiceMixer = () => {
|
|||||||
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
|
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
|
||||||
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
|
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
|
||||||
|
|
||||||
|
const providerPickerModal = document.querySelector('[data-role="provider-picker-modal"]');
|
||||||
|
const providerPickerOverlay = document.querySelector('[data-role="provider-picker-overlay"]');
|
||||||
|
const providerPickerClose = document.querySelector('[data-role="provider-picker-close"]');
|
||||||
|
const providerPickerCancel = document.querySelector('[data-role="provider-picker-cancel"]');
|
||||||
|
const providerPickerConfirm = document.querySelector('[data-role="provider-picker-confirm"]');
|
||||||
|
const providerPickerOptions = document.querySelector('[data-role="provider-picker-options"]');
|
||||||
|
|
||||||
if (previewBtn && !previewBtn.dataset.label) {
|
if (previewBtn && !previewBtn.dataset.label) {
|
||||||
previewBtn.dataset.label = previewBtn.textContent.trim();
|
previewBtn.dataset.label = previewBtn.textContent.trim();
|
||||||
}
|
}
|
||||||
@@ -143,12 +151,123 @@ const setupVoiceMixer = () => {
|
|||||||
return candidate === "supertonic" ? "supertonic" : "kokoro";
|
return candidate === "supertonic" ? "supertonic" : "kokoro";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getProviderCatalog = () => {
|
||||||
|
if (!providerSelect) {
|
||||||
|
return [
|
||||||
|
{ id: "kokoro", label: "Kokoro" },
|
||||||
|
{ id: "supertonic", label: "Supertonic" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return Array.from(providerSelect.options || []).map((option) => ({
|
||||||
|
id: normalizeProvider(option.value),
|
||||||
|
label: option.textContent?.trim() || option.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDescription = (providerId) => {
|
||||||
|
const provider = normalizeProvider(providerId);
|
||||||
|
if (provider === "supertonic") {
|
||||||
|
return "Voice selection + quality/speed per speaker.";
|
||||||
|
}
|
||||||
|
return "Voice mixing supported via the Kokoro mixer.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProviderPicker = (defaultProvider = "kokoro") => {
|
||||||
|
if (!providerPickerModal || !providerPickerOptions || !providerPickerConfirm) {
|
||||||
|
return Promise.resolve(normalizeProvider(defaultProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
providerPickerOptions.innerHTML = "";
|
||||||
|
const catalog = getProviderCatalog();
|
||||||
|
const normalizedDefault = normalizeProvider(defaultProvider);
|
||||||
|
|
||||||
|
catalog.forEach((item) => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "toggle-pill";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = "provider-picker";
|
||||||
|
input.value = item.id;
|
||||||
|
input.checked = item.id === normalizedDefault;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = item.label;
|
||||||
|
const detail = document.createElement("span");
|
||||||
|
detail.className = "muted";
|
||||||
|
detail.textContent = ` — ${providerDescription(item.id)}`;
|
||||||
|
span.appendChild(title);
|
||||||
|
span.appendChild(detail);
|
||||||
|
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(span);
|
||||||
|
providerPickerOptions.appendChild(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRadio = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||||
|
providerPickerConfirm.disabled = !selectedRadio;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const teardown = () => {
|
||||||
|
providerPickerModal.hidden = true;
|
||||||
|
document.removeEventListener("keydown", onKeydown);
|
||||||
|
providerPickerOptions.removeEventListener("change", onChange);
|
||||||
|
providerPickerOverlay?.removeEventListener("click", onCancel);
|
||||||
|
providerPickerClose?.removeEventListener("click", onCancel);
|
||||||
|
providerPickerCancel?.removeEventListener("click", onCancel);
|
||||||
|
providerPickerConfirm?.removeEventListener("click", onConfirm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (value) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
teardown();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => finish(null);
|
||||||
|
const onConfirm = () => {
|
||||||
|
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||||
|
finish(selected ? normalizeProvider(selected.value) : null);
|
||||||
|
};
|
||||||
|
const onChange = () => {
|
||||||
|
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
|
||||||
|
providerPickerConfirm.disabled = !selected;
|
||||||
|
};
|
||||||
|
const onKeydown = (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
providerPickerModal.hidden = false;
|
||||||
|
document.addEventListener("keydown", onKeydown);
|
||||||
|
providerPickerOptions.addEventListener("change", onChange);
|
||||||
|
providerPickerOverlay?.addEventListener("click", onCancel);
|
||||||
|
providerPickerClose?.addEventListener("click", onCancel);
|
||||||
|
providerPickerCancel?.addEventListener("click", onCancel);
|
||||||
|
providerPickerConfirm?.addEventListener("click", onConfirm);
|
||||||
|
|
||||||
|
const focusTarget = providerPickerOptions.querySelector('input[name="provider-picker"]:checked')
|
||||||
|
|| providerPickerOptions.querySelector('input[name="provider-picker"]');
|
||||||
|
if (focusTarget instanceof HTMLElement) {
|
||||||
|
focusTarget.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyProviderToUI = () => {
|
const applyProviderToUI = () => {
|
||||||
const provider = normalizeProvider(state.draft.provider);
|
const provider = normalizeProvider(state.draft.provider);
|
||||||
const isSupertonic = provider === "supertonic";
|
const isSupertonic = provider === "supertonic";
|
||||||
if (providerSelect) {
|
if (providerSelect) {
|
||||||
providerSelect.value = provider;
|
providerSelect.value = provider;
|
||||||
}
|
}
|
||||||
|
if (languageField) {
|
||||||
|
languageField.hidden = isSupertonic;
|
||||||
|
}
|
||||||
if (kokoroMixerEl) {
|
if (kokoroMixerEl) {
|
||||||
kokoroMixerEl.hidden = isSupertonic;
|
kokoroMixerEl.hidden = isSupertonic;
|
||||||
}
|
}
|
||||||
@@ -588,13 +707,12 @@ const setupVoiceMixer = () => {
|
|||||||
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
|
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createNewProfile = () => {
|
const startNewProfile = (provider = "kokoro") => {
|
||||||
const isSupertonic = window.confirm("Create a Supertonic speaker?\n\nOK = Supertonic\nCancel = Kokoro");
|
|
||||||
state.selectedProfile = null;
|
state.selectedProfile = null;
|
||||||
state.originalName = null;
|
state.originalName = null;
|
||||||
state.draft = {
|
state.draft = {
|
||||||
name: "",
|
name: "",
|
||||||
provider: isSupertonic ? "supertonic" : "kokoro",
|
provider: normalizeProvider(provider),
|
||||||
language: languageSelect ? languageSelect.value || "a" : "a",
|
language: languageSelect ? languageSelect.value || "a" : "a",
|
||||||
voices: new Map(),
|
voices: new Map(),
|
||||||
supertonic: {
|
supertonic: {
|
||||||
@@ -608,6 +726,15 @@ const setupVoiceMixer = () => {
|
|||||||
loadSampleText();
|
loadSampleText();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestNewProfile = async () => {
|
||||||
|
const chosen = await openProviderPicker(normalizeProvider(state.draft.provider));
|
||||||
|
if (!chosen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startNewProfile(chosen);
|
||||||
|
setStatus("New speaker ready.", "info");
|
||||||
|
};
|
||||||
|
|
||||||
const refreshProfiles = (nextProfiles, selectedName = null) => {
|
const refreshProfiles = (nextProfiles, selectedName = null) => {
|
||||||
profiles = nextProfiles || {};
|
profiles = nextProfiles || {};
|
||||||
renderProfileList();
|
renderProfileList();
|
||||||
@@ -620,7 +747,7 @@ const setupVoiceMixer = () => {
|
|||||||
if (names.length) {
|
if (names.length) {
|
||||||
selectProfile(names[0]);
|
selectProfile(names[0]);
|
||||||
} else {
|
} else {
|
||||||
createNewProfile();
|
startNewProfile("kokoro");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
@@ -657,7 +784,7 @@ const setupVoiceMixer = () => {
|
|||||||
name,
|
name,
|
||||||
originalName: state.originalName,
|
originalName: state.originalName,
|
||||||
provider: normalizeProvider(state.draft.provider),
|
provider: normalizeProvider(state.draft.provider),
|
||||||
language: languageSelect ? languageSelect.value : "a",
|
language: normalizeProvider(state.draft.provider) === "kokoro" ? (languageSelect ? languageSelect.value : "a") : "a",
|
||||||
voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [],
|
voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [],
|
||||||
voice: state.draft.supertonic?.voice,
|
voice: state.draft.supertonic?.voice,
|
||||||
total_steps: state.draft.supertonic?.total_steps,
|
total_steps: state.draft.supertonic?.total_steps,
|
||||||
@@ -964,8 +1091,7 @@ const setupVoiceMixer = () => {
|
|||||||
const action = target.dataset.action;
|
const action = target.dataset.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
if (action === "new-profile") {
|
if (action === "new-profile") {
|
||||||
createNewProfile();
|
requestNewProfile();
|
||||||
setStatus("New profile ready.", "info");
|
|
||||||
} else if (action === "import-profiles") {
|
} else if (action === "import-profiles") {
|
||||||
importInput?.click();
|
importInput?.click();
|
||||||
} else if (action === "export-profiles") {
|
} else if (action === "export-profiles") {
|
||||||
@@ -1023,7 +1149,7 @@ const setupVoiceMixer = () => {
|
|||||||
|
|
||||||
renderAvailableVoices();
|
renderAvailableVoices();
|
||||||
renderProfileList();
|
renderProfileList();
|
||||||
createNewProfile();
|
startNewProfile("kokoro");
|
||||||
|
|
||||||
if (Object.keys(profiles).length) {
|
if (Object.keys(profiles).length) {
|
||||||
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
|
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
|
||||||
|
|||||||
@@ -146,6 +146,25 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<input id="voice-import-input" type="file" accept="application/json" hidden>
|
<input id="voice-import-input" type="file" accept="application/json" hidden>
|
||||||
|
|
||||||
|
<div class="modal" data-role="provider-picker-modal" hidden>
|
||||||
|
<div class="modal__overlay" data-role="provider-picker-overlay" tabindex="-1"></div>
|
||||||
|
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="provider-picker-title">
|
||||||
|
<header class="modal__header">
|
||||||
|
<p class="modal__eyebrow">Speaker Studio</p>
|
||||||
|
<h2 class="modal__title" id="provider-picker-title">Choose a TTS provider</h2>
|
||||||
|
<button type="button" class="button button--ghost button--small" data-role="provider-picker-close" aria-label="Close provider picker">Close</button>
|
||||||
|
</header>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p class="hint">Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.</p>
|
||||||
|
<div class="field field--choices" data-role="provider-picker-options"></div>
|
||||||
|
</div>
|
||||||
|
<footer class="modal__footer">
|
||||||
|
<button type="button" class="button button--ghost" data-role="provider-picker-cancel">Cancel</button>
|
||||||
|
<button type="button" class="button" data-role="provider-picker-confirm">Continue</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user