mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
feat: Kokoro 82M TTS engine + voice profile type system
Add Kokoro-82M as a new TTS engine — 82M params, CPU realtime, 8 languages, Apache 2.0. Unlike cloning engines, Kokoro uses pre-built voice styles, which required a new profile type system to support non-cloning engines cleanly. Kokoro engine: - New kokoro_backend.py implementing TTSBackend protocol - 50 built-in voices across en/es/fr/hi/it/pt/ja/zh - KPipeline API with language-aware G2P routing via misaki - PyInstaller bundling for misaki, language_tags, espeakng_loader, en_core_web_sm Voice profile type system: - New voice_type column: 'cloned' | 'preset' | 'designed' (future) - Preset profiles store engine + voice ID instead of audio samples - default_engine field on profiles — auto-selects engine on profile pick - Create Voice dialog: toggle between 'Clone from audio' and 'Built-in voice' - Edit dialog shows preset voice info instead of sample list for preset profiles - Engine selector locks to preset engine when preset profile is selected - Profile grid filters by engine — shows Kokoro voices when Kokoro selected - Custom empty state when no preset profiles exist for selected engine Bug fixes: - Fix relative audio paths in DB causing 404s in production builds - config.set_data_dir() now resolves to absolute paths - Startup migration converts existing relative paths to absolute Also updates PROJECT_STATUS.md and tts-engines.mdx developer guide.
This commit is contained in:
@@ -167,6 +167,7 @@ TTS_ENGINES = {
|
||||
"chatterbox": "Chatterbox TTS",
|
||||
"chatterbox_turbo": "Chatterbox Turbo",
|
||||
"tada": "TADA",
|
||||
"kokoro": "Kokoro",
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +279,14 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]:
|
||||
size_mb=8000,
|
||||
languages=["en", "ar", "zh", "de", "es", "fr", "it", "ja", "pl", "pt"],
|
||||
),
|
||||
ModelConfig(
|
||||
model_name="kokoro",
|
||||
display_name="Kokoro 82M",
|
||||
engine="kokoro",
|
||||
hf_repo_id="hexgrad/Kokoro-82M",
|
||||
size_mb=350,
|
||||
languages=["en", "es", "fr", "hi", "it", "pt", "ja", "zh"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -515,6 +524,10 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
|
||||
from .hume_backend import HumeTadaBackend
|
||||
|
||||
backend = HumeTadaBackend()
|
||||
elif engine == "kokoro":
|
||||
from .kokoro_backend import KokoroTTSBackend
|
||||
|
||||
backend = KokoroTTSBackend()
|
||||
else:
|
||||
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Kokoro TTS backend implementation.
|
||||
|
||||
Wraps the Kokoro-82M model for fast, lightweight text-to-speech.
|
||||
82M parameters, CPU realtime, 24kHz output, Apache 2.0 license.
|
||||
|
||||
Kokoro uses pre-built voice style vectors (not traditional zero-shot cloning
|
||||
from arbitrary audio). Voice prompts are stored as deferred references to
|
||||
HF-hosted voice .pt files.
|
||||
|
||||
Languages supported (via misaki G2P):
|
||||
- American English (a), British English (b)
|
||||
- Spanish (e), French (f), Hindi (h), Italian (i), Portuguese (p)
|
||||
- Japanese (j) — requires misaki[ja]
|
||||
- Chinese (z) — requires misaki[zh]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from .base import (
|
||||
get_torch_device,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HuggingFace repo for model + voice detection
|
||||
KOKORO_HF_REPO = "hexgrad/Kokoro-82M"
|
||||
KOKORO_SAMPLE_RATE = 24000
|
||||
|
||||
# Default voice if none specified
|
||||
KOKORO_DEFAULT_VOICE = "af_heart"
|
||||
|
||||
# All available Kokoro voices: (voice_id, display_name, gender, lang_code)
|
||||
KOKORO_VOICES = [
|
||||
# American English female
|
||||
("af_alloy", "Alloy", "female", "en"),
|
||||
("af_aoede", "Aoede", "female", "en"),
|
||||
("af_bella", "Bella", "female", "en"),
|
||||
("af_heart", "Heart", "female", "en"),
|
||||
("af_jessica", "Jessica", "female", "en"),
|
||||
("af_kore", "Kore", "female", "en"),
|
||||
("af_nicole", "Nicole", "female", "en"),
|
||||
("af_nova", "Nova", "female", "en"),
|
||||
("af_river", "River", "female", "en"),
|
||||
("af_sarah", "Sarah", "female", "en"),
|
||||
("af_sky", "Sky", "female", "en"),
|
||||
# American English male
|
||||
("am_adam", "Adam", "male", "en"),
|
||||
("am_echo", "Echo", "male", "en"),
|
||||
("am_eric", "Eric", "male", "en"),
|
||||
("am_fenrir", "Fenrir", "male", "en"),
|
||||
("am_liam", "Liam", "male", "en"),
|
||||
("am_michael", "Michael", "male", "en"),
|
||||
("am_onyx", "Onyx", "male", "en"),
|
||||
("am_puck", "Puck", "male", "en"),
|
||||
("am_santa", "Santa", "male", "en"),
|
||||
# British English female
|
||||
("bf_alice", "Alice", "female", "en"),
|
||||
("bf_emma", "Emma", "female", "en"),
|
||||
("bf_isabella", "Isabella", "female", "en"),
|
||||
("bf_lily", "Lily", "female", "en"),
|
||||
# British English male
|
||||
("bm_daniel", "Daniel", "male", "en"),
|
||||
("bm_fable", "Fable", "male", "en"),
|
||||
("bm_george", "George", "male", "en"),
|
||||
("bm_lewis", "Lewis", "male", "en"),
|
||||
# Spanish
|
||||
("ef_dora", "Dora", "female", "es"),
|
||||
("em_alex", "Alex", "male", "es"),
|
||||
("em_santa", "Santa", "male", "es"),
|
||||
# French
|
||||
("ff_siwis", "Siwis", "female", "fr"),
|
||||
# Hindi
|
||||
("hf_alpha", "Alpha", "female", "hi"),
|
||||
("hf_beta", "Beta", "female", "hi"),
|
||||
("hm_omega", "Omega", "male", "hi"),
|
||||
("hm_psi", "Psi", "male", "hi"),
|
||||
# Italian
|
||||
("if_sara", "Sara", "female", "it"),
|
||||
("im_nicola", "Nicola", "male", "it"),
|
||||
# Japanese
|
||||
("jf_alpha", "Alpha", "female", "ja"),
|
||||
("jf_gongitsune", "Gongitsune", "female", "ja"),
|
||||
("jf_nezumi", "Nezumi", "female", "ja"),
|
||||
("jf_tebukuro", "Tebukuro", "female", "ja"),
|
||||
("jm_kumo", "Kumo", "male", "ja"),
|
||||
# Portuguese
|
||||
("pf_dora", "Dora", "female", "pt"),
|
||||
("pm_alex", "Alex", "male", "pt"),
|
||||
("pm_santa", "Santa", "male", "pt"),
|
||||
# Chinese
|
||||
("zf_xiaobei", "Xiaobei", "female", "zh"),
|
||||
("zf_xiaoni", "Xiaoni", "female", "zh"),
|
||||
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
|
||||
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
|
||||
]
|
||||
|
||||
# Map our ISO language codes to Kokoro lang_code characters
|
||||
LANG_CODE_MAP = {
|
||||
"en": "a", # American English
|
||||
"es": "e",
|
||||
"fr": "f",
|
||||
"hi": "h",
|
||||
"it": "i",
|
||||
"pt": "p",
|
||||
"ja": "j",
|
||||
"zh": "z",
|
||||
}
|
||||
|
||||
|
||||
class KokoroTTSBackend:
|
||||
"""Kokoro-82M TTS backend — tiny, fast, CPU-friendly."""
|
||||
|
||||
def __init__(self):
|
||||
self._model = None
|
||||
self._pipelines: dict = {} # lang_code -> KPipeline
|
||||
self._device: Optional[str] = None
|
||||
self.model_size = "default"
|
||||
|
||||
def _get_device(self) -> str:
|
||||
"""Select device. Kokoro supports CUDA and CPU. MPS needs fallback env var."""
|
||||
device = get_torch_device(allow_mps=False)
|
||||
# Kokoro can use MPS but requires PYTORCH_ENABLE_MPS_FALLBACK=1
|
||||
# For now, skip MPS to avoid user confusion — CPU is already realtime
|
||||
return device
|
||||
|
||||
@property
|
||||
def device(self) -> str:
|
||||
if self._device is None:
|
||||
self._device = self._get_device()
|
||||
return self._device
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self._model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
return KOKORO_HF_REPO
|
||||
|
||||
def _is_model_cached(self, model_size: str = "default") -> bool:
|
||||
"""Check if Kokoro model files are cached locally."""
|
||||
from .base import is_model_cached
|
||||
|
||||
return is_model_cached(
|
||||
KOKORO_HF_REPO,
|
||||
required_files=["config.json", "kokoro-v1_0.pth"],
|
||||
)
|
||||
|
||||
async def load_model(self, model_size: str = "default") -> None:
|
||||
"""Load the Kokoro model."""
|
||||
if self._model is not None:
|
||||
return
|
||||
await asyncio.to_thread(self._load_model_sync)
|
||||
|
||||
def _load_model_sync(self):
|
||||
"""Synchronous model loading."""
|
||||
model_name = "kokoro"
|
||||
is_cached = self._is_model_cached()
|
||||
|
||||
with model_load_progress(model_name, is_cached):
|
||||
from kokoro import KModel
|
||||
|
||||
device = self.device
|
||||
logger.info(f"Loading Kokoro-82M on {device}...")
|
||||
|
||||
self._model = KModel(repo_id=KOKORO_HF_REPO).to(device).eval()
|
||||
|
||||
logger.info("Kokoro-82M loaded successfully")
|
||||
|
||||
def _get_pipeline(self, lang_code: str):
|
||||
"""Get or create a KPipeline for the given language code."""
|
||||
kokoro_lang = LANG_CODE_MAP.get(lang_code, "a")
|
||||
|
||||
if kokoro_lang not in self._pipelines:
|
||||
from kokoro import KPipeline
|
||||
|
||||
# Create pipeline with our existing model (no redundant model loading)
|
||||
self._pipelines[kokoro_lang] = KPipeline(
|
||||
lang_code=kokoro_lang,
|
||||
repo_id=KOKORO_HF_REPO,
|
||||
model=self._model,
|
||||
)
|
||||
|
||||
return self._pipelines[kokoro_lang]
|
||||
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
if self._model is not None:
|
||||
del self._model
|
||||
self._model = None
|
||||
self._pipelines.clear()
|
||||
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
logger.info("Kokoro unloaded")
|
||||
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt for Kokoro.
|
||||
|
||||
Kokoro doesn't do traditional voice cloning from arbitrary audio.
|
||||
When called for a cloned profile (fallback), uses the default voice.
|
||||
For preset profiles, the voice_prompt dict is built by the profile
|
||||
service and bypasses this method entirely.
|
||||
"""
|
||||
return {
|
||||
"voice_type": "preset",
|
||||
"preset_engine": "kokoro",
|
||||
"preset_voice_id": KOKORO_DEFAULT_VOICE,
|
||||
}, False
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
"""Combine voice prompts — uses base implementation for audio concatenation."""
|
||||
return await _combine_voice_prompts(
|
||||
audio_paths, reference_texts, sample_rate=KOKORO_SAMPLE_RATE
|
||||
)
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio from text using Kokoro.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice_prompt: Dict with kokoro_voice key
|
||||
language: Language code
|
||||
seed: Random seed for reproducibility
|
||||
instruct: Not supported by Kokoro (ignored)
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_array, sample_rate)
|
||||
"""
|
||||
await self.load_model()
|
||||
|
||||
voice_name = voice_prompt.get("preset_voice_id") or voice_prompt.get("kokoro_voice") or KOKORO_DEFAULT_VOICE
|
||||
|
||||
def _generate_sync():
|
||||
import torch
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
|
||||
pipeline = self._get_pipeline(language)
|
||||
|
||||
# Generate all chunks and concatenate
|
||||
audio_chunks = []
|
||||
for result in pipeline(text, voice=voice_name, speed=1.0):
|
||||
if result.audio is not None:
|
||||
chunk = result.audio
|
||||
if isinstance(chunk, torch.Tensor):
|
||||
chunk = chunk.detach().cpu().numpy()
|
||||
audio_chunks.append(chunk.squeeze())
|
||||
|
||||
if not audio_chunks:
|
||||
# Return 1 second of silence as fallback
|
||||
return np.zeros(KOKORO_SAMPLE_RATE, dtype=np.float32), KOKORO_SAMPLE_RATE
|
||||
|
||||
audio = np.concatenate(audio_chunks)
|
||||
return audio.astype(np.float32), KOKORO_SAMPLE_RATE
|
||||
|
||||
return await asyncio.to_thread(_generate_sync)
|
||||
@@ -228,6 +228,44 @@ def build_server(cuda=False):
|
||||
"torchaudio",
|
||||
"--collect-submodules",
|
||||
"tada",
|
||||
# Kokoro 82M — lightweight TTS engine using misaki G2P
|
||||
"--hidden-import",
|
||||
"backend.backends.kokoro_backend",
|
||||
"--hidden-import",
|
||||
"kokoro",
|
||||
"--hidden-import",
|
||||
"kokoro.pipeline",
|
||||
"--hidden-import",
|
||||
"kokoro.model",
|
||||
"--hidden-import",
|
||||
"kokoro.istftnet",
|
||||
"--hidden-import",
|
||||
"kokoro.modules",
|
||||
"--hidden-import",
|
||||
"kokoro.custom_stft",
|
||||
# misaki ships G2P data files (dictionaries, phoneme tables)
|
||||
# that must be bundled for espeak/en/ja/zh G2P to work
|
||||
"--collect-all",
|
||||
"misaki",
|
||||
# language_tags ships JSON data files (index.json etc.) loaded at
|
||||
# runtime via: misaki → phonemizer → segments → csvw → language_tags
|
||||
"--collect-all",
|
||||
"language_tags",
|
||||
# espeakng_loader ships the entire espeak-ng-data directory (369 files)
|
||||
# loaded at import time by misaki.espeak via get_data_path()
|
||||
"--collect-all",
|
||||
"espeakng_loader",
|
||||
# spacy en_core_web_sm model — misaki.en tries to spacy.cli.download()
|
||||
# at runtime if not found, which calls pip as a subprocess and crashes
|
||||
# the frozen binary. Bundle the model so spacy.util.is_package() passes.
|
||||
"--collect-all",
|
||||
"en_core_web_sm",
|
||||
"--copy-metadata",
|
||||
"en_core_web_sm",
|
||||
"--hidden-import",
|
||||
"en_core_web_sm",
|
||||
"--hidden-import",
|
||||
"loguru",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ if _custom_models_dir:
|
||||
logger.info("Model download path set to: %s", _custom_models_dir)
|
||||
|
||||
# Default data directory (used in development)
|
||||
_data_dir = Path("data")
|
||||
_data_dir = Path("data").resolve()
|
||||
|
||||
|
||||
def set_data_dir(path: str | Path):
|
||||
@@ -30,9 +30,9 @@ def set_data_dir(path: str | Path):
|
||||
path: Path to the data directory
|
||||
"""
|
||||
global _data_dir
|
||||
_data_dir = Path(path)
|
||||
_data_dir = Path(path).resolve()
|
||||
_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Data directory set to: %s", _data_dir.absolute())
|
||||
logger.info("Data directory set to: %s", _data_dir)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
|
||||
@@ -34,6 +34,7 @@ def run_migrations(engine) -> None:
|
||||
_migrate_generations(engine, inspector, tables)
|
||||
_migrate_effect_presets(engine, inspector, tables)
|
||||
_migrate_generation_versions(engine, inspector, tables)
|
||||
_resolve_relative_paths(engine, tables)
|
||||
|
||||
|
||||
# -- helpers ---------------------------------------------------------------
|
||||
@@ -134,6 +135,17 @@ def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
||||
_add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
|
||||
if "effects_chain" not in columns:
|
||||
_add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
|
||||
# Voice type system — v0.3.x
|
||||
if "voice_type" not in columns:
|
||||
_add_column(engine, "profiles", "voice_type VARCHAR DEFAULT 'cloned'", "voice_type")
|
||||
if "preset_engine" not in columns:
|
||||
_add_column(engine, "profiles", "preset_engine VARCHAR", "preset_engine")
|
||||
if "preset_voice_id" not in columns:
|
||||
_add_column(engine, "profiles", "preset_voice_id VARCHAR", "preset_voice_id")
|
||||
if "design_prompt" not in columns:
|
||||
_add_column(engine, "profiles", "design_prompt TEXT", "design_prompt")
|
||||
if "default_engine" not in columns:
|
||||
_add_column(engine, "profiles", "default_engine VARCHAR", "default_engine")
|
||||
|
||||
|
||||
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
|
||||
@@ -168,3 +180,47 @@ def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
|
||||
columns = _get_columns(inspector, "generation_versions")
|
||||
if "source_version_id" not in columns:
|
||||
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
|
||||
|
||||
|
||||
def _resolve_relative_paths(engine, tables: set[str]) -> None:
|
||||
"""Resolve any relative file paths in the database to absolute paths.
|
||||
|
||||
Earlier versions stored paths relative to CWD (e.g. "data/generations/abc.wav").
|
||||
These break when the production binary's CWD differs from the data directory.
|
||||
This migration converts them to absolute paths using the configured data dir.
|
||||
Idempotent: absolute paths are left untouched.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
path_columns = [
|
||||
("generations", "audio_path"),
|
||||
("generation_versions", "audio_path"),
|
||||
("profile_samples", "audio_path"),
|
||||
("profiles", "avatar_path"),
|
||||
]
|
||||
|
||||
total_fixed = 0
|
||||
with engine.connect() as conn:
|
||||
for table, column in path_columns:
|
||||
if table not in tables:
|
||||
continue
|
||||
rows = conn.execute(
|
||||
text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL")
|
||||
).fetchall()
|
||||
for row_id, path_val in rows:
|
||||
if not path_val:
|
||||
continue
|
||||
p = Path(path_val)
|
||||
if p.is_absolute():
|
||||
continue
|
||||
# Resolve relative to CWD (which is where they were created)
|
||||
resolved = p.resolve()
|
||||
if resolved.exists():
|
||||
conn.execute(
|
||||
text(f"UPDATE {table} SET {column} = :path WHERE id = :id"),
|
||||
{"path": str(resolved), "id": row_id},
|
||||
)
|
||||
total_fixed += 1
|
||||
if total_fixed > 0:
|
||||
conn.commit()
|
||||
logger.info("Resolved %d relative file paths to absolute", total_fixed)
|
||||
|
||||
@@ -10,7 +10,13 @@ Base = declarative_base()
|
||||
|
||||
|
||||
class VoiceProfile(Base):
|
||||
"""Voice profile."""
|
||||
"""Voice profile.
|
||||
|
||||
voice_type discriminates three flavours:
|
||||
- "cloned" — traditional reference-audio profiles (all cloning engines)
|
||||
- "preset" — engine-specific pre-built voice (e.g. Kokoro voices)
|
||||
- "designed" — text-described voice (e.g. Qwen CustomVoice, future)
|
||||
"""
|
||||
|
||||
__tablename__ = "profiles"
|
||||
|
||||
@@ -20,6 +26,14 @@ class VoiceProfile(Base):
|
||||
language = Column(String, default="en")
|
||||
avatar_path = Column(String, nullable=True)
|
||||
effects_chain = Column(Text, nullable=True)
|
||||
|
||||
# Voice type system — added v0.3.x
|
||||
voice_type = Column(String, default="cloned") # "cloned" | "preset" | "designed"
|
||||
preset_engine = Column(String, nullable=True) # e.g. "kokoro" — only for preset
|
||||
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
|
||||
design_prompt = Column(Text, nullable=True) # text description — only for designed
|
||||
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
+11
-1
@@ -15,6 +15,11 @@ class VoiceProfileCreate(BaseModel):
|
||||
language: str = Field(
|
||||
default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$"
|
||||
)
|
||||
voice_type: Optional[str] = Field(default="cloned", pattern="^(cloned|preset|designed)$")
|
||||
preset_engine: Optional[str] = Field(None, max_length=50)
|
||||
preset_voice_id: Optional[str] = Field(None, max_length=100)
|
||||
design_prompt: Optional[str] = Field(None, max_length=2000)
|
||||
default_engine: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class VoiceProfileResponse(BaseModel):
|
||||
@@ -26,6 +31,11 @@ class VoiceProfileResponse(BaseModel):
|
||||
language: str
|
||||
avatar_path: Optional[str] = None
|
||||
effects_chain: Optional[List["EffectConfig"]] = None
|
||||
voice_type: str = "cloned"
|
||||
preset_engine: Optional[str] = None
|
||||
preset_voice_id: Optional[str] = None
|
||||
design_prompt: Optional[str] = None
|
||||
default_engine: Optional[str] = None
|
||||
generation_count: int = 0
|
||||
sample_count: int = 0
|
||||
created_at: datetime
|
||||
@@ -68,7 +78,7 @@ class GenerationRequest(BaseModel):
|
||||
seed: Optional[int] = Field(None, ge=0)
|
||||
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
|
||||
instruct: Optional[str] = Field(None, max_length=500)
|
||||
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada)$")
|
||||
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
|
||||
max_chunk_chars: int = Field(
|
||||
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
|
||||
)
|
||||
|
||||
@@ -40,6 +40,13 @@ pyloudnorm
|
||||
# provides the only class TADA uses: Snake1d.)
|
||||
torchaudio
|
||||
|
||||
# Kokoro TTS (lightweight 82M-param engine)
|
||||
kokoro>=0.9.4
|
||||
misaki[en]>=0.9.4
|
||||
# spacy model for misaki English G2P — must be pre-installed or misaki
|
||||
# tries spacy.cli.download() at runtime which crashes frozen builds
|
||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
||||
|
||||
# Audio processing
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.0
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Voice profile endpoints."""
|
||||
|
||||
import io
|
||||
import json as _json
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
@@ -15,6 +19,8 @@ from ..database import VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services import channels, export_import, profiles
|
||||
from ..services.profiles import _profile_to_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -62,6 +68,97 @@ async def import_profile(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Preset Voice Endpoints ───────────────────────────────────────────
|
||||
# These MUST be declared before /profiles/{profile_id} to avoid the
|
||||
# wildcard swallowing "presets" as a profile_id.
|
||||
|
||||
|
||||
@router.get("/profiles/presets/{engine}")
|
||||
async def list_preset_voices(engine: str):
|
||||
"""List available preset voices for an engine."""
|
||||
if engine == "kokoro":
|
||||
from ..backends.kokoro_backend import KOKORO_VOICES
|
||||
|
||||
return {
|
||||
"engine": engine,
|
||||
"voices": [
|
||||
{
|
||||
"voice_id": vid,
|
||||
"name": name,
|
||||
"gender": gender,
|
||||
"language": lang,
|
||||
}
|
||||
for vid, name, gender, lang in KOKORO_VOICES
|
||||
],
|
||||
}
|
||||
return {"engine": engine, "voices": []}
|
||||
|
||||
|
||||
@router.post("/profiles/presets/{engine}/seed")
|
||||
async def seed_preset_profiles_route(
|
||||
engine: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Seed preset voice profiles for an engine.
|
||||
|
||||
Creates profiles for all available preset voices that don't already exist.
|
||||
Returns the count of newly created profiles.
|
||||
"""
|
||||
if engine != "kokoro":
|
||||
raise HTTPException(status_code=400, detail=f"No presets available for engine: {engine}")
|
||||
|
||||
try:
|
||||
from ..backends.kokoro_backend import KOKORO_VOICES
|
||||
|
||||
created = 0
|
||||
for voice_id, display_name, gender, lang in KOKORO_VOICES:
|
||||
profile_name = display_name
|
||||
|
||||
# Disambiguate duplicate display names across languages
|
||||
# (e.g. "Alpha" exists in Hindi and Japanese, "Dora" in Spanish and Portuguese)
|
||||
dupes = [v for v in KOKORO_VOICES if v[1] == display_name]
|
||||
if len(dupes) > 1:
|
||||
lang_labels = {"en": "English", "es": "Spanish", "fr": "French", "hi": "Hindi",
|
||||
"it": "Italian", "pt": "Portuguese", "ja": "Japanese", "zh": "Chinese"}
|
||||
profile_name = f"{display_name} {lang_labels.get(lang, lang)}"
|
||||
|
||||
# Skip if preset already exists
|
||||
existing = (
|
||||
db.query(DBVoiceProfile)
|
||||
.filter_by(preset_engine="kokoro", preset_voice_id=voice_id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Skip name collisions
|
||||
if db.query(DBVoiceProfile).filter_by(name=profile_name).first():
|
||||
continue
|
||||
|
||||
profile = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=profile_name,
|
||||
description=f"Kokoro preset voice — {display_name} ({gender})",
|
||||
language=lang,
|
||||
voice_type="preset",
|
||||
preset_engine="kokoro",
|
||||
preset_voice_id=voice_id,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(profile)
|
||||
created += 1
|
||||
|
||||
if created > 0:
|
||||
db.commit()
|
||||
logger.info(f"Seeded {created} Kokoro preset profiles")
|
||||
|
||||
return {"engine": engine, "created": created, "total_available": len(KOKORO_VOICES)}
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to seed Kokoro profiles: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
|
||||
async def get_profile(
|
||||
profile_id: str,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Voice profile management module.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -10,6 +11,8 @@ from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, select
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..models import (
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
@@ -52,6 +55,11 @@ def _profile_to_response(
|
||||
language=profile.language,
|
||||
avatar_path=profile.avatar_path,
|
||||
effects_chain=effects_chain,
|
||||
voice_type=getattr(profile, "voice_type", None) or "cloned",
|
||||
preset_engine=getattr(profile, "preset_engine", None),
|
||||
preset_voice_id=getattr(profile, "preset_voice_id", None),
|
||||
design_prompt=getattr(profile, "design_prompt", None),
|
||||
default_engine=getattr(profile, "default_engine", None),
|
||||
generation_count=generation_count,
|
||||
sample_count=sample_count,
|
||||
created_at=profile.created_at,
|
||||
@@ -80,11 +88,22 @@ async def create_profile(
|
||||
if existing_profile:
|
||||
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
|
||||
|
||||
# Auto-set default_engine for preset profiles
|
||||
default_engine = data.default_engine
|
||||
voice_type = data.voice_type or "cloned"
|
||||
if voice_type == "preset" and data.preset_engine and not default_engine:
|
||||
default_engine = data.preset_engine
|
||||
|
||||
db_profile = DBVoiceProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
language=data.language,
|
||||
voice_type=voice_type,
|
||||
preset_engine=data.preset_engine,
|
||||
preset_voice_id=data.preset_voice_id,
|
||||
design_prompt=data.design_prompt,
|
||||
default_engine=default_engine,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
@@ -265,6 +284,8 @@ async def update_profile(
|
||||
profile.name = data.name
|
||||
profile.description = data.description
|
||||
profile.language = data.language
|
||||
if data.default_engine is not None:
|
||||
profile.default_engine = data.default_engine or None # empty string → NULL
|
||||
profile.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
@@ -382,19 +403,45 @@ async def create_voice_prompt_for_profile(
|
||||
engine: str = "qwen",
|
||||
) -> dict:
|
||||
"""
|
||||
Create a combined voice prompt from all samples in a profile.
|
||||
Create a voice prompt from a profile.
|
||||
|
||||
For cloned profiles: combines all audio samples into a voice prompt.
|
||||
For preset profiles: returns the engine-specific preset voice reference.
|
||||
For designed profiles: returns the text design prompt (future).
|
||||
|
||||
Args:
|
||||
profile_id: Profile ID
|
||||
db: Database session
|
||||
use_cache: Whether to use cached prompts
|
||||
engine: TTS engine to create prompt for ("qwen" or "luxtts")
|
||||
engine: TTS engine to create prompt for
|
||||
|
||||
Returns:
|
||||
Voice prompt dictionary
|
||||
"""
|
||||
from ..backends import get_tts_backend_for_engine
|
||||
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile not found: {profile_id}")
|
||||
|
||||
voice_type = getattr(profile, "voice_type", None) or "cloned"
|
||||
|
||||
# ── Preset profiles: return engine-specific voice reference ──
|
||||
if voice_type == "preset":
|
||||
return {
|
||||
"voice_type": "preset",
|
||||
"preset_engine": profile.preset_engine,
|
||||
"preset_voice_id": profile.preset_voice_id,
|
||||
}
|
||||
|
||||
# ── Designed profiles: return text description (future) ──
|
||||
if voice_type == "designed":
|
||||
return {
|
||||
"voice_type": "designed",
|
||||
"design_prompt": profile.design_prompt,
|
||||
}
|
||||
|
||||
# ── Cloned profiles: create from audio samples ──
|
||||
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
|
||||
|
||||
if not samples:
|
||||
@@ -524,3 +571,6 @@ async def delete_avatar(
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'kokoro', 'kokoro.pipeline', 'kokoro.model', 'kokoro.istftnet', 'kokoro.modules', 'kokoro.custom_stft', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += copy_metadata('qwen-tts')
|
||||
datas += copy_metadata('requests')
|
||||
datas += copy_metadata('transformers')
|
||||
@@ -15,8 +13,9 @@ datas += copy_metadata('huggingface-hub')
|
||||
datas += copy_metadata('tokenizers')
|
||||
datas += copy_metadata('safetensors')
|
||||
datas += copy_metadata('tqdm')
|
||||
hiddenimports += collect_submodules('qwen_tts')
|
||||
datas += copy_metadata('en_core_web_sm')
|
||||
hiddenimports += collect_submodules('jaraco')
|
||||
hiddenimports += collect_submodules('tada')
|
||||
hiddenimports += collect_submodules('mlx')
|
||||
hiddenimports += collect_submodules('mlx_audio')
|
||||
tmp_ret = collect_all('zipvoice')
|
||||
@@ -27,12 +26,22 @@ tmp_ret = collect_all('lazy_loader')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('librosa')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('qwen_tts')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('inflect')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('perth')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('piper_phonemize')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('misaki')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('language_tags')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('espeakng_loader')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('en_core_web_sm')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx_audio')
|
||||
|
||||
Reference in New Issue
Block a user