comment cleanup

This commit is contained in:
James Pine
2026-03-16 01:46:19 -07:00
parent fe19a9ca47
commit b7781951df
10 changed files with 738 additions and 694 deletions
+83 -24
View File
@@ -14,9 +14,16 @@ import numpy as np
from ..platform_detect import get_backend_type from ..platform_detect import get_backend_type
LANGUAGE_CODE_TO_NAME = { LANGUAGE_CODE_TO_NAME = {
"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean", "zh": "chinese",
"de": "german", "fr": "french", "ru": "russian", "pt": "portuguese", "en": "english",
"es": "spanish", "it": "italian", "ja": "japanese",
"ko": "korean",
"de": "german",
"fr": "french",
"ru": "russian",
"pt": "portuguese",
"es": "spanish",
"it": "italian",
} }
WHISPER_HF_REPOS = { WHISPER_HF_REPOS = {
@@ -31,10 +38,11 @@ WHISPER_HF_REPOS = {
@dataclass @dataclass
class ModelConfig: class ModelConfig:
"""Declarative config for a downloadable model variant.""" """Declarative config for a downloadable model variant."""
model_name: str # e.g. "luxtts", "chatterbox-tts"
display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)" model_name: str # e.g. "luxtts", "chatterbox-tts"
engine: str # e.g. "luxtts", "chatterbox" display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)"
hf_repo_id: str # e.g. "YatharthS/LuxTTS" engine: str # e.g. "luxtts", "chatterbox"
hf_repo_id: str # e.g. "YatharthS/LuxTTS"
model_size: str = "default" model_size: str = "default"
size_mb: int = 0 size_mb: int = 0
needs_trim: bool = False needs_trim: bool = False
@@ -160,10 +168,6 @@ TTS_ENGINES = {
} }
# ---------------------------------------------------------------------------
# Model config registry
# ---------------------------------------------------------------------------
def _get_qwen_model_configs() -> list[ModelConfig]: def _get_qwen_model_configs() -> list[ModelConfig]:
"""Return Qwen model configs with backend-aware HF repo IDs.""" """Return Qwen model configs with backend-aware HF repo IDs."""
backend_type = get_backend_type() backend_type = get_backend_type()
@@ -220,9 +224,29 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]:
size_mb=3200, size_mb=3200,
needs_trim=True, needs_trim=True,
languages=[ languages=[
"zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it", "zh",
"he", "ar", "da", "el", "fi", "hi", "ms", "nl", "no", "pl", "en",
"sv", "sw", "tr", "ja",
"ko",
"de",
"fr",
"ru",
"pt",
"es",
"it",
"he",
"ar",
"da",
"el",
"fi",
"hi",
"ms",
"nl",
"no",
"pl",
"sv",
"sw",
"tr",
], ],
), ),
ModelConfig( ModelConfig(
@@ -240,11 +264,41 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]:
def _get_whisper_configs() -> list[ModelConfig]: def _get_whisper_configs() -> list[ModelConfig]:
"""Return Whisper STT model configs.""" """Return Whisper STT model configs."""
return [ return [
ModelConfig(model_name="whisper-base", display_name="Whisper Base", engine="whisper", hf_repo_id="openai/whisper-base", model_size="base"), ModelConfig(
ModelConfig(model_name="whisper-small", display_name="Whisper Small", engine="whisper", hf_repo_id="openai/whisper-small", model_size="small"), model_name="whisper-base",
ModelConfig(model_name="whisper-medium", display_name="Whisper Medium", engine="whisper", hf_repo_id="openai/whisper-medium", model_size="medium"), display_name="Whisper Base",
ModelConfig(model_name="whisper-large", display_name="Whisper Large", engine="whisper", hf_repo_id="openai/whisper-large-v3", model_size="large"), engine="whisper",
ModelConfig(model_name="whisper-turbo", display_name="Whisper Turbo", engine="whisper", hf_repo_id="openai/whisper-large-v3-turbo", model_size="turbo"), hf_repo_id="openai/whisper-base",
model_size="base",
),
ModelConfig(
model_name="whisper-small",
display_name="Whisper Small",
engine="whisper",
hf_repo_id="openai/whisper-small",
model_size="small",
),
ModelConfig(
model_name="whisper-medium",
display_name="Whisper Medium",
engine="whisper",
hf_repo_id="openai/whisper-medium",
model_size="medium",
),
ModelConfig(
model_name="whisper-large",
display_name="Whisper Large",
engine="whisper",
hf_repo_id="openai/whisper-large-v3",
model_size="large",
),
ModelConfig(
model_name="whisper-turbo",
display_name="Whisper Turbo",
engine="whisper",
hf_repo_id="openai/whisper-large-v3-turbo",
model_size="turbo",
),
] ]
@@ -260,6 +314,7 @@ def get_tts_model_configs() -> list[ModelConfig]:
# Lookup helpers — these replace the if/elif chains in main.py # Lookup helpers — these replace the if/elif chains in main.py
def get_model_config(model_name: str) -> Optional[ModelConfig]: def get_model_config(model_name: str) -> Optional[ModelConfig]:
"""Look up a model config by model_name.""" """Look up a model config by model_name."""
for cfg in get_all_model_configs(): for cfg in get_all_model_configs():
@@ -294,6 +349,7 @@ async def load_engine_model(engine: str, model_size: str = "default") -> None:
async def ensure_model_cached_or_raise(engine: str, model_size: str = "default") -> None: async def ensure_model_cached_or_raise(engine: str, model_size: str = "default") -> None:
"""Check if a model is cached, raise HTTPException if not. Used by streaming endpoint.""" """Check if a model is cached, raise HTTPException if not. Used by streaming endpoint."""
from fastapi import HTTPException from fastapi import HTTPException
backend = get_tts_backend_for_engine(engine) backend = get_tts_backend_for_engine(engine)
cfg = None cfg = None
for c in get_tts_model_configs(): for c in get_tts_model_configs():
@@ -352,7 +408,7 @@ def check_model_loaded(config: ModelConfig) -> bool:
try: try:
if config.engine == "whisper": if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model() whisper_model = transcribe.get_whisper_model()
return whisper_model.is_loaded() and getattr(whisper_model, 'model_size', None) == config.model_size return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size
if config.engine == "qwen": if config.engine == "qwen":
tts_model = tts.get_tts_model() tts_model = tts.get_tts_model()
@@ -379,10 +435,6 @@ def get_model_load_func(config: ModelConfig):
return lambda: get_tts_backend_for_engine(config.engine).load_model() return lambda: get_tts_backend_for_engine(config.engine).load_model()
# ---------------------------------------------------------------------------
# Backend factory
# ---------------------------------------------------------------------------
def get_tts_backend() -> TTSBackend: def get_tts_backend() -> TTSBackend:
""" """
Get or create the default (Qwen) TTS backend instance based on platform. Get or create the default (Qwen) TTS backend instance based on platform.
@@ -419,18 +471,23 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
backend_type = get_backend_type() backend_type = get_backend_type()
if backend_type == "mlx": if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend from .mlx_backend import MLXTTSBackend
backend = MLXTTSBackend() backend = MLXTTSBackend()
else: else:
from .pytorch_backend import PyTorchTTSBackend from .pytorch_backend import PyTorchTTSBackend
backend = PyTorchTTSBackend() backend = PyTorchTTSBackend()
elif engine == "luxtts": elif engine == "luxtts":
from .luxtts_backend import LuxTTSBackend from .luxtts_backend import LuxTTSBackend
backend = LuxTTSBackend() backend = LuxTTSBackend()
elif engine == "chatterbox": elif engine == "chatterbox":
from .chatterbox_backend import ChatterboxTTSBackend from .chatterbox_backend import ChatterboxTTSBackend
backend = ChatterboxTTSBackend() backend = ChatterboxTTSBackend()
elif engine == "chatterbox_turbo": elif engine == "chatterbox_turbo":
from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend
backend = ChatterboxTurboTTSBackend() backend = ChatterboxTurboTTSBackend()
else: else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}") raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
@@ -453,9 +510,11 @@ def get_stt_backend() -> STTBackend:
if backend_type == "mlx": if backend_type == "mlx":
from .mlx_backend import MLXSTTBackend from .mlx_backend import MLXSTTBackend
_stt_backend = MLXSTTBackend() _stt_backend = MLXSTTBackend()
else: else:
from .pytorch_backend import PyTorchSTTBackend from .pytorch_backend import PyTorchSTTBackend
_stt_backend = PyTorchSTTBackend() _stt_backend = PyTorchSTTBackend()
return _stt_backend return _stt_backend
+4 -23
View File
@@ -21,10 +21,6 @@ from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# HuggingFace cache checking
# ---------------------------------------------------------------------------
def is_model_cached( def is_model_cached(
hf_repo: str, hf_repo: str,
*, *,
@@ -46,9 +42,7 @@ def is_model_cached(
try: try:
from huggingface_hub import constants as hf_constants from huggingface_hub import constants as hf_constants
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ( repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
"models--" + hf_repo.replace("/", "--")
)
if not repo_cache.exists(): if not repo_cache.exists():
return False return False
@@ -83,10 +77,6 @@ def is_model_cached(
return False return False
# ---------------------------------------------------------------------------
# Device detection
# ---------------------------------------------------------------------------
def get_torch_device( def get_torch_device(
*, *,
allow_xpu: bool = False, allow_xpu: bool = False,
@@ -114,6 +104,7 @@ def get_torch_device(
if allow_xpu: if allow_xpu:
try: try:
import intel_extension_for_pytorch # noqa: F401 import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, "xpu") and torch.xpu.is_available(): if hasattr(torch, "xpu") and torch.xpu.is_available():
return "xpu" return "xpu"
except ImportError: except ImportError:
@@ -122,6 +113,7 @@ def get_torch_device(
if allow_directml: if allow_directml:
try: try:
import torch_directml import torch_directml
if torch_directml.device_count() > 0: if torch_directml.device_count() > 0:
return torch_directml.device(0) return torch_directml.device(0)
except ImportError: except ImportError:
@@ -134,10 +126,6 @@ def get_torch_device(
return "cpu" return "cpu"
# ---------------------------------------------------------------------------
# Voice prompt combination
# ---------------------------------------------------------------------------
async def combine_voice_prompts( async def combine_voice_prompts(
audio_paths: List[str], audio_paths: List[str],
reference_texts: List[str], reference_texts: List[str],
@@ -169,10 +157,6 @@ async def combine_voice_prompts(
return mixed, combined_text return mixed, combined_text
# ---------------------------------------------------------------------------
# Model loading progress tracking
# ---------------------------------------------------------------------------
@contextmanager @contextmanager
def model_load_progress( def model_load_progress(
model_name: str, model_name: str,
@@ -237,10 +221,6 @@ def model_load_progress(
tracker_context.__exit__(None, None, None) tracker_context.__exit__(None, None, None)
# ---------------------------------------------------------------------------
# Chatterbox f32 dtype patches
# ---------------------------------------------------------------------------
def patch_chatterbox_f32(model) -> None: def patch_chatterbox_f32(model) -> None:
""" """
Patch float64 -> float32 dtype mismatches in upstream chatterbox. Patch float64 -> float32 dtype mismatches in upstream chatterbox.
@@ -261,6 +241,7 @@ def patch_chatterbox_f32(model) -> None:
def _f32_log_mel(self_tokzr, audio, padding=0): def _f32_log_mel(self_tokzr, audio, padding=0):
import torch as _torch import torch as _torch
if _torch.is_tensor(audio): if _torch.is_tensor(audio):
audio = audio.float() audio = audio.float()
return _orig_log_mel(self_tokzr, audio, padding) return _orig_log_mel(self_tokzr, audio, padding)
+292 -302
View File
File diff suppressed because it is too large Load Diff
+68 -12
View File
@@ -9,13 +9,17 @@ from datetime import datetime
class VoiceProfileCreate(BaseModel): class VoiceProfileCreate(BaseModel):
"""Request model for creating a voice profile.""" """Request model for creating a voice profile."""
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500)
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)$") 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)$"
)
class VoiceProfileResponse(BaseModel): class VoiceProfileResponse(BaseModel):
"""Response model for voice profile.""" """Response model for voice profile."""
id: str id: str
name: str name: str
description: Optional[str] description: Optional[str]
@@ -33,16 +37,19 @@ class VoiceProfileResponse(BaseModel):
class ProfileSampleCreate(BaseModel): class ProfileSampleCreate(BaseModel):
"""Request model for adding a sample to a profile.""" """Request model for adding a sample to a profile."""
reference_text: str = Field(..., min_length=1, max_length=1000) reference_text: str = Field(..., min_length=1, max_length=1000)
class ProfileSampleUpdate(BaseModel): class ProfileSampleUpdate(BaseModel):
"""Request model for updating a profile sample.""" """Request model for updating a profile sample."""
reference_text: str = Field(..., min_length=1, max_length=1000) reference_text: str = Field(..., min_length=1, max_length=1000)
class ProfileSampleResponse(BaseModel): class ProfileSampleResponse(BaseModel):
"""Response model for profile sample.""" """Response model for profile sample."""
id: str id: str
profile_id: str profile_id: str
audio_path: str audio_path: str
@@ -54,6 +61,7 @@ class ProfileSampleResponse(BaseModel):
class GenerationRequest(BaseModel): class GenerationRequest(BaseModel):
"""Request model for voice generation.""" """Request model for voice generation."""
profile_id: str profile_id: str
text: str = Field(..., min_length=1, max_length=50000) text: str = Field(..., min_length=1, max_length=50000)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$") language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
@@ -61,14 +69,21 @@ class GenerationRequest(BaseModel):
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$") model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
instruct: Optional[str] = Field(None, max_length=500) instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$") engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting") max_chunk_chars: int = Field(
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)") default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
)
crossfade_ms: int = Field(
default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)"
)
normalize: bool = Field(default=True, description="Normalize output audio volume") normalize: bool = Field(default=True, description="Normalize output audio volume")
effects_chain: Optional[List["EffectConfig"]] = Field(None, description="Effects chain to apply after generation (overrides profile default)") effects_chain: Optional[List["EffectConfig"]] = Field(
None, description="Effects chain to apply after generation (overrides profile default)"
)
class GenerationResponse(BaseModel): class GenerationResponse(BaseModel):
"""Response model for voice generation.""" """Response model for voice generation."""
id: str id: str
profile_id: str profile_id: str
text: str text: str
@@ -92,6 +107,7 @@ class GenerationResponse(BaseModel):
class HistoryQuery(BaseModel): class HistoryQuery(BaseModel):
"""Query model for generation history.""" """Query model for generation history."""
profile_id: Optional[str] = None profile_id: Optional[str] = None
search: Optional[str] = None search: Optional[str] = None
limit: int = Field(default=50, ge=1, le=100) limit: int = Field(default=50, ge=1, le=100)
@@ -100,6 +116,7 @@ class HistoryQuery(BaseModel):
class HistoryResponse(BaseModel): class HistoryResponse(BaseModel):
"""Response model for history entry (includes profile name).""" """Response model for history entry (includes profile name)."""
id: str id: str
profile_id: str profile_id: str
profile_name: str profile_name: str
@@ -124,23 +141,27 @@ class HistoryResponse(BaseModel):
class HistoryListResponse(BaseModel): class HistoryListResponse(BaseModel):
"""Response model for history list.""" """Response model for history list."""
items: List[HistoryResponse] items: List[HistoryResponse]
total: int total: int
class TranscriptionRequest(BaseModel): class TranscriptionRequest(BaseModel):
"""Request model for audio transcription.""" """Request model for audio transcription."""
language: Optional[str] = Field(None, pattern="^(en|zh)$") language: Optional[str] = Field(None, pattern="^(en|zh)$")
class TranscriptionResponse(BaseModel): class TranscriptionResponse(BaseModel):
"""Response model for transcription.""" """Response model for transcription."""
text: str text: str
duration: float duration: float
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
"""Response model for health check.""" """Response model for health check."""
status: str status: str
model_loaded: bool model_loaded: bool
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
@@ -154,6 +175,7 @@ class HealthResponse(BaseModel):
class DirectoryCheck(BaseModel): class DirectoryCheck(BaseModel):
"""Health status for a single directory.""" """Health status for a single directory."""
path: str path: str
exists: bool exists: bool
writable: bool writable: bool
@@ -162,6 +184,7 @@ class DirectoryCheck(BaseModel):
class FilesystemHealthResponse(BaseModel): class FilesystemHealthResponse(BaseModel):
"""Response model for filesystem health check.""" """Response model for filesystem health check."""
healthy: bool healthy: bool
disk_free_mb: Optional[float] = None disk_free_mb: Optional[float] = None
disk_total_mb: Optional[float] = None disk_total_mb: Optional[float] = None
@@ -170,6 +193,7 @@ class FilesystemHealthResponse(BaseModel):
class ModelStatus(BaseModel): class ModelStatus(BaseModel):
"""Response model for model status.""" """Response model for model status."""
model_name: str model_name: str
display_name: str display_name: str
hf_repo_id: Optional[str] = None # HuggingFace repository ID hf_repo_id: Optional[str] = None # HuggingFace repository ID
@@ -181,33 +205,38 @@ class ModelStatus(BaseModel):
class ModelStatusListResponse(BaseModel): class ModelStatusListResponse(BaseModel):
"""Response model for model status list.""" """Response model for model status list."""
models: List[ModelStatus] models: List[ModelStatus]
class ModelDownloadRequest(BaseModel): class ModelDownloadRequest(BaseModel):
"""Request model for triggering model download.""" """Request model for triggering model download."""
model_name: str model_name: str
class ModelMigrateRequest(BaseModel): class ModelMigrateRequest(BaseModel):
"""Request model for migrating models to a new directory.""" """Request model for migrating models to a new directory."""
destination: str destination: str
class ActiveDownloadTask(BaseModel): class ActiveDownloadTask(BaseModel):
"""Response model for active download task.""" """Response model for active download task."""
model_name: str model_name: str
status: str status: str
started_at: datetime started_at: datetime
error: Optional[str] = None error: Optional[str] = None
progress: Optional[float] = None # 0-100 percentage progress: Optional[float] = None # 0-100 percentage
current: Optional[int] = None # bytes downloaded current: Optional[int] = None # bytes downloaded
total: Optional[int] = None # total bytes total: Optional[int] = None # total bytes
filename: Optional[str] = None # current file being downloaded filename: Optional[str] = None # current file being downloaded
class ActiveGenerationTask(BaseModel): class ActiveGenerationTask(BaseModel):
"""Response model for active generation task.""" """Response model for active generation task."""
task_id: str task_id: str
profile_id: str profile_id: str
text_preview: str text_preview: str
@@ -216,24 +245,28 @@ class ActiveGenerationTask(BaseModel):
class ActiveTasksResponse(BaseModel): class ActiveTasksResponse(BaseModel):
"""Response model for active tasks.""" """Response model for active tasks."""
downloads: List[ActiveDownloadTask] downloads: List[ActiveDownloadTask]
generations: List[ActiveGenerationTask] generations: List[ActiveGenerationTask]
class AudioChannelCreate(BaseModel): class AudioChannelCreate(BaseModel):
"""Request model for creating an audio channel.""" """Request model for creating an audio channel."""
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
device_ids: List[str] = Field(default_factory=list) device_ids: List[str] = Field(default_factory=list)
class AudioChannelUpdate(BaseModel): class AudioChannelUpdate(BaseModel):
"""Request model for updating an audio channel.""" """Request model for updating an audio channel."""
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
device_ids: Optional[List[str]] = None device_ids: Optional[List[str]] = None
class AudioChannelResponse(BaseModel): class AudioChannelResponse(BaseModel):
"""Response model for audio channel.""" """Response model for audio channel."""
id: str id: str
name: str name: str
is_default: bool is_default: bool
@@ -246,22 +279,26 @@ class AudioChannelResponse(BaseModel):
class ChannelVoiceAssignment(BaseModel): class ChannelVoiceAssignment(BaseModel):
"""Request model for assigning voices to a channel.""" """Request model for assigning voices to a channel."""
profile_ids: List[str] profile_ids: List[str]
class ProfileChannelAssignment(BaseModel): class ProfileChannelAssignment(BaseModel):
"""Request model for assigning channels to a profile.""" """Request model for assigning channels to a profile."""
channel_ids: List[str] channel_ids: List[str]
class StoryCreate(BaseModel): class StoryCreate(BaseModel):
"""Request model for creating a story.""" """Request model for creating a story."""
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500)
class StoryResponse(BaseModel): class StoryResponse(BaseModel):
"""Response model for story (list view).""" """Response model for story (list view)."""
id: str id: str
name: str name: str
description: Optional[str] description: Optional[str]
@@ -275,6 +312,7 @@ class StoryResponse(BaseModel):
class StoryItemDetail(BaseModel): class StoryItemDetail(BaseModel):
"""Detail model for story item with generation info.""" """Detail model for story item with generation info."""
id: str id: str
story_id: str story_id: str
generation_id: str generation_id: str
@@ -304,6 +342,7 @@ class StoryItemDetail(BaseModel):
class StoryDetailResponse(BaseModel): class StoryDetailResponse(BaseModel):
"""Response model for story with items.""" """Response model for story with items."""
id: str id: str
name: str name: str
description: Optional[str] description: Optional[str]
@@ -317,6 +356,7 @@ class StoryDetailResponse(BaseModel):
class StoryItemCreate(BaseModel): class StoryItemCreate(BaseModel):
"""Request model for adding a generation to a story.""" """Request model for adding a generation to a story."""
generation_id: str generation_id: str
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
track: Optional[int] = 0 # Track number (0 = main track) track: Optional[int] = 0 # Track number (0 = main track)
@@ -324,48 +364,52 @@ class StoryItemCreate(BaseModel):
class StoryItemUpdateTime(BaseModel): class StoryItemUpdateTime(BaseModel):
"""Request model for updating a story item's timecode.""" """Request model for updating a story item's timecode."""
generation_id: str generation_id: str
start_time_ms: int = Field(..., ge=0) start_time_ms: int = Field(..., ge=0)
class StoryItemBatchUpdate(BaseModel): class StoryItemBatchUpdate(BaseModel):
"""Request model for batch updating story item timecodes.""" """Request model for batch updating story item timecodes."""
updates: List[StoryItemUpdateTime] updates: List[StoryItemUpdateTime]
class StoryItemReorder(BaseModel): class StoryItemReorder(BaseModel):
"""Request model for reordering story items.""" """Request model for reordering story items."""
generation_ids: List[str] = Field(..., min_length=1) generation_ids: List[str] = Field(..., min_length=1)
class StoryItemMove(BaseModel): class StoryItemMove(BaseModel):
"""Request model for moving a story item (position and/or track).""" """Request model for moving a story item (position and/or track)."""
start_time_ms: int = Field(..., ge=0) start_time_ms: int = Field(..., ge=0)
track: int = 0 track: int = 0
class StoryItemTrim(BaseModel): class StoryItemTrim(BaseModel):
"""Request model for trimming a story item.""" """Request model for trimming a story item."""
trim_start_ms: int = Field(..., ge=0) trim_start_ms: int = Field(..., ge=0)
trim_end_ms: int = Field(..., ge=0) trim_end_ms: int = Field(..., ge=0)
class StoryItemSplit(BaseModel): class StoryItemSplit(BaseModel):
"""Request model for splitting a story item.""" """Request model for splitting a story item."""
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start) split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
class StoryItemVersionUpdate(BaseModel): class StoryItemVersionUpdate(BaseModel):
"""Request model for setting a story item's pinned version.""" """Request model for setting a story item's pinned version."""
version_id: Optional[str] = None # null = use generation default version_id: Optional[str] = None # null = use generation default
# ============================================
# Effects & Versions
# ============================================
class EffectConfig(BaseModel): class EffectConfig(BaseModel):
"""A single effect in an effects chain.""" """A single effect in an effects chain."""
type: str type: str
enabled: bool = True enabled: bool = True
params: dict = Field(default_factory=dict) params: dict = Field(default_factory=dict)
@@ -373,11 +417,13 @@ class EffectConfig(BaseModel):
class EffectsChain(BaseModel): class EffectsChain(BaseModel):
"""An ordered list of effects to apply.""" """An ordered list of effects to apply."""
effects: List[EffectConfig] = Field(default_factory=list) effects: List[EffectConfig] = Field(default_factory=list)
class EffectPresetCreate(BaseModel): class EffectPresetCreate(BaseModel):
"""Request model for creating an effect preset.""" """Request model for creating an effect preset."""
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500)
effects_chain: List[EffectConfig] effects_chain: List[EffectConfig]
@@ -385,6 +431,7 @@ class EffectPresetCreate(BaseModel):
class EffectPresetUpdate(BaseModel): class EffectPresetUpdate(BaseModel):
"""Request model for updating an effect preset.""" """Request model for updating an effect preset."""
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
effects_chain: Optional[List[EffectConfig]] = None effects_chain: Optional[List[EffectConfig]] = None
@@ -392,6 +439,7 @@ class EffectPresetUpdate(BaseModel):
class EffectPresetResponse(BaseModel): class EffectPresetResponse(BaseModel):
"""Response model for effect preset.""" """Response model for effect preset."""
id: str id: str
name: str name: str
description: Optional[str] = None description: Optional[str] = None
@@ -405,6 +453,7 @@ class EffectPresetResponse(BaseModel):
class GenerationVersionResponse(BaseModel): class GenerationVersionResponse(BaseModel):
"""Response model for a generation version.""" """Response model for a generation version."""
id: str id: str
generation_id: str generation_id: str
label: str label: str
@@ -420,19 +469,24 @@ class GenerationVersionResponse(BaseModel):
class ApplyEffectsRequest(BaseModel): class ApplyEffectsRequest(BaseModel):
"""Request to apply effects to an existing generation.""" """Request to apply effects to an existing generation."""
effects_chain: List[EffectConfig] effects_chain: List[EffectConfig]
source_version_id: Optional[str] = Field(None, description="Version to use as source audio (defaults to clean/original)") source_version_id: Optional[str] = Field(
None, description="Version to use as source audio (defaults to clean/original)"
)
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)") label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
set_as_default: bool = Field(default=True, description="Set this version as the default") set_as_default: bool = Field(default=True, description="Set this version as the default")
class ProfileEffectsUpdate(BaseModel): class ProfileEffectsUpdate(BaseModel):
"""Request to update the default effects chain on a profile.""" """Request to update the default effects chain on a profile."""
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)") effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
class AvailableEffectParam(BaseModel): class AvailableEffectParam(BaseModel):
"""Description of a single effect parameter.""" """Description of a single effect parameter."""
default: float default: float
min: float min: float
max: float max: float
@@ -442,6 +496,7 @@ class AvailableEffectParam(BaseModel):
class AvailableEffect(BaseModel): class AvailableEffect(BaseModel):
"""Description of an available effect type.""" """Description of an available effect type."""
type: str type: str
label: str label: str
description: str description: str
@@ -450,4 +505,5 @@ class AvailableEffect(BaseModel):
class AvailableEffectsResponse(BaseModel): class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types.""" """Response listing all available effect types."""
effects: List[AvailableEffect] effects: List[AvailableEffect]
+51 -88
View File
@@ -43,6 +43,7 @@ def _profile_to_response(
effects_chain = [EffectConfig(**e) for e in raw] effects_chain = [EffectConfig(**e) for e in raw]
except Exception as e: except Exception as e:
import logging import logging
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}") logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return VoiceProfileResponse( return VoiceProfileResponse(
id=profile.id, id=profile.id,
@@ -75,12 +76,10 @@ async def create_profile(
Raises: Raises:
ValueError: If a profile with the same name already exists ValueError: If a profile with the same name already exists
""" """
# Check if profile name already exists
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first() existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile: if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.") raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Create profile in database
db_profile = DBVoiceProfile( db_profile = DBVoiceProfile(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=data.name, name=data.name,
@@ -94,7 +93,6 @@ async def create_profile(
db.commit() db.commit()
db.refresh(db_profile) db.refresh(db_profile)
# Create profile directory
profile_dir = config.get_profiles_dir() / db_profile.id profile_dir = config.get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
@@ -109,56 +107,50 @@ async def add_profile_sample(
) -> ProfileSampleResponse: ) -> ProfileSampleResponse:
""" """
Add a sample to a voice profile. Add a sample to a voice profile.
Args: Args:
profile_id: Profile ID profile_id: Profile ID
audio_path: Path to temporary audio file audio_path: Path to temporary audio file
reference_text: Transcript of audio reference_text: Transcript of audio
db: Database session db: Database session
Returns: Returns:
Created sample Created sample
""" """
# Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile: if not profile:
raise ValueError(f"Profile {profile_id} not found") raise ValueError(f"Profile {profile_id} not found")
# Validate audio
is_valid, error_msg = validate_reference_audio(audio_path) is_valid, error_msg = validate_reference_audio(audio_path)
if not is_valid: if not is_valid:
raise ValueError(f"Invalid reference audio: {error_msg}") raise ValueError(f"Invalid reference audio: {error_msg}")
# Create sample ID and directory
sample_id = str(uuid.uuid4()) sample_id = str(uuid.uuid4())
profile_dir = config.get_profiles_dir() / profile_id profile_dir = config.get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
# Copy audio file to profile directory
dest_path = profile_dir / f"{sample_id}.wav" dest_path = profile_dir / f"{sample_id}.wav"
audio, sr = load_audio(audio_path) audio, sr = load_audio(audio_path)
save_audio(audio, str(dest_path), sr) save_audio(audio, str(dest_path), sr)
# Create database entry
db_sample = DBProfileSample( db_sample = DBProfileSample(
id=sample_id, id=sample_id,
profile_id=profile_id, profile_id=profile_id,
audio_path=str(dest_path), audio_path=str(dest_path),
reference_text=reference_text, reference_text=reference_text,
) )
db.add(db_sample) db.add(db_sample)
# Update profile timestamp
profile.updated_at = datetime.utcnow() profile.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(db_sample) db.refresh(db_sample)
# Invalidate combined audio cache for this profile # Invalidate combined audio cache for this profile
# Since a new sample was added, any cached combined audio is now stale # Since a new sample was added, any cached combined audio is now stale
clear_profile_cache(profile_id) clear_profile_cache(profile_id)
return ProfileSampleResponse.model_validate(db_sample) return ProfileSampleResponse.model_validate(db_sample)
@@ -168,18 +160,18 @@ async def get_profile(
) -> Optional[VoiceProfileResponse]: ) -> Optional[VoiceProfileResponse]:
""" """
Get a voice profile by ID. Get a voice profile by ID.
Args: Args:
profile_id: Profile ID profile_id: Profile ID
db: Database session db: Database session
Returns: Returns:
Profile or None if not found Profile or None if not found
""" """
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile: if not profile:
return None return None
return _profile_to_response(profile) return _profile_to_response(profile)
@@ -189,11 +181,11 @@ async def get_profile_samples(
) -> List[ProfileSampleResponse]: ) -> List[ProfileSampleResponse]:
""" """
Get all samples for a profile. Get all samples for a profile.
Args: Args:
profile_id: Profile ID profile_id: Profile ID
db: Database session db: Database session
Returns: Returns:
List of samples List of samples
""" """
@@ -204,33 +196,27 @@ async def get_profile_samples(
async def list_profiles(db: Session) -> List[VoiceProfileResponse]: async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
""" """
List all voice profiles with generation and sample counts. List all voice profiles with generation and sample counts.
Args: Args:
db: Database session db: Database session
Returns: Returns:
List of profiles List of profiles
""" """
profiles = db.query(DBVoiceProfile).order_by( profiles = db.query(DBVoiceProfile).order_by(DBVoiceProfile.created_at.desc()).all()
DBVoiceProfile.created_at.desc()
).all()
if not profiles: if not profiles:
return [] return []
# Batch-fetch generation counts # Batch-fetch generation counts
gen_counts_rows = ( gen_counts_rows = (
db.query(DBGeneration.profile_id, func.count(DBGeneration.id)) db.query(DBGeneration.profile_id, func.count(DBGeneration.id)).group_by(DBGeneration.profile_id).all()
.group_by(DBGeneration.profile_id)
.all()
) )
gen_counts = {row[0]: row[1] for row in gen_counts_rows} gen_counts = {row[0]: row[1] for row in gen_counts_rows}
# Batch-fetch sample counts # Batch-fetch sample counts
sample_counts_rows = ( sample_counts_rows = (
db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id)) db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id)).group_by(DBProfileSample.profile_id).all()
.group_by(DBProfileSample.profile_id)
.all()
) )
sample_counts = {row[0]: row[1] for row in sample_counts_rows} sample_counts = {row[0]: row[1] for row in sample_counts_rows}
@@ -267,13 +253,11 @@ async def update_profile(
if not profile: if not profile:
return None return None
# Check if the new name conflicts with another profile
if profile.name != data.name: if profile.name != data.name:
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first() existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile: if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.") raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Update fields
profile.name = data.name profile.name = data.name
profile.description = data.description profile.description = data.description
profile.language = data.language profile.language = data.language
@@ -291,33 +275,30 @@ async def delete_profile(
) -> bool: ) -> bool:
""" """
Delete a voice profile and all associated data. Delete a voice profile and all associated data.
Args: Args:
profile_id: Profile ID profile_id: Profile ID
db: Database session db: Database session
Returns: Returns:
True if deleted, False if not found True if deleted, False if not found
""" """
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile: if not profile:
return False return False
# Delete samples from database
db.query(DBProfileSample).filter_by(profile_id=profile_id).delete() db.query(DBProfileSample).filter_by(profile_id=profile_id).delete()
# Delete profile from database
db.delete(profile) db.delete(profile)
db.commit() db.commit()
# Delete profile directory
profile_dir = config.get_profiles_dir() / profile_id profile_dir = config.get_profiles_dir() / profile_id
if profile_dir.exists(): if profile_dir.exists():
shutil.rmtree(profile_dir) shutil.rmtree(profile_dir)
# Clean up combined audio cache files for this profile # Clean up combined audio cache files for this profile
clear_profile_cache(profile_id) clear_profile_cache(profile_id)
return True return True
@@ -327,34 +308,32 @@ async def delete_profile_sample(
) -> bool: ) -> bool:
""" """
Delete a profile sample. Delete a profile sample.
Args: Args:
sample_id: Sample ID sample_id: Sample ID
db: Database session db: Database session
Returns: Returns:
True if deleted, False if not found True if deleted, False if not found
""" """
sample = db.query(DBProfileSample).filter_by(id=sample_id).first() sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample: if not sample:
return False return False
# Store profile_id before deleting # Store profile_id before deleting
profile_id = sample.profile_id profile_id = sample.profile_id
# Delete audio file
audio_path = Path(sample.audio_path) audio_path = Path(sample.audio_path)
if audio_path.exists(): if audio_path.exists():
audio_path.unlink() audio_path.unlink()
# Delete from database
db.delete(sample) db.delete(sample)
db.commit() db.commit()
# Invalidate combined audio cache for this profile # Invalidate combined audio cache for this profile
# Since the sample set changed, any cached combined audio is now stale # Since the sample set changed, any cached combined audio is now stale
clear_profile_cache(profile_id) clear_profile_cache(profile_id)
return True return True
@@ -365,30 +344,30 @@ async def update_profile_sample(
) -> Optional[ProfileSampleResponse]: ) -> Optional[ProfileSampleResponse]:
""" """
Update a profile sample's reference text. Update a profile sample's reference text.
Args: Args:
sample_id: Sample ID sample_id: Sample ID
reference_text: Updated reference text reference_text: Updated reference text
db: Database session db: Database session
Returns: Returns:
Updated sample or None if not found Updated sample or None if not found
""" """
sample = db.query(DBProfileSample).filter_by(id=sample_id).first() sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample: if not sample:
return None return None
# Store profile_id before updating # Store profile_id before updating
profile_id = sample.profile_id profile_id = sample.profile_id
sample.reference_text = reference_text sample.reference_text = reference_text
db.commit() db.commit()
db.refresh(sample) db.refresh(sample)
# Invalidate combined audio cache for this profile # Invalidate combined audio cache for this profile
# Since the reference text changed, cache keys and combined text are now stale # Since the reference text changed, cache keys and combined text are now stale
clear_profile_cache(profile_id) clear_profile_cache(profile_id)
return ProfileSampleResponse.model_validate(sample) return ProfileSampleResponse.model_validate(sample)
@@ -412,7 +391,6 @@ async def create_voice_prompt_for_profile(
""" """
from .backends import get_tts_backend_for_engine from .backends import get_tts_backend_for_engine
# Get all samples for profile
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all() samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples: if not samples:
@@ -421,7 +399,6 @@ async def create_voice_prompt_for_profile(
tts_model = get_tts_backend_for_engine(engine) tts_model = get_tts_backend_for_engine(engine)
if len(samples) == 1: if len(samples) == 1:
# Single sample - use directly
sample = samples[0] sample = samples[0]
voice_prompt, _ = await tts_model.create_voice_prompt( voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path, sample.audio_path,
@@ -430,11 +407,9 @@ async def create_voice_prompt_for_profile(
) )
return voice_prompt return voice_prompt
else: else:
# Multiple samples - combine them
audio_paths = [s.audio_path for s in samples] audio_paths = [s.audio_path for s in samples]
reference_texts = [s.reference_text for s in samples] reference_texts = [s.reference_text for s in samples]
# Combine audio
combined_audio, combined_text = await tts_model.combine_voice_prompts( combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths, audio_paths,
reference_texts, reference_texts,
@@ -443,18 +418,16 @@ async def create_voice_prompt_for_profile(
# Save combined audio to cache directory (persistent) # Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination # Create a hash of sample IDs to identify this specific combination
import hashlib import hashlib
sample_ids_str = "-".join(sorted([s.id for s in samples])) sample_ids_str = "-".join(sorted([s.id for s in samples]))
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12] combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
# Store in cache directory
cache_dir = _get_cache_dir() cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav" combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
# Save combined audio
save_audio(combined_audio, str(combined_path), 24000) save_audio(combined_audio, str(combined_path), 24000)
# Create prompt from combined audio
voice_prompt, _ = await tts_model.create_voice_prompt( voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path), str(combined_path),
combined_text, combined_text,
@@ -479,17 +452,14 @@ async def upload_avatar(
Returns: Returns:
Updated profile Updated profile
""" """
# Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile: if not profile:
raise ValueError(f"Profile {profile_id} not found") raise ValueError(f"Profile {profile_id} not found")
# Validate image
is_valid, error_msg = validate_image(image_path) is_valid, error_msg = validate_image(image_path)
if not is_valid: if not is_valid:
raise ValueError(error_msg) raise ValueError(error_msg)
# Delete existing avatar if present
if profile.avatar_path: if profile.avatar_path:
old_avatar = Path(profile.avatar_path) old_avatar = Path(profile.avatar_path)
if old_avatar.exists(): if old_avatar.exists():
@@ -497,27 +467,22 @@ async def upload_avatar(
# Determine file extension from uploaded file # Determine file extension from uploaded file
from PIL import Image from PIL import Image
with Image.open(image_path) as img: with Image.open(image_path) as img:
# Normalize JPEG variants (MPO is multi-picture format from some cameras) # Normalize JPEG variants (MPO is multi-picture format from some cameras)
img_format = img.format img_format = img.format
if img_format in ('MPO', 'JPG'): if img_format in ("MPO", "JPG"):
img_format = 'JPEG' img_format = "JPEG"
ext_map = { ext_map = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
'PNG': '.png', ext = ext_map.get(img_format, ".png")
'JPEG': '.jpg',
'WEBP': '.webp'
}
ext = ext_map.get(img_format, '.png')
# Save processed image to profile directory
profile_dir = config.get_profiles_dir() / profile_id profile_dir = config.get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / f"avatar{ext}" output_path = profile_dir / f"avatar{ext}"
process_avatar(image_path, str(output_path)) process_avatar(image_path, str(output_path))
# Update database
profile.avatar_path = str(output_path) profile.avatar_path = str(output_path)
profile.updated_at = datetime.utcnow() profile.updated_at = datetime.utcnow()
@@ -545,12 +510,10 @@ async def delete_avatar(
if not profile or not profile.avatar_path: if not profile or not profile.avatar_path:
return False return False
# Delete avatar file
avatar_path = Path(profile.avatar_path) avatar_path = Path(profile.avatar_path)
if avatar_path.exists(): if avatar_path.exists():
avatar_path.unlink() avatar_path.unlink()
# Update database
profile.avatar_path = None profile.avatar_path = None
profile.updated_at = datetime.utcnow() profile.updated_at = datetime.utcnow()
+3 -14
View File
@@ -81,9 +81,7 @@ async def run_generation(
if crossfade_ms is not None: if crossfade_ms is not None:
gen_kwargs["crossfade_ms"] = crossfade_ms gen_kwargs["crossfade_ms"] = crossfade_ms
audio, sample_rate = await generate_chunked( audio, sample_rate = await generate_chunked(tts_model, text, voice_prompt, **gen_kwargs)
tts_model, text, voice_prompt, **gen_kwargs
)
# --- Normalize (generate and regenerate always; retry skips) ----- # --- Normalize (generate and regenerate always; retry skips) -----
if normalize or mode == "regenerate": if normalize or mode == "regenerate":
@@ -139,11 +137,6 @@ async def run_generation(
bg_db.close() bg_db.close()
# ---------------------------------------------------------------------
# Mode-specific save helpers (sync, return final audio path)
# ---------------------------------------------------------------------
def _save_generate( def _save_generate(
*, *,
generation_id: str, generation_id: str,
@@ -163,9 +156,7 @@ def _save_generate(
clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav" clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav"
save_audio(audio, str(clean_audio_path), sample_rate) save_audio(audio, str(clean_audio_path), sample_rate)
has_effects = effects_chain and any( has_effects = effects_chain and any(e.get("enabled", True) for e in effects_chain)
e.get("enabled", True) for e in effects_chain
)
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
@@ -186,9 +177,7 @@ def _save_generate(
print(f"Warning: invalid effects chain, skipping: {error_msg}") print(f"Warning: invalid effects chain, skipping: {error_msg}")
else: else:
processed_audio = apply_effects(audio, sample_rate, effects_chain) processed_audio = apply_effects(audio, sample_rate, effects_chain)
processed_path = ( processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
config.get_generations_dir() / f"{generation_id}_processed.wav"
)
save_audio(processed_audio, str(processed_path), sample_rate) save_audio(processed_audio, str(processed_path), sample_rate)
final_audio_path = str(processed_path) final_audio_path = str(processed_path)
versions_mod.create_version( versions_mod.create_version(
+149 -141
View File
@@ -22,7 +22,12 @@ from .models import (
StoryItemSplit, StoryItemSplit,
StoryItemVersionUpdate, StoryItemVersionUpdate,
) )
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import (
Story as DBStory,
StoryItem as DBStoryItem,
Generation as DBGeneration,
VoiceProfile as DBVoiceProfile,
)
from .history import _get_versions_for_generation from .history import _get_versions_for_generation
from .utils.audio import load_audio, save_audio from .utils.audio import load_audio, save_audio
import numpy as np import numpy as np
@@ -49,11 +54,11 @@ def _build_item_detail(
id=item.id, id=item.id,
story_id=item.story_id, story_id=item.story_id,
generation_id=item.generation_id, generation_id=item.generation_id,
version_id=getattr(item, 'version_id', None), version_id=getattr(item, "version_id", None),
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0), trim_start_ms=getattr(item, "trim_start_ms", 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0), trim_end_ms=getattr(item, "trim_end_ms", 0),
created_at=item.created_at, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile_name, profile_name=profile_name,
@@ -95,10 +100,7 @@ async def create_story(
db.commit() db.commit()
db.refresh(db_story) db.refresh(db_story)
# Get item count item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == db_story.id).scalar()
item_count = db.query(func.count(DBStoryItem.id)).filter(
DBStoryItem.story_id == db_story.id
).scalar()
response = StoryResponse.model_validate(db_story) response = StoryResponse.model_validate(db_story)
response.item_count = item_count response.item_count = item_count
@@ -118,17 +120,15 @@ async def list_stories(
List of stories with item counts List of stories with item counts
""" """
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all() stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
result = [] result = []
for story in stories: for story in stories:
item_count = db.query(func.count(DBStoryItem.id)).filter( item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
DBStoryItem.story_id == story.id
).scalar()
response = StoryResponse.model_validate(story) response = StoryResponse.model_validate(story)
response.item_count = item_count response.item_count = item_count
result.append(response) result.append(response)
return result return result
@@ -150,22 +150,15 @@ async def get_story(
if not story: if not story:
return None return None
# Get all items ordered by start_time_ms items = (
items = db.query( db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name"))
DBStoryItem, .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
DBGeneration, .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
DBVoiceProfile.name.label('profile_name') .filter(DBStoryItem.story_id == story_id)
).join( .order_by(DBStoryItem.start_time_ms)
DBGeneration, .all()
DBStoryItem.generation_id == DBGeneration.id )
).join(
DBVoiceProfile,
DBGeneration.profile_id == DBVoiceProfile.id
).filter(
DBStoryItem.story_id == story_id
).order_by(DBStoryItem.start_time_ms).all()
# Build item details
item_details = [] item_details = []
for item, generation, profile_name in items: for item, generation, profile_name in items:
item_details.append(_build_item_detail(item, generation, profile_name, db)) item_details.append(_build_item_detail(item, generation, profile_name, db))
@@ -202,10 +195,7 @@ async def update_story(
db.commit() db.commit()
db.refresh(story) db.refresh(story)
# Get item count item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
item_count = db.query(func.count(DBStoryItem.id)).filter(
DBStoryItem.story_id == story.id
).scalar()
response = StoryResponse.model_validate(story) response = StoryResponse.model_validate(story)
response.item_count = item_count response.item_count = item_count
@@ -267,10 +257,7 @@ async def add_item_to_story(
return None return None
# Check if generation is already in story # Check if generation is already in story
existing = db.query(DBStoryItem).filter_by( existing = db.query(DBStoryItem).filter_by(story_id=story_id, generation_id=data.generation_id).first()
story_id=story_id,
generation_id=data.generation_id
).first()
if existing: if existing:
# Return existing item # Return existing item
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
@@ -283,18 +270,16 @@ async def add_item_to_story(
if data.start_time_ms is not None: if data.start_time_ms is not None:
start_time_ms = data.start_time_ms start_time_ms = data.start_time_ms
else: else:
# Find the maximum end time on the target track only existing_items = (
existing_items = db.query( db.query(DBStoryItem, DBGeneration)
DBStoryItem, .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
DBGeneration .filter(
).join( DBStoryItem.story_id == story_id,
DBGeneration, DBStoryItem.track == track,
DBStoryItem.generation_id == DBGeneration.id )
).filter( .all()
DBStoryItem.story_id == story_id, )
DBStoryItem.track == track,
).all()
if not existing_items: if not existing_items:
start_time_ms = 0 start_time_ms = 0
else: else:
@@ -302,7 +287,7 @@ async def add_item_to_story(
for item, gen in existing_items: for item, gen in existing_items:
item_end_ms = item.start_time_ms + int(gen.duration * 1000) item_end_ms = item.start_time_ms + int(gen.duration * 1000)
max_end_time_ms = max(max_end_time_ms, item_end_ms) max_end_time_ms = max(max_end_time_ms, item_end_ms)
# Add 200ms gap after the last item # Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200 start_time_ms = max_end_time_ms + 200
@@ -317,10 +302,10 @@ async def add_item_to_story(
) )
db.add(item) db.add(item)
# Update story updated_at # Update story updated_at
story.updated_at = datetime.utcnow() story.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(item) db.refresh(item)
@@ -349,10 +334,14 @@ async def move_story_item(
Updated item detail or None if not found Updated item detail or None if not found
""" """
# Get the item # Get the item
item = db.query(DBStoryItem).filter_by( item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not item: if not item:
return None return None
@@ -395,10 +384,14 @@ async def remove_item_from_story(
Returns: Returns:
True if removed, False if not found True if removed, False if not found
""" """
item = db.query(DBStoryItem).filter_by( item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not item: if not item:
return False return False
@@ -433,10 +426,14 @@ async def trim_story_item(
Updated item detail or None if not found Updated item detail or None if not found
""" """
# Get the item # Get the item
item = db.query(DBStoryItem).filter_by( item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not item: if not item:
return None return None
@@ -487,10 +484,14 @@ async def split_story_item(
List of two updated item details (original and new) or None if not found/invalid List of two updated item details (original and new) or None if not found/invalid
""" """
# Get the item # Get the item
item = db.query(DBStoryItem).filter_by( item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not item: if not item:
return None return None
@@ -500,8 +501,8 @@ async def split_story_item(
return None return None
# Calculate effective duration and validate split point # Calculate effective duration and validate split point
current_trim_start = getattr(item, 'trim_start_ms', 0) current_trim_start = getattr(item, "trim_start_ms", 0)
current_trim_end = getattr(item, 'trim_end_ms', 0) current_trim_end = getattr(item, "trim_end_ms", 0)
original_duration_ms = int(generation.duration * 1000) original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
@@ -520,7 +521,7 @@ async def split_story_item(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
story_id=story_id, story_id=story_id,
generation_id=item.generation_id, # Same generation, different trim generation_id=item.generation_id, # Same generation, different trim
version_id=getattr(item, 'version_id', None), # Preserve pinned version version_id=getattr(item, "version_id", None), # Preserve pinned version
start_time_ms=item.start_time_ms + data.split_time_ms, start_time_ms=item.start_time_ms + data.split_time_ms,
track=item.track, track=item.track,
trim_start_ms=absolute_split_ms, trim_start_ms=absolute_split_ms,
@@ -566,10 +567,14 @@ async def duplicate_story_item(
New item detail or None if not found New item detail or None if not found
""" """
# Get the original item # Get the original item
original_item = db.query(DBStoryItem).filter_by( original_item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not original_item: if not original_item:
return None return None
@@ -579,8 +584,8 @@ async def duplicate_story_item(
return None return None
# Calculate effective duration # Calculate effective duration
current_trim_start = getattr(original_item, 'trim_start_ms', 0) current_trim_start = getattr(original_item, "trim_start_ms", 0)
current_trim_end = getattr(original_item, 'trim_end_ms', 0) current_trim_end = getattr(original_item, "trim_end_ms", 0)
original_duration_ms = int(generation.duration * 1000) original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
@@ -589,7 +594,7 @@ async def duplicate_story_item(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
story_id=story_id, story_id=story_id,
generation_id=original_item.generation_id, # Same generation as original generation_id=original_item.generation_id, # Same generation as original
version_id=getattr(original_item, 'version_id', None), # Preserve pinned version version_id=getattr(original_item, "version_id", None), # Preserve pinned version
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
track=original_item.track, track=original_item.track,
trim_start_ms=current_trim_start, trim_start_ms=current_trim_start,
@@ -673,19 +678,13 @@ async def reorder_story_items(
return None return None
# Get all items for this story with their generation data # Get all items for this story with their generation data
items_with_gen = db.query( items_with_gen = (
DBStoryItem, db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name"))
DBGeneration, .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
DBVoiceProfile.name.label('profile_name') .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
).join( .filter(DBStoryItem.story_id == story_id)
DBGeneration, .all()
DBStoryItem.generation_id == DBGeneration.id )
).join(
DBVoiceProfile,
DBGeneration.profile_id == DBVoiceProfile.id
).filter(
DBStoryItem.story_id == story_id
).all()
# Create maps for quick lookup # Create maps for quick lookup
item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen} item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen}
@@ -700,13 +699,13 @@ async def reorder_story_items(
for gen_id in generation_ids: for gen_id in generation_ids:
item, generation, profile_name = item_map[gen_id] item, generation, profile_name = item_map[gen_id]
# Update the item's start time # Update the item's start time
item.start_time_ms = current_time_ms item.start_time_ms = current_time_ms
# Calculate the duration in ms # Calculate the duration in ms
duration_ms = int(generation.duration * 1000) duration_ms = int(generation.duration * 1000)
# Move to next position (current end + gap) # Move to next position (current end + gap)
current_time_ms += duration_ms + gap_ms current_time_ms += duration_ms + gap_ms
@@ -738,10 +737,14 @@ async def set_story_item_version(
Returns: Returns:
Updated item detail or None if not found Updated item detail or None if not found
""" """
item = db.query(DBStoryItem).filter_by( item = (
id=item_id, db.query(DBStoryItem)
story_id=story_id, .filter_by(
).first() id=item_id,
story_id=story_id,
)
.first()
)
if not item: if not item:
return None return None
@@ -752,10 +755,15 @@ async def set_story_item_version(
# Validate version_id belongs to this generation if provided # Validate version_id belongs to this generation if provided
if data.version_id: if data.version_id:
from .database import GenerationVersion as DBGenerationVersion from .database import GenerationVersion as DBGenerationVersion
version = db.query(DBGenerationVersion).filter_by(
id=data.version_id, version = (
generation_id=item.generation_id, db.query(DBGenerationVersion)
).first() .filter_by(
id=data.version_id,
generation_id=item.generation_id,
)
.first()
)
if not version: if not version:
return None return None
@@ -793,15 +801,13 @@ async def export_story_audio(
return None return None
# Get all items ordered by start_time_ms # Get all items ordered by start_time_ms
items = db.query( items = (
DBStoryItem, db.query(DBStoryItem, DBGeneration)
DBGeneration .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
).join( .filter(DBStoryItem.story_id == story_id)
DBGeneration, .order_by(DBStoryItem.start_time_ms)
DBStoryItem.generation_id == DBGeneration.id .all()
).filter( )
DBStoryItem.story_id == story_id
).order_by(DBStoryItem.start_time_ms).all()
if not items: if not items:
return None return None
@@ -813,8 +819,9 @@ async def export_story_audio(
for item, generation in items: for item, generation in items:
# Resolve audio path: use pinned version if set, otherwise generation default # Resolve audio path: use pinned version if set, otherwise generation default
resolved_audio_path = generation.audio_path resolved_audio_path = generation.audio_path
if getattr(item, 'version_id', None): if getattr(item, "version_id", None):
from .database import GenerationVersion as DBGenerationVersion from .database import GenerationVersion as DBGenerationVersion
version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first() version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first()
if version: if version:
resolved_audio_path = version.audio_path resolved_audio_path = version.audio_path
@@ -826,33 +833,37 @@ async def export_story_audio(
try: try:
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
sample_rate = sr # Use actual sample rate from first file sample_rate = sr # Use actual sample rate from first file
# Get trim values # Get trim values
trim_start_ms = getattr(item, 'trim_start_ms', 0) trim_start_ms = getattr(item, "trim_start_ms", 0)
trim_end_ms = getattr(item, 'trim_end_ms', 0) trim_end_ms = getattr(item, "trim_end_ms", 0)
# Calculate effective duration # Calculate effective duration
original_duration_ms = int(generation.duration * 1000) original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
# Slice audio based on trim values # Slice audio based on trim values
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate) trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate) trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
# Extract the trimmed portion # Extract the trimmed portion
if trim_end_ms > 0: if trim_end_ms > 0:
trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:] trimmed_audio = (
audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
)
else: else:
trimmed_audio = audio[trim_start_sample:] trimmed_audio = audio[trim_start_sample:]
# Store audio with its timecode info # Store audio with its timecode info
start_time_ms = item.start_time_ms start_time_ms = item.start_time_ms
audio_data.append({ audio_data.append(
'audio': trimmed_audio, {
'start_time_ms': start_time_ms, "audio": trimmed_audio,
'duration_ms': effective_duration_ms, "start_time_ms": start_time_ms,
}) "duration_ms": effective_duration_ms,
}
)
except Exception: except Exception:
# Skip files that can't be loaded # Skip files that can't be loaded
continue continue
@@ -861,33 +872,30 @@ async def export_story_audio(
return None return None
# Calculate total duration: max(start_time_ms + duration_ms) # Calculate total duration: max(start_time_ms + duration_ms)
max_end_time_ms = max( max_end_time_ms = max((data["start_time_ms"] + data["duration_ms"] for data in audio_data), default=0)
(data['start_time_ms'] + data['duration_ms'] for data in audio_data),
default=0
)
# Convert to samples # Convert to samples
total_samples = int((max_end_time_ms / 1000.0) * sample_rate) total_samples = int((max_end_time_ms / 1000.0) * sample_rate)
# Create output buffer initialized to zeros # Create output buffer initialized to zeros
final_audio = np.zeros(total_samples, dtype=np.float32) final_audio = np.zeros(total_samples, dtype=np.float32)
# Mix each audio segment at its timecode position # Mix each audio segment at its timecode position
for data in audio_data: for data in audio_data:
audio = data['audio'] audio = data["audio"]
start_time_ms = data['start_time_ms'] start_time_ms = data["start_time_ms"]
# Calculate start sample index # Calculate start sample index
start_sample = int((start_time_ms / 1000.0) * sample_rate) start_sample = int((start_time_ms / 1000.0) * sample_rate)
# Ensure we don't exceed buffer bounds # Ensure we don't exceed buffer bounds
audio_length = len(audio) audio_length = len(audio)
end_sample = min(start_sample + audio_length, total_samples) end_sample = min(start_sample + audio_length, total_samples)
if start_sample < total_samples: if start_sample < total_samples:
# Trim audio if it extends beyond buffer # Trim audio if it extends beyond buffer
audio_to_mix = audio[:end_sample - start_sample] audio_to_mix = audio[: end_sample - start_sample]
# Mix: add audio to existing buffer (overlapping audio will sum) # Mix: add audio to existing buffer (overlapping audio will sum)
# Normalize to prevent clipping (simple approach: divide by max) # Normalize to prevent clipping (simple approach: divide by max)
final_audio[start_sample:end_sample] += audio_to_mix final_audio[start_sample:end_sample] += audio_to_mix
@@ -898,14 +906,14 @@ async def export_story_audio(
final_audio = final_audio / max_val final_audio = final_audio / max_val
# Save to temporary file # Save to temporary file
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
save_audio(final_audio, tmp_path, sample_rate) save_audio(final_audio, tmp_path, sample_rate)
# Read file bytes # Read file bytes
with open(tmp_path, 'rb') as f: with open(tmp_path, "rb") as f:
audio_bytes = f.read() audio_bytes = f.read()
return audio_bytes return audio_bytes
+15 -31
View File
@@ -37,11 +37,10 @@ async def monitor_sse_stream(model_name: str, timeout: int = 120):
if line.startswith("data: "): if line.startswith("data: "):
try: try:
data = json.loads(line[6:]) data = json.loads(line[6:])
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}") print(
events.append({ f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}"
**data, )
"_timestamp": timestamp events.append({**data, "_timestamp": timestamp})
})
# Stop if complete or error # Stop if complete or error
if data.get("status") in ("complete", "error"): if data.get("status") in ("complete", "error"):
@@ -74,12 +73,15 @@ async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B
try: try:
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(url, json={ response = await client.post(
"profile_id": profile_id, url,
"text": text, json={
"language": "en", "profile_id": profile_id,
"model_size": model_size, "text": text,
}) "language": "en",
"model_size": model_size,
},
)
print(f"[{_timestamp()}] Response: {response.status_code}") print(f"[{_timestamp()}] Response: {response.status_code}")
@@ -140,7 +142,7 @@ def _timestamp():
async def test_generation_with_cached_model(): async def test_generation_with_cached_model():
""" """
Test Case 1: Generation when model is already cached. Test Case 1: Generation when model is already cached.
This should NOT show any download progress events. This should NOT show any download progress events.
If it does, that's the UX bug we're trying to fix. If it does, that's the UX bug we're trying to fix.
""" """
@@ -194,7 +196,7 @@ async def test_generation_with_cached_model():
async def test_generation_with_fresh_download(): async def test_generation_with_fresh_download():
""" """
Test Case 2: Generation when model needs to be downloaded. Test Case 2: Generation when model needs to be downloaded.
This SHOULD show download progress events. This SHOULD show download progress events.
""" """
print("\n" + "=" * 80) print("\n" + "=" * 80)
@@ -292,24 +294,6 @@ async def main():
print(" Users see progress events even when the model is already cached,") print(" Users see progress events even when the model is already cached,")
print(" making them think the model is downloading again.") print(" making them think the model is downloading again.")
# Test Case 2: Fresh download (optional, commented out by default)
# Uncomment if you want to test download progress
# print("\n" + "🧪 " * 20)
# events_download = await test_generation_with_fresh_download()
#
# print("\n" + "=" * 80)
# print("TEST CASE 2 RESULTS: Generation with Model Download")
# print("=" * 80)
#
# if not events_download:
# print("ℹ Model was already cached, no download occurred")
# else:
# print(f"✓ Received {len(events_download)} download progress events")
# print("\nDownload Timeline:")
# for i, event in enumerate(events_download, 1):
# timestamp = event.pop("_timestamp", "??:??:??.???")
# print(f" {i}. [{timestamp}] {event}")
print("\n" + "=" * 80) print("\n" + "=" * 80)
print("Test Complete!") print("Test Complete!")
print("=" * 80) print("=" * 80)
+16 -19
View File
@@ -58,11 +58,6 @@ _ABBREVIATIONS = frozenset(
_PARA_TAG_RE = re.compile(r"\[[^\]]*\]") _PARA_TAG_RE = re.compile(r"\[[^\]]*\]")
# ---------------------------------------------------------------------------
# Text splitting
# ---------------------------------------------------------------------------
def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]: def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]:
"""Split *text* at natural boundaries into chunks of at most *max_chars*. """Split *text* at natural boundaries into chunks of at most *max_chars*.
@@ -174,11 +169,6 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int:
return cut return cut
# ---------------------------------------------------------------------------
# Audio concatenation
# ---------------------------------------------------------------------------
def concatenate_audio_chunks( def concatenate_audio_chunks(
chunks: List[np.ndarray], chunks: List[np.ndarray],
sample_rate: int, sample_rate: int,
@@ -211,11 +201,6 @@ def concatenate_audio_chunks(
return result return result
# ---------------------------------------------------------------------------
# Engine-agnostic chunked generation
# ---------------------------------------------------------------------------
async def generate_chunked( async def generate_chunked(
backend, backend,
text: str, text: str,
@@ -264,7 +249,11 @@ async def generate_chunked(
if len(chunks) <= 1: if len(chunks) <= 1:
# Short text — single-shot fast path # Short text — single-shot fast path
audio, sample_rate = await backend.generate( audio, sample_rate = await backend.generate(
text, voice_prompt, language, seed, instruct, text,
voice_prompt,
language,
seed,
instruct,
) )
if trim_fn is not None: if trim_fn is not None:
audio = trim_fn(audio, sample_rate) audio = trim_fn(audio, sample_rate)
@@ -273,7 +262,9 @@ async def generate_chunked(
# Long text — chunked generation # Long text — chunked generation
logger.info( logger.info(
"Splitting %d chars into %d chunks (max %d chars each)", "Splitting %d chars into %d chunks (max %d chars each)",
len(text), len(chunks), max_chunk_chars, len(text),
len(chunks),
max_chunk_chars,
) )
audio_chunks: List[np.ndarray] = [] audio_chunks: List[np.ndarray] = []
sample_rate: int | None = None sample_rate: int | None = None
@@ -281,7 +272,9 @@ async def generate_chunked(
for i, chunk_text in enumerate(chunks): for i, chunk_text in enumerate(chunks):
logger.info( logger.info(
"Generating chunk %d/%d (%d chars)", "Generating chunk %d/%d (%d chars)",
i + 1, len(chunks), len(chunk_text), i + 1,
len(chunks),
len(chunk_text),
) )
# Vary the seed per chunk to avoid correlated RNG artefacts, # Vary the seed per chunk to avoid correlated RNG artefacts,
# but keep it deterministic so the same (text, seed) pair # but keep it deterministic so the same (text, seed) pair
@@ -289,7 +282,11 @@ async def generate_chunked(
chunk_seed = (seed + i) if seed is not None else None chunk_seed = (seed + i) if seed is not None else None
chunk_audio, chunk_sr = await backend.generate( chunk_audio, chunk_sr = await backend.generate(
chunk_text, voice_prompt, language, chunk_seed, instruct, chunk_text,
voice_prompt,
language,
chunk_seed,
instruct,
) )
if trim_fn is not None: if trim_fn is not None:
chunk_audio = trim_fn(chunk_audio, chunk_sr) chunk_audio = trim_fn(chunk_audio, chunk_sr)
+57 -40
View File
@@ -35,10 +35,6 @@ from pedalboard import (
) )
# ---------------------------------------------------------------------------
# Effect registry: maps type names -> (pedalboard class, param definitions)
# ---------------------------------------------------------------------------
# Each param definition: (default, min, max, description) # Each param definition: (default, min, max, description)
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = { EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"chorus": { "chorus": {
@@ -46,11 +42,17 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "Chorus / Flanger", "label": "Chorus / Flanger",
"description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.", "description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
"params": { "params": {
"rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"}, "rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
"depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"}, "depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
"feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, "feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"centre_delay_ms": {"default": 7.0, "min": 0.5, "max": 50.0, "step": 0.1, "description": "Centre delay (ms)"}, "centre_delay_ms": {
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, "default": 7.0,
"min": 0.5,
"max": 50.0,
"step": 0.1,
"description": "Centre delay (ms)",
},
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
}, },
}, },
"reverb": { "reverb": {
@@ -58,11 +60,11 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "Reverb", "label": "Reverb",
"description": "Room reverb effect.", "description": "Room reverb effect.",
"params": { "params": {
"room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"}, "room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
"damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"}, "damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
"wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"}, "wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
"dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"}, "dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
"width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"}, "width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
}, },
}, },
"delay": { "delay": {
@@ -70,9 +72,15 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "Delay", "label": "Delay",
"description": "Echo / delay line.", "description": "Echo / delay line.",
"params": { "params": {
"delay_seconds": {"default": 0.3, "min": 0.01, "max": 2.0, "step": 0.01, "description": "Delay time (seconds)"}, "delay_seconds": {
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"}, "default": 0.3,
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"}, "min": 0.01,
"max": 2.0,
"step": 0.01,
"description": "Delay time (seconds)",
},
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
}, },
}, },
"compressor": { "compressor": {
@@ -80,10 +88,16 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "Compressor", "label": "Compressor",
"description": "Dynamic range compression for consistent loudness.", "description": "Dynamic range compression for consistent loudness.",
"params": { "params": {
"threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"}, "threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
"ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"}, "ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
"attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"}, "attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
"release_ms": {"default": 100.0, "min": 10.0, "max": 1000.0,"step": 1.0, "description": "Release time (ms)"}, "release_ms": {
"default": 100.0,
"min": 10.0,
"max": 1000.0,
"step": 1.0,
"description": "Release time (ms)",
},
}, },
}, },
"gain": { "gain": {
@@ -99,7 +113,13 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "High-Pass Filter", "label": "High-Pass Filter",
"description": "Removes frequencies below the cutoff.", "description": "Removes frequencies below the cutoff.",
"params": { "params": {
"cutoff_frequency_hz": {"default": 80.0, "min": 20.0, "max": 8000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"}, "cutoff_frequency_hz": {
"default": 80.0,
"min": 20.0,
"max": 8000.0,
"step": 1.0,
"description": "Cutoff frequency (Hz)",
},
}, },
}, },
"lowpass": { "lowpass": {
@@ -107,7 +127,13 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"label": "Low-Pass Filter", "label": "Low-Pass Filter",
"description": "Removes frequencies above the cutoff.", "description": "Removes frequencies above the cutoff.",
"params": { "params": {
"cutoff_frequency_hz": {"default": 8000.0, "min": 200.0, "max": 20000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"}, "cutoff_frequency_hz": {
"default": 8000.0,
"min": 200.0,
"max": 20000.0,
"step": 1.0,
"description": "Cutoff frequency (Hz)",
},
}, },
}, },
"pitch_shift": { "pitch_shift": {
@@ -121,10 +147,6 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
} }
# ---------------------------------------------------------------------------
# Built-in presets
# ---------------------------------------------------------------------------
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = { BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
"robotic": { "robotic": {
"name": "Robotic", "name": "Robotic",
@@ -233,10 +255,6 @@ BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
} }
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_available_effects() -> List[Dict[str, Any]]: def get_available_effects() -> List[Dict[str, Any]]:
"""Return the list of available effect types with their parameter definitions. """Return the list of available effect types with their parameter definitions.
@@ -244,15 +262,14 @@ def get_available_effects() -> List[Dict[str, Any]]:
""" """
result = [] result = []
for effect_type, info in EFFECT_REGISTRY.items(): for effect_type, info in EFFECT_REGISTRY.items():
result.append({ result.append(
"type": effect_type, {
"label": info["label"], "type": effect_type,
"description": info["description"], "label": info["label"],
"params": { "description": info["description"],
name: {k: v for k, v in pdef.items()} "params": {name: {k: v for k, v in pdef.items()} for name, pdef in info["params"].items()},
for name, pdef in info["params"].items() }
}, )
})
return result return result