mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 17:15:19 -07:00
chore(backend): repair test suite and bring ruff to green
The suite hadn't run green since the routes refactor: - test_profile_duplicate_names.py imported the pre-refactor module layout and broke collection; now imports backend.services.profiles - tests/conftest.py puts the repo root and backend dir on sys.path so files collect standalone instead of depending on run order - test_cors.py tested a hand-copied mirror of the origin list that had drifted from app.py (missing http://tauri.localhost); it now builds the app via the real create_app() factory - test_progress.py simulated a 1KB download, below the tracker's 1MB reporting threshold; simulation raised to 5MB - slow/timeout markers registered in pyproject Ruff: ~900 violations auto-fixed (typing modernization, import sorting, unused imports, whitespace). The remaining rules are baselined in pyproject.toml with per-rule counts to burn down, plus per-file carve-outs for deliberate env-before-import ordering. ruff check is now clean; suite is 134 passed, 2 skipped.
This commit is contained in:
+10
-9
@@ -95,18 +95,19 @@ if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
if not os.environ.get("MIOPEN_LOG_LEVEL"):
|
||||
os.environ["MIOPEN_LOG_LEVEL"] = "4"
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import torch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from urllib.parse import quote
|
||||
|
||||
from . import __version__, config, database
|
||||
from .services import tts, transcribe, llm
|
||||
from .database import get_db
|
||||
from .routes import register_routers
|
||||
from .services import llm, transcribe, tts
|
||||
from .services.task_queue import create_background_task, init_queue
|
||||
from .utils.platform_detect import get_backend_type
|
||||
from .utils.progress import get_progress_manager
|
||||
from .services.task_queue import create_background_task, init_queue
|
||||
from .routes import register_routers
|
||||
|
||||
|
||||
def safe_content_disposition(disposition_type: str, filename: str) -> str:
|
||||
@@ -122,8 +123,8 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str:
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
from .mcp_server.server import build_mcp_server, compose_lifespan
|
||||
from .mcp_server.context import ClientIdMiddleware
|
||||
from .mcp_server.server import build_mcp_server, compose_lifespan
|
||||
|
||||
# Build the MCP app up-front so we can wire its lifespan into FastAPI's —
|
||||
# FastMCP's Streamable HTTP transport only works if its session manager
|
||||
@@ -202,8 +203,8 @@ def _mount_frontend(application: FastAPI) -> None:
|
||||
if not frontend_dir.is_dir():
|
||||
return
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# Mount hashed assets (JS, CSS, images) that Vite places under /assets
|
||||
assets_dir = frontend_dir / "assets"
|
||||
@@ -243,9 +244,9 @@ def _get_gpu_status() -> str:
|
||||
if not compatible:
|
||||
label += " [UNSUPPORTED - see logs]"
|
||||
return label
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
elif backend_type == "mlx":
|
||||
if backend_type == "mlx":
|
||||
return "Metal (Apple Silicon via MLX)"
|
||||
|
||||
# Intel XPU (Arc / Data Center) via IPEX
|
||||
@@ -302,7 +303,7 @@ async def _run_startup(application: FastAPI) -> None:
|
||||
if result.rowcount > 0:
|
||||
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
|
||||
|
||||
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
|
||||
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
|
||||
profile_count = db.query(DBVoiceProfile).count()
|
||||
generation_count = db.query(DBGeneration).count()
|
||||
|
||||
@@ -9,13 +9,12 @@ import logging
|
||||
import platform
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..utils.audio import normalize_audio, load_audio
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.audio import load_audio, normalize_audio
|
||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,7 +24,7 @@ def is_model_cached(
|
||||
hf_repo: str,
|
||||
*,
|
||||
weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
|
||||
required_files: Optional[list[str]] = None,
|
||||
required_files: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a HuggingFace model is fully cached locally.
|
||||
@@ -201,11 +200,11 @@ def manual_seed(seed: int, device: str) -> None:
|
||||
|
||||
|
||||
async def combine_voice_prompts(
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
*,
|
||||
sample_rate: Optional[int] = None,
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
sample_rate: int | None = None,
|
||||
) -> tuple[np.ndarray, str]:
|
||||
"""
|
||||
Combine multiple reference audio samples into one.
|
||||
|
||||
@@ -235,7 +234,7 @@ async def combine_voice_prompts(
|
||||
def model_load_progress(
|
||||
model_name: str,
|
||||
is_cached: bool,
|
||||
filter_non_downloads: Optional[bool] = None,
|
||||
filter_non_downloads: bool | None = None,
|
||||
):
|
||||
"""
|
||||
Context manager for model loading with HF download progress tracking.
|
||||
|
||||
@@ -10,17 +10,16 @@ import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, List, Optional, Tuple
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
empty_device_cache,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
patch_chatterbox_f32,
|
||||
)
|
||||
@@ -127,7 +126,7 @@ class ChatterboxTTSBackend:
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
@@ -143,9 +142,9 @@ class ChatterboxTTSBackend:
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||
|
||||
# Per-language generation defaults. Lower temp + higher cfg = clearer speech.
|
||||
@@ -169,9 +168,9 @@ class ChatterboxTTSBackend:
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio using Chatterbox Multilingual TTS.
|
||||
|
||||
|
||||
@@ -10,17 +10,16 @@ import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, List, Optional, Tuple
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
empty_device_cache,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
patch_chatterbox_f32,
|
||||
)
|
||||
@@ -81,8 +80,8 @@ class ChatterboxTurboTTSBackend:
|
||||
logger.info(f"Loading Chatterbox Turbo TTS on {device}...")
|
||||
|
||||
import torch
|
||||
from huggingface_hub import snapshot_download
|
||||
from chatterbox.tts_turbo import ChatterboxTurboTTS
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
local_path = snapshot_download(
|
||||
repo_id=CHATTERBOX_TURBO_HF_REPO,
|
||||
@@ -126,7 +125,7 @@ class ChatterboxTurboTTSBackend:
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
@@ -141,9 +140,9 @@ class ChatterboxTurboTTSBackend:
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||
|
||||
async def generate(
|
||||
@@ -151,9 +150,9 @@ class ChatterboxTurboTTSBackend:
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio using Chatterbox Turbo TTS.
|
||||
|
||||
|
||||
@@ -16,20 +16,19 @@ causal LM generates speech via flow-matching diffusion.
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import ClassVar, List, Optional, Tuple
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
empty_device_cache,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -182,7 +181,7 @@ class HumeTadaBackend:
|
||||
# getattr(config, "tokenizer_name", "meta-llama/Llama-3.2-1B")
|
||||
# which hits the gated repo. Pre-load the config from HF,
|
||||
# inject the local tokenizer path, then pass it in.
|
||||
from tada.modules.tada import TadaForCausalLM, TadaConfig
|
||||
from tada.modules.tada import TadaConfig, TadaForCausalLM
|
||||
|
||||
logger.info(f"Loading TADA {model_size} model...")
|
||||
config = TadaConfig.from_pretrained(repo)
|
||||
@@ -214,7 +213,7 @@ class HumeTadaBackend:
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt from reference audio using TADA's encoder.
|
||||
|
||||
@@ -234,8 +233,8 @@ class HumeTadaBackend:
|
||||
return cached, True
|
||||
|
||||
def _encode_sync():
|
||||
import torch
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
device = self._device
|
||||
|
||||
@@ -258,9 +257,7 @@ class HumeTadaBackend:
|
||||
val = getattr(prompt, field_name)
|
||||
if isinstance(val, torch.Tensor):
|
||||
prompt_dict[field_name] = val.detach().cpu()
|
||||
elif isinstance(val, list):
|
||||
prompt_dict[field_name] = val
|
||||
elif isinstance(val, (int, float)):
|
||||
elif isinstance(val, (list, int, float)):
|
||||
prompt_dict[field_name] = val
|
||||
else:
|
||||
prompt_dict[field_name] = val
|
||||
@@ -275,9 +272,9 @@ class HumeTadaBackend:
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
return await _combine_voice_prompts(audio_paths, reference_texts, sample_rate=24000)
|
||||
|
||||
async def generate(
|
||||
@@ -285,9 +282,9 @@ class HumeTadaBackend:
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio from text using HumeAI TADA.
|
||||
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
PyTorch backend implementation for TTS and STT.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
import asyncio
|
||||
import logging
|
||||
import torch
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||
from ..utils.audio import load_audio
|
||||
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
|
||||
from . import LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
empty_device_cache,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
from ..utils.audio import load_audio
|
||||
|
||||
|
||||
class PyTorchTTSBackend:
|
||||
@@ -63,7 +63,7 @@ class PyTorchTTSBackend:
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
return is_model_cached(self._get_model_path(model_size))
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
async def load_model_async(self, model_size: str | None = None):
|
||||
"""
|
||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||
|
||||
@@ -140,7 +140,7 @@ class PyTorchTTSBackend:
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Create voice prompt from reference audio.
|
||||
|
||||
@@ -165,7 +165,7 @@ class PyTorchTTSBackend:
|
||||
# For PyTorch backend, the dict should contain tensors, not file paths
|
||||
# So we can safely return it
|
||||
return cached_prompt, True
|
||||
elif isinstance(cached_prompt, torch.Tensor):
|
||||
if isinstance(cached_prompt, torch.Tensor):
|
||||
# Legacy cache format - convert to dict
|
||||
# This shouldn't happen in practice, but handle it
|
||||
return {"prompt": cached_prompt}, True
|
||||
@@ -194,9 +194,9 @@ class PyTorchTTSBackend:
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||
|
||||
async def generate(
|
||||
@@ -204,9 +204,9 @@ class PyTorchTTSBackend:
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio from text using voice prompt.
|
||||
|
||||
@@ -266,7 +266,7 @@ class PyTorchSTTBackend:
|
||||
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
return is_model_cached(hf_repo)
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
async def load_model_async(self, model_size: str | None = None):
|
||||
"""
|
||||
Lazy load the Whisper model.
|
||||
|
||||
@@ -290,7 +290,7 @@ class PyTorchSTTBackend:
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
with model_load_progress(progress_model_name, is_cached):
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
from transformers import WhisperForConditionalGeneration, WhisperProcessor
|
||||
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
|
||||
@@ -317,8 +317,8 @@ class PyTorchSTTBackend:
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
language: str | None = None,
|
||||
model_size: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
@@ -16,16 +16,15 @@ Languages supported: zh, en, ja, ko, de, fr, ru, pt, es, it
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from . import TTSBackend, LANGUAGE_CODE_TO_NAME
|
||||
from . import LANGUAGE_CODE_TO_NAME
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
model_load_progress,
|
||||
)
|
||||
|
||||
@@ -62,7 +61,7 @@ class QwenCustomVoiceBackend:
|
||||
self.model = None
|
||||
self.model_size = model_size
|
||||
self.device = self._get_device()
|
||||
self._current_model_size: Optional[str] = None
|
||||
self._current_model_size: str | None = None
|
||||
|
||||
def _get_device(self) -> str:
|
||||
return get_torch_device(allow_xpu=True, allow_directml=True)
|
||||
@@ -75,11 +74,11 @@ class QwenCustomVoiceBackend:
|
||||
raise ValueError(f"Unknown model size: {model_size}")
|
||||
return QWEN_CV_HF_REPOS[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: Optional[str] = None) -> bool:
|
||||
def _is_model_cached(self, model_size: str | None = None) -> bool:
|
||||
size = model_size or self.model_size
|
||||
return is_model_cached(self._get_model_path(size))
|
||||
|
||||
async def load_model_async(self, model_size: Optional[str] = None) -> None:
|
||||
async def load_model_async(self, model_size: str | None = None) -> None:
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
@@ -164,8 +163,8 @@ class QwenCustomVoiceBackend:
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Generate audio using Qwen CustomVoice.
|
||||
|
||||
@@ -9,18 +9,16 @@ and STT engines.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from . import LLMBackend, DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
|
||||
from ..services.mlx_thread import clear_mlx_cache, run_on_mlx_thread
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
from . import DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
get_torch_device,
|
||||
is_model_cached,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,8 +42,8 @@ def _progress_name(model_size: str) -> str:
|
||||
|
||||
def _build_messages(
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
system: str | None,
|
||||
examples: list[tuple[str, str]] | None = None,
|
||||
) -> list[dict]:
|
||||
messages: list[dict] = []
|
||||
if system:
|
||||
@@ -65,7 +63,7 @@ class PyTorchQwenLLMBackend:
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self.model_size = model_size
|
||||
self._current_model_size: Optional[str] = None
|
||||
self._current_model_size: str | None = None
|
||||
self.device = self._get_device()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
@@ -82,7 +80,7 @@ class PyTorchQwenLLMBackend:
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
return is_model_cached(self._get_model_path(model_size))
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
async def load_model(self, model_size: str | None = None) -> None:
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
@@ -132,11 +130,11 @@ class PyTorchQwenLLMBackend:
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
system: str | None = None,
|
||||
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
|
||||
temperature: float = DEFAULT_LLM_TEMPERATURE,
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
model_size: str | None = None,
|
||||
examples: list[tuple[str, str]] | None = None,
|
||||
) -> str:
|
||||
await self.load_model(model_size)
|
||||
return await asyncio.to_thread(
|
||||
@@ -146,10 +144,10 @@ class PyTorchQwenLLMBackend:
|
||||
def _generate_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
system: str | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
examples: list[tuple[str, str]] | None = None,
|
||||
) -> str:
|
||||
import torch
|
||||
|
||||
@@ -187,7 +185,7 @@ class MLXQwenLLMBackend:
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self.model_size = model_size
|
||||
self._current_model_size: Optional[str] = None
|
||||
self._current_model_size: str | None = None
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
@@ -203,7 +201,7 @@ class MLXQwenLLMBackend:
|
||||
weight_extensions=(".safetensors", ".bin", ".npz"),
|
||||
)
|
||||
|
||||
def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
|
||||
def _ensure_loaded_sync(self, model_size: str | None) -> None:
|
||||
"""Load the model if the requested size isn't already resident.
|
||||
|
||||
Runs on the MLX worker thread so it stays serialized with generation.
|
||||
@@ -219,7 +217,7 @@ class MLXQwenLLMBackend:
|
||||
|
||||
self._load_model_sync(model_size)
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
async def load_model(self, model_size: str | None = None) -> None:
|
||||
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
|
||||
|
||||
async def unload(self) -> None:
|
||||
@@ -261,11 +259,11 @@ class MLXQwenLLMBackend:
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
system: str | None = None,
|
||||
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
|
||||
temperature: float = DEFAULT_LLM_TEMPERATURE,
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
model_size: str | None = None,
|
||||
examples: list[tuple[str, str]] | None = None,
|
||||
) -> str:
|
||||
# Load-if-needed and inference run as one job on the MLX worker so a
|
||||
# concurrent unload or different-size load can't land between them.
|
||||
@@ -278,10 +276,10 @@ class MLXQwenLLMBackend:
|
||||
def _generate_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
system: str | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
examples: list[tuple[str, str]] | None = None,
|
||||
) -> str:
|
||||
from mlx_lm import generate as mlx_generate
|
||||
from mlx_lm.sample_utils import make_sampler
|
||||
|
||||
@@ -6,8 +6,8 @@ without changing any importers.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
Base,
|
||||
AudioChannel,
|
||||
Base,
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
ChannelDeviceMapping,
|
||||
@@ -24,12 +24,12 @@ from .models import (
|
||||
StoryItem,
|
||||
VoiceProfile,
|
||||
)
|
||||
from .session import engine, SessionLocal, _db_path, init_db, get_db
|
||||
from .session import SessionLocal, _db_path, engine, get_db, init_db
|
||||
|
||||
__all__ = [
|
||||
"AudioChannel",
|
||||
# Models
|
||||
"Base",
|
||||
"AudioChannel",
|
||||
"Capture",
|
||||
"CaptureSettings",
|
||||
"ChannelDeviceMapping",
|
||||
@@ -42,13 +42,13 @@ __all__ = [
|
||||
"ProfileChannelMapping",
|
||||
"ProfileSample",
|
||||
"Project",
|
||||
"SessionLocal",
|
||||
"Story",
|
||||
"StoryItem",
|
||||
"VoiceProfile",
|
||||
"_db_path",
|
||||
# Session
|
||||
"engine",
|
||||
"SessionLocal",
|
||||
"_db_path",
|
||||
"init_db",
|
||||
"get_db",
|
||||
"init_db",
|
||||
]
|
||||
|
||||
@@ -303,7 +303,7 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None:
|
||||
"""Normalize stored file paths to be relative to the configured data dir."""
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import get_data_dir, to_storage_path, resolve_storage_path
|
||||
from ..config import get_data_dir, resolve_storage_path, to_storage_path
|
||||
|
||||
data_dir = get_data_dir()
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""ORM model definitions for the voicebox SQLite database."""
|
||||
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from ..utils.capture_chords import (
|
||||
|
||||
+2
-1
@@ -5,10 +5,11 @@ entry point for development.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
|
||||
from . import config, database
|
||||
from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="voicebox backend server")
|
||||
|
||||
@@ -11,14 +11,13 @@ import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Strong refs to in-flight stamp tasks so asyncio.create_task results
|
||||
@@ -141,7 +140,7 @@ def _stamp_last_seen(client_id: str) -> None:
|
||||
if row is None:
|
||||
row = MCPClientBinding(client_id=client_id)
|
||||
db.add(row)
|
||||
row.last_seen_at = datetime.now(timezone.utc)
|
||||
row.last_seen_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
|
||||
@@ -8,7 +8,6 @@ floating pill surfaces whenever an agent is speaking.
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Each subscriber gets its own queue. Bounded to drop oldest if a client lags.
|
||||
_subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def resolve_profile(
|
||||
if client_id:
|
||||
# Per-client binding. Imported lazily so this module stays importable
|
||||
# even before the migration adds the table on first boot.
|
||||
from ..database.models import MCPClientBinding # noqa: WPS433
|
||||
from ..database.models import MCPClientBinding
|
||||
|
||||
binding = (
|
||||
db.query(MCPClientBinding)
|
||||
|
||||
@@ -9,8 +9,8 @@ binary bundled with the desktop app.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from collections.abc import Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastmcp import FastMCP
|
||||
@@ -18,7 +18,6 @@ from fastmcp import FastMCP
|
||||
from .context import ClientIdMiddleware
|
||||
from .tools import register_tools
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -18,13 +18,11 @@ from fastmcp import FastMCP
|
||||
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..services import captures as captures_service
|
||||
from ..services import profiles as profiles_service
|
||||
from ..services import captures as captures_service, profiles as profiles_service
|
||||
from . import events as mcp_events
|
||||
from .context import current_client_id, request_is_loopback
|
||||
from .resolve import resolve_profile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Absolute-path transcribes are bounded to keep a bad client from
|
||||
|
||||
@@ -23,7 +23,6 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
CLIENT_ID_HEADER = "X-Voicebox-Client-Id"
|
||||
SESSION_HEADER = "mcp-session-id"
|
||||
HEALTH_TIMEOUT_S = 30.0
|
||||
|
||||
+153
-153
@@ -2,10 +2,10 @@
|
||||
Pydantic models for request/response validation.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .utils.capture_chords import (
|
||||
default_push_to_talk_chord,
|
||||
default_toggle_to_talk_chord,
|
||||
@@ -16,16 +16,16 @@ class VoiceProfileCreate(BaseModel):
|
||||
"""Request model for creating a voice profile."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
description: str | None = 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)$"
|
||||
)
|
||||
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)
|
||||
personality: Optional[str] = Field(None, max_length=2000)
|
||||
voice_type: str | None = Field(default="cloned", pattern="^(cloned|preset|designed)$")
|
||||
preset_engine: str | None = Field(None, max_length=50)
|
||||
preset_voice_id: str | None = Field(None, max_length=100)
|
||||
design_prompt: str | None = Field(None, max_length=2000)
|
||||
default_engine: str | None = Field(None, max_length=50)
|
||||
personality: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class VoiceProfileResponse(BaseModel):
|
||||
@@ -33,16 +33,16 @@ class VoiceProfileResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
description: str | None
|
||||
language: str
|
||||
avatar_path: Optional[str] = None
|
||||
effects_chain: Optional[List["EffectConfig"]] = None
|
||||
avatar_path: str | None = None
|
||||
effects_chain: list["EffectConfig"] | None = 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
|
||||
personality: Optional[str] = None
|
||||
preset_engine: str | None = None
|
||||
preset_voice_id: str | None = None
|
||||
design_prompt: str | None = None
|
||||
default_engine: str | None = None
|
||||
personality: str | None = None
|
||||
generation_count: int = 0
|
||||
sample_count: int = 0
|
||||
created_at: datetime
|
||||
@@ -82,10 +82,10 @@ class GenerationRequest(BaseModel):
|
||||
profile_id: str
|
||||
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|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
|
||||
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|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
|
||||
seed: int | None = Field(None, ge=0)
|
||||
model_size: str | None = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
|
||||
instruct: str | None = Field(None, max_length=500)
|
||||
engine: str | None = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
|
||||
personality: bool = Field(
|
||||
default=False,
|
||||
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS.",
|
||||
@@ -97,7 +97,7 @@ class GenerationRequest(BaseModel):
|
||||
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")
|
||||
effects_chain: Optional[List["EffectConfig"]] = Field(
|
||||
effects_chain: list["EffectConfig"] | None = Field(
|
||||
None, description="Effects chain to apply after generation (overrides profile default)"
|
||||
)
|
||||
|
||||
@@ -109,19 +109,19 @@ class GenerationResponse(BaseModel):
|
||||
profile_id: str
|
||||
text: str
|
||||
language: str
|
||||
audio_path: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
seed: Optional[int] = None
|
||||
instruct: Optional[str] = None
|
||||
engine: Optional[str] = "qwen"
|
||||
model_size: Optional[str] = None
|
||||
audio_path: str | None = None
|
||||
duration: float | None = None
|
||||
seed: int | None = None
|
||||
instruct: str | None = None
|
||||
engine: str | None = "qwen"
|
||||
model_size: str | None = None
|
||||
status: str = "completed"
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
is_favorited: bool = False
|
||||
source: str = "manual"
|
||||
created_at: datetime
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
versions: list["GenerationVersionResponse"] | None = None
|
||||
active_version_id: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -130,8 +130,8 @@ class GenerationResponse(BaseModel):
|
||||
class HistoryQuery(BaseModel):
|
||||
"""Query model for generation history."""
|
||||
|
||||
profile_id: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
profile_id: str | None = None
|
||||
search: str | None = None
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
@@ -144,18 +144,18 @@ class HistoryResponse(BaseModel):
|
||||
profile_name: str
|
||||
text: str
|
||||
language: str
|
||||
audio_path: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
seed: Optional[int] = None
|
||||
instruct: Optional[str] = None
|
||||
engine: Optional[str] = "qwen"
|
||||
model_size: Optional[str] = None
|
||||
audio_path: str | None = None
|
||||
duration: float | None = None
|
||||
seed: int | None = None
|
||||
instruct: str | None = None
|
||||
engine: str | None = "qwen"
|
||||
model_size: str | None = None
|
||||
status: str = "completed"
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
is_favorited: bool = False
|
||||
created_at: datetime
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
versions: list["GenerationVersionResponse"] | None = None
|
||||
active_version_id: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -164,15 +164,15 @@ class HistoryResponse(BaseModel):
|
||||
class HistoryListResponse(BaseModel):
|
||||
"""Response model for history list."""
|
||||
|
||||
items: List[HistoryResponse]
|
||||
items: list[HistoryResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Request model for audio transcription."""
|
||||
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
language: str | None = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
model: str | None = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
@@ -196,13 +196,13 @@ class CaptureResponse(BaseModel):
|
||||
id: str
|
||||
audio_path: str
|
||||
source: str
|
||||
language: Optional[str] = None
|
||||
duration_ms: Optional[int] = None
|
||||
language: str | None = None
|
||||
duration_ms: int | None = None
|
||||
transcript_raw: str
|
||||
transcript_refined: Optional[str] = None
|
||||
stt_model: Optional[str] = None
|
||||
llm_model: Optional[str] = None
|
||||
refinement_flags: Optional[RefinementFlagsModel] = None
|
||||
transcript_refined: str | None = None
|
||||
stt_model: str | None = None
|
||||
llm_model: str | None = None
|
||||
refinement_flags: RefinementFlagsModel | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -212,7 +212,7 @@ class CaptureResponse(BaseModel):
|
||||
class CaptureListResponse(BaseModel):
|
||||
"""Response model for paginated capture list."""
|
||||
|
||||
items: List[CaptureResponse]
|
||||
items: list[CaptureResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -234,15 +234,15 @@ class CaptureCreateResponse(CaptureResponse):
|
||||
class CaptureRefineRequest(BaseModel):
|
||||
"""Request to refine a capture's transcript via the LLM."""
|
||||
|
||||
flags: Optional[RefinementFlagsModel] = None
|
||||
model_size: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
flags: RefinementFlagsModel | None = None
|
||||
model_size: str | None = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
|
||||
|
||||
class CaptureRetranscribeRequest(BaseModel):
|
||||
"""Request to re-run STT on a capture's audio with a different model."""
|
||||
|
||||
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
model: str | None = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
language: str | None = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
|
||||
|
||||
class CaptureSettingsResponse(BaseModel):
|
||||
@@ -256,13 +256,13 @@ class CaptureSettingsResponse(BaseModel):
|
||||
self_correction: bool = True
|
||||
preserve_technical: bool = True
|
||||
allow_auto_paste: bool = True
|
||||
default_playback_voice_id: Optional[str] = None
|
||||
default_playback_voice_id: str | None = None
|
||||
hotkey_enabled: bool = False
|
||||
keep_mic_warm: bool = False
|
||||
chord_push_to_talk_keys: List[str] = Field(
|
||||
chord_push_to_talk_keys: list[str] = Field(
|
||||
default_factory=default_push_to_talk_chord
|
||||
)
|
||||
chord_toggle_to_talk_keys: List[str] = Field(
|
||||
chord_toggle_to_talk_keys: list[str] = Field(
|
||||
default_factory=default_toggle_to_talk_chord
|
||||
)
|
||||
|
||||
@@ -273,19 +273,19 @@ class CaptureSettingsResponse(BaseModel):
|
||||
class CaptureSettingsUpdate(BaseModel):
|
||||
"""Partial update for capture settings — every field is optional."""
|
||||
|
||||
stt_model: Optional[str] = Field(default=None, pattern="^(base|small|medium|large|turbo)$")
|
||||
language: Optional[str] = None
|
||||
auto_refine: Optional[bool] = None
|
||||
llm_model: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
smart_cleanup: Optional[bool] = None
|
||||
self_correction: Optional[bool] = None
|
||||
preserve_technical: Optional[bool] = None
|
||||
allow_auto_paste: Optional[bool] = None
|
||||
default_playback_voice_id: Optional[str] = None
|
||||
hotkey_enabled: Optional[bool] = None
|
||||
keep_mic_warm: Optional[bool] = None
|
||||
chord_push_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
|
||||
chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
|
||||
stt_model: str | None = Field(default=None, pattern="^(base|small|medium|large|turbo)$")
|
||||
language: str | None = None
|
||||
auto_refine: bool | None = None
|
||||
llm_model: str | None = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
smart_cleanup: bool | None = None
|
||||
self_correction: bool | None = None
|
||||
preserve_technical: bool | None = None
|
||||
allow_auto_paste: bool | None = None
|
||||
default_playback_voice_id: str | None = None
|
||||
hotkey_enabled: bool | None = None
|
||||
keep_mic_warm: bool | None = None
|
||||
chord_push_to_talk_keys: list[str] | None = Field(default=None, min_length=1, max_length=6)
|
||||
chord_toggle_to_talk_keys: list[str] | None = Field(default=None, min_length=1, max_length=6)
|
||||
|
||||
|
||||
class GenerationSettingsResponse(BaseModel):
|
||||
@@ -303,10 +303,10 @@ class GenerationSettingsResponse(BaseModel):
|
||||
class GenerationSettingsUpdate(BaseModel):
|
||||
"""Partial update for generation settings — every field is optional."""
|
||||
|
||||
max_chunk_chars: Optional[int] = Field(default=None, ge=100, le=5000)
|
||||
crossfade_ms: Optional[int] = Field(default=None, ge=0, le=500)
|
||||
normalize_audio: Optional[bool] = None
|
||||
autoplay_on_generate: Optional[bool] = None
|
||||
max_chunk_chars: int | None = Field(default=None, ge=100, le=5000)
|
||||
crossfade_ms: int | None = Field(default=None, ge=0, le=500)
|
||||
normalize_audio: bool | None = None
|
||||
autoplay_on_generate: bool | None = None
|
||||
|
||||
|
||||
class MCPClientBindingResponse(BaseModel):
|
||||
@@ -315,14 +315,14 @@ class MCPClientBindingResponse(BaseModel):
|
||||
opt-in personality-rewrite default."""
|
||||
|
||||
client_id: str
|
||||
label: Optional[str] = None
|
||||
profile_id: Optional[str] = None
|
||||
default_engine: Optional[str] = Field(
|
||||
label: str | None = None
|
||||
profile_id: str | None = None
|
||||
default_engine: str | None = Field(
|
||||
None,
|
||||
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
|
||||
)
|
||||
default_personality: bool = False
|
||||
last_seen_at: Optional[datetime] = None
|
||||
last_seen_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -334,9 +334,9 @@ class MCPClientBindingUpsert(BaseModel):
|
||||
"""Create or update a binding. Matched by ``client_id``."""
|
||||
|
||||
client_id: str = Field(..., min_length=1, max_length=64)
|
||||
label: Optional[str] = Field(None, max_length=128)
|
||||
profile_id: Optional[str] = None
|
||||
default_engine: Optional[str] = Field(
|
||||
label: str | None = Field(None, max_length=128)
|
||||
profile_id: str | None = None
|
||||
default_engine: str | None = Field(
|
||||
None,
|
||||
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
|
||||
)
|
||||
@@ -344,26 +344,26 @@ class MCPClientBindingUpsert(BaseModel):
|
||||
|
||||
|
||||
class MCPClientBindingListResponse(BaseModel):
|
||||
items: List[MCPClientBindingResponse]
|
||||
items: list[MCPClientBindingResponse]
|
||||
|
||||
|
||||
class SpeakRequest(BaseModel):
|
||||
"""Body for POST /speak — non-MCP REST surface that mirrors voicebox.speak."""
|
||||
|
||||
text: str = Field(..., min_length=1, max_length=10000)
|
||||
profile: Optional[str] = Field(
|
||||
profile: str | None = Field(
|
||||
None,
|
||||
description="Voice profile name or id. Falls back to per-client binding, then default.",
|
||||
)
|
||||
engine: Optional[str] = Field(
|
||||
engine: str | None = Field(
|
||||
None,
|
||||
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
|
||||
)
|
||||
personality: Optional[bool] = Field(
|
||||
personality: bool | None = Field(
|
||||
None,
|
||||
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS. When null, the per-client binding's default_personality flag decides.",
|
||||
)
|
||||
language: Optional[str] = Field(
|
||||
language: str | None = Field(
|
||||
None,
|
||||
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
|
||||
)
|
||||
@@ -373,15 +373,15 @@ class LLMGenerateRequest(BaseModel):
|
||||
"""Request model for LLM text generation."""
|
||||
|
||||
prompt: str = Field(..., min_length=1, max_length=50000)
|
||||
system: Optional[str] = Field(None, max_length=4000)
|
||||
model_size: Optional[str] = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
system: str | None = Field(None, max_length=4000)
|
||||
model_size: str | None = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
|
||||
max_tokens: int = Field(default=512, ge=1, le=4096)
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
# Few-shot (user, assistant) pairs prepended as real chat turns.
|
||||
# Used by the refinement service to pin tricky rules (imperatives
|
||||
# staying imperatives, technical-term punctuation) that small models
|
||||
# lose when the examples live inline in the system prompt.
|
||||
examples: Optional[List[List[str]]] = Field(default=None, max_length=8)
|
||||
examples: list[list[str]] | None = Field(default=None, max_length=8)
|
||||
|
||||
|
||||
class LLMGenerateResponse(BaseModel):
|
||||
@@ -418,7 +418,7 @@ class ModelReadiness(BaseModel):
|
||||
model_name: str
|
||||
display_name: str
|
||||
size: str
|
||||
size_mb: Optional[int] = None
|
||||
size_mb: int | None = None
|
||||
|
||||
|
||||
class CaptureReadinessResponse(BaseModel):
|
||||
@@ -438,15 +438,15 @@ class HealthResponse(BaseModel):
|
||||
|
||||
status: str
|
||||
model_loaded: bool
|
||||
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
||||
model_size: Optional[str] = None # Current model size if loaded
|
||||
model_downloaded: bool | None = None # Whether model is cached/downloaded
|
||||
model_size: str | None = None # Current model size if loaded
|
||||
gpu_available: bool
|
||||
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||
vram_used_mb: Optional[float] = None
|
||||
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
||||
backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm)
|
||||
gpu_type: str | None = None # GPU type (CUDA, MPS, or None)
|
||||
vram_used_mb: float | None = None
|
||||
backend_type: str | None = None # Backend type (mlx or pytorch)
|
||||
backend_variant: str | None = None # Binary variant (cpu, cuda, or rocm)
|
||||
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
|
||||
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
|
||||
gpu_compatibility_warning: str | None = None # Warning if GPU arch unsupported
|
||||
|
||||
|
||||
class DirectoryCheck(BaseModel):
|
||||
@@ -455,16 +455,16 @@ class DirectoryCheck(BaseModel):
|
||||
path: str
|
||||
exists: bool
|
||||
writable: bool
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class FilesystemHealthResponse(BaseModel):
|
||||
"""Response model for filesystem health check."""
|
||||
|
||||
healthy: bool
|
||||
disk_free_mb: Optional[float] = None
|
||||
disk_total_mb: Optional[float] = None
|
||||
directories: List[DirectoryCheck]
|
||||
disk_free_mb: float | None = None
|
||||
disk_total_mb: float | None = None
|
||||
directories: list[DirectoryCheck]
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
@@ -472,17 +472,17 @@ class ModelStatus(BaseModel):
|
||||
|
||||
model_name: str
|
||||
display_name: str
|
||||
hf_repo_id: Optional[str] = None # HuggingFace repository ID
|
||||
hf_repo_id: str | None = None # HuggingFace repository ID
|
||||
downloaded: bool
|
||||
downloading: bool = False # True if download is in progress
|
||||
size_mb: Optional[float] = None
|
||||
size_mb: float | None = None
|
||||
loaded: bool = False
|
||||
|
||||
|
||||
class ModelStatusListResponse(BaseModel):
|
||||
"""Response model for model status list."""
|
||||
|
||||
models: List[ModelStatus]
|
||||
models: list[ModelStatus]
|
||||
|
||||
|
||||
class ModelDownloadRequest(BaseModel):
|
||||
@@ -503,11 +503,11 @@ class ActiveDownloadTask(BaseModel):
|
||||
model_name: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
error: Optional[str] = None
|
||||
progress: Optional[float] = None # 0-100 percentage
|
||||
current: Optional[int] = None # bytes downloaded
|
||||
total: Optional[int] = None # total bytes
|
||||
filename: Optional[str] = None # current file being downloaded
|
||||
error: str | None = None
|
||||
progress: float | None = None # 0-100 percentage
|
||||
current: int | None = None # bytes downloaded
|
||||
total: int | None = None # total bytes
|
||||
filename: str | None = None # current file being downloaded
|
||||
|
||||
|
||||
class ActiveGenerationTask(BaseModel):
|
||||
@@ -522,22 +522,22 @@ class ActiveGenerationTask(BaseModel):
|
||||
class ActiveTasksResponse(BaseModel):
|
||||
"""Response model for active tasks."""
|
||||
|
||||
downloads: List[ActiveDownloadTask]
|
||||
generations: List[ActiveGenerationTask]
|
||||
downloads: list[ActiveDownloadTask]
|
||||
generations: list[ActiveGenerationTask]
|
||||
|
||||
|
||||
class AudioChannelCreate(BaseModel):
|
||||
"""Request model for creating an audio channel."""
|
||||
|
||||
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):
|
||||
"""Request model for updating an audio channel."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
device_ids: Optional[List[str]] = None
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
device_ids: list[str] | None = None
|
||||
|
||||
|
||||
class AudioChannelResponse(BaseModel):
|
||||
@@ -546,7 +546,7 @@ class AudioChannelResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
is_default: bool
|
||||
device_ids: List[str]
|
||||
device_ids: list[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -556,20 +556,20 @@ class AudioChannelResponse(BaseModel):
|
||||
class ChannelVoiceAssignment(BaseModel):
|
||||
"""Request model for assigning voices to a channel."""
|
||||
|
||||
profile_ids: List[str]
|
||||
profile_ids: list[str]
|
||||
|
||||
|
||||
class ProfileChannelAssignment(BaseModel):
|
||||
"""Request model for assigning channels to a profile."""
|
||||
|
||||
channel_ids: List[str]
|
||||
channel_ids: list[str]
|
||||
|
||||
|
||||
class StoryCreate(BaseModel):
|
||||
"""Request model for creating a story."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class StoryResponse(BaseModel):
|
||||
@@ -577,7 +577,7 @@ class StoryResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
item_count: int = 0
|
||||
@@ -592,7 +592,7 @@ class StoryItemDetail(BaseModel):
|
||||
id: str
|
||||
story_id: str
|
||||
generation_id: str
|
||||
version_id: Optional[str] = None
|
||||
version_id: str | None = None
|
||||
start_time_ms: int
|
||||
track: int = 0
|
||||
trim_start_ms: int = 0
|
||||
@@ -605,14 +605,14 @@ class StoryItemDetail(BaseModel):
|
||||
language: str
|
||||
audio_path: str
|
||||
duration: float
|
||||
seed: Optional[int]
|
||||
instruct: Optional[str]
|
||||
engine: Optional[str] = None
|
||||
seed: int | None
|
||||
instruct: str | None
|
||||
engine: str | None = None
|
||||
volume: float = 1.0
|
||||
generation_created_at: datetime
|
||||
# Versions available for this generation
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
versions: list["GenerationVersionResponse"] | None = None
|
||||
active_version_id: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -623,10 +623,10 @@ class StoryDetailResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: List[StoryItemDetail] = []
|
||||
items: list[StoryItemDetail] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -636,8 +636,8 @@ class StoryItemCreate(BaseModel):
|
||||
"""Request model for adding a generation to a story."""
|
||||
|
||||
generation_id: str
|
||||
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
||||
track: Optional[int] = 0 # Track number (0 = main track)
|
||||
start_time_ms: int | None = None # If not provided, will be calculated automatically
|
||||
track: int | None = 0 # Track number (0 = main track)
|
||||
|
||||
|
||||
class StoryItemUpdateTime(BaseModel):
|
||||
@@ -650,13 +650,13 @@ class StoryItemUpdateTime(BaseModel):
|
||||
class StoryItemBatchUpdate(BaseModel):
|
||||
"""Request model for batch updating story item timecodes."""
|
||||
|
||||
updates: List[StoryItemUpdateTime]
|
||||
updates: list[StoryItemUpdateTime]
|
||||
|
||||
|
||||
class StoryItemReorder(BaseModel):
|
||||
"""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):
|
||||
@@ -682,7 +682,7 @@ class StoryItemSplit(BaseModel):
|
||||
class StoryItemVersionUpdate(BaseModel):
|
||||
"""Request model for setting a story item's pinned version."""
|
||||
|
||||
version_id: Optional[str] = None # null = use generation default
|
||||
version_id: str | None = None # null = use generation default
|
||||
|
||||
|
||||
class StoryItemVolumeUpdate(BaseModel):
|
||||
@@ -707,23 +707,23 @@ class EffectConfig(BaseModel):
|
||||
class EffectsChain(BaseModel):
|
||||
"""An ordered list of effects to apply."""
|
||||
|
||||
effects: List[EffectConfig] = Field(default_factory=list)
|
||||
effects: list[EffectConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EffectPresetCreate(BaseModel):
|
||||
"""Request model for creating an effect preset."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
effects_chain: List[EffectConfig]
|
||||
description: str | None = Field(None, max_length=500)
|
||||
effects_chain: list[EffectConfig]
|
||||
|
||||
|
||||
class EffectPresetUpdate(BaseModel):
|
||||
"""Request model for updating an effect preset."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
effects_chain: Optional[List[EffectConfig]] = None
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
effects_chain: list[EffectConfig] | None = None
|
||||
|
||||
|
||||
class EffectPresetResponse(BaseModel):
|
||||
@@ -731,8 +731,8 @@ class EffectPresetResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
effects_chain: List[EffectConfig]
|
||||
description: str | None = None
|
||||
effects_chain: list[EffectConfig]
|
||||
is_builtin: bool = False
|
||||
created_at: datetime
|
||||
|
||||
@@ -747,8 +747,8 @@ class GenerationVersionResponse(BaseModel):
|
||||
generation_id: str
|
||||
label: str
|
||||
audio_path: str
|
||||
effects_chain: Optional[List[EffectConfig]] = None
|
||||
source_version_id: Optional[str] = None
|
||||
effects_chain: list[EffectConfig] | None = None
|
||||
source_version_id: str | None = None
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
|
||||
@@ -759,18 +759,18 @@ class GenerationVersionResponse(BaseModel):
|
||||
class ApplyEffectsRequest(BaseModel):
|
||||
"""Request to apply effects to an existing generation."""
|
||||
|
||||
effects_chain: List[EffectConfig]
|
||||
source_version_id: Optional[str] = Field(
|
||||
effects_chain: list[EffectConfig]
|
||||
source_version_id: str | None = 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: str | None = 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")
|
||||
|
||||
|
||||
class ProfileEffectsUpdate(BaseModel):
|
||||
"""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: list[EffectConfig] | None = Field(None, description="Effects chain (null to remove)")
|
||||
|
||||
|
||||
class AvailableEffectParam(BaseModel):
|
||||
@@ -795,7 +795,7 @@ class AvailableEffect(BaseModel):
|
||||
class AvailableEffectsResponse(BaseModel):
|
||||
"""Response listing all available effect types."""
|
||||
|
||||
effects: List[AvailableEffect]
|
||||
effects: list[AvailableEffect]
|
||||
|
||||
|
||||
# ─── Cloud (backup & sync) ──────────────────────────────────────────────
|
||||
@@ -812,8 +812,8 @@ class CloudStatusResponse(BaseModel):
|
||||
"""Current link between this device and a Voicebox Cloud account."""
|
||||
|
||||
connected: bool
|
||||
device_name: Optional[str] = None
|
||||
account_user_id: Optional[str] = None
|
||||
key_prefix: Optional[str] = None
|
||||
connected_at: Optional[datetime] = None
|
||||
device_name: str | None = None
|
||||
account_user_id: str | None = None
|
||||
key_prefix: str | None = None
|
||||
connected_at: datetime | None = None
|
||||
dashboard_url: str
|
||||
|
||||
@@ -56,7 +56,6 @@ import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
# Diagnostics — log hook activity to a file alongside the bundle so we can
|
||||
# see what's happening when the server is run as a sidecar (no stdout for
|
||||
# runtime hook prints). Safe no-op if the file can't be written.
|
||||
|
||||
+32
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "voicebox-backend"
|
||||
version = "0.2.3"
|
||||
version = "0.5.0"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,19 +49,43 @@ ignore = [
|
||||
"SIM108", # use ternary operator (sometimes less readable)
|
||||
"B008", # function call in default argument (FastAPI Depends() pattern)
|
||||
"UP007", # use X | Y for union (auto-fixed by UP, but noisy on big diffs)
|
||||
|
||||
# Existing-violation baseline so ruff can gate CI. Remove entries from
|
||||
# this list as the remaining occurrences are fixed; counts are as of
|
||||
# 2026-07-26 after the auto-fix pass.
|
||||
"B904", # raise without `from` inside except (49) -- needs per-site from err/from None
|
||||
"SIM105", # try/except/pass instead of contextlib.suppress (14)
|
||||
"N806", # non-lowercase variable in function (9)
|
||||
"RUF002", # ambiguous unicode in docstring (6)
|
||||
"F841", # unused variable (5)
|
||||
"N803", # invalid argument name (5)
|
||||
"B007", # unused loop control variable (4)
|
||||
"ERA001", # commented-out code (4)
|
||||
"SIM102", # collapsible if (4)
|
||||
"SIM117", # multiple with statements (4)
|
||||
"SIM115", # open() without context manager (3)
|
||||
"RUF001", # ambiguous unicode in string (2)
|
||||
"RUF012", # mutable class default (2)
|
||||
"SIM110", # reimplemented builtin (2)
|
||||
"RUF006", # asyncio dangling task (1)
|
||||
"RUF034", # useless if-else (1)
|
||||
]
|
||||
|
||||
# Per-file rule overrides.
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Tests can use assert, print, and magic values freely.
|
||||
"tests/**" = ["S101", "T201", "PLR2004", "ERA001"]
|
||||
# Tests can use assert, print, magic values, and script-style setup freely.
|
||||
"tests/**" = ["S101", "T201", "PLR2004", "ERA001", "E402", "PT011", "PT018", "PT019"]
|
||||
# __init__.py re-exports are expected to have unused imports.
|
||||
"**/__init__.py" = ["F401"]
|
||||
# Entry points and scripts legitimately use print.
|
||||
"server.py" = ["T201"]
|
||||
"main.py" = ["T201"]
|
||||
# AMD GPU env vars must be set before torch import.
|
||||
"app.py" = ["E402"]
|
||||
# Environment and stdout hardening must run before heavy imports.
|
||||
"server.py" = ["T201", "E402"]
|
||||
"backends/__init__.py" = ["E402"]
|
||||
"backends/mlx_backend.py" = ["E402"]
|
||||
"backends/pytorch_backend.py" = ["E402"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["backend"]
|
||||
@@ -81,3 +105,7 @@ docstring-code-format = true
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"slow: long-running tests, deselect with '-m \"not slow\"'",
|
||||
"timeout: per-test timeout in seconds (enforced only when pytest-timeout is installed)",
|
||||
]
|
||||
|
||||
+18
-18
@@ -5,26 +5,26 @@ from fastapi import FastAPI
|
||||
|
||||
def register_routers(app: FastAPI) -> None:
|
||||
"""Include all domain routers on the application."""
|
||||
from .health import router as health_router
|
||||
from .profiles import router as profiles_router
|
||||
from .channels import router as channels_router
|
||||
from .generations import router as generations_router
|
||||
from .history import router as history_router
|
||||
from .transcription import router as transcription_router
|
||||
from .llm import router as llm_router
|
||||
from .captures import router as captures_router
|
||||
from .stories import router as stories_router
|
||||
from .effects import router as effects_router
|
||||
from .audio import router as audio_router
|
||||
from .models import router as models_router
|
||||
from .settings import router as settings_router
|
||||
from .tasks import router as tasks_router
|
||||
from .cuda import router as cuda_router
|
||||
from .rocm import router as rocm_router
|
||||
from .speak import router as speak_router
|
||||
from .mcp_bindings import router as mcp_bindings_router
|
||||
from .events import router as events_router
|
||||
from .captures import router as captures_router
|
||||
from .channels import router as channels_router
|
||||
from .cloud import router as cloud_router
|
||||
from .cuda import router as cuda_router
|
||||
from .effects import router as effects_router
|
||||
from .events import router as events_router
|
||||
from .generations import router as generations_router
|
||||
from .health import router as health_router
|
||||
from .history import router as history_router
|
||||
from .llm import router as llm_router
|
||||
from .mcp_bindings import router as mcp_bindings_router
|
||||
from .models import router as models_router
|
||||
from .profiles import router as profiles_router
|
||||
from .rocm import router as rocm_router
|
||||
from .settings import router as settings_router
|
||||
from .speak import router as speak_router
|
||||
from .stories import router as stories_router
|
||||
from .tasks import router as tasks_router
|
||||
from .transcription import router as transcription_router
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(profiles_router)
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..services import history
|
||||
from .. import config
|
||||
from ..database import get_db
|
||||
from ..services import history
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ from .. import config, models
|
||||
from ..backends import get_llm_model_configs, get_stt_model_configs
|
||||
from ..backends.base import is_model_cached
|
||||
from ..database import Capture as DBCapture, get_db
|
||||
from ..services import captures as captures_service
|
||||
from ..services import settings as settings_service
|
||||
from ..services import captures as captures_service, settings as settings_service
|
||||
from ..services.refinement import RefinementFlags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..services import channels
|
||||
from ..database import get_db
|
||||
from ..services import channels
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..services import history
|
||||
from ..database import Generation as DBGeneration, get_db
|
||||
from ..services import history
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,8 +29,8 @@ async def preview_effects(
|
||||
raise HTTPException(status_code=400, detail="Generation is not completed")
|
||||
|
||||
from ..services import versions as versions_mod
|
||||
from ..utils.effects import apply_effects, validate_effects_chain
|
||||
from ..utils.audio import load_audio
|
||||
from ..utils.effects import apply_effects, validate_effects_chain
|
||||
|
||||
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||
error = validate_effects_chain(chain_dicts)
|
||||
@@ -170,8 +170,8 @@ async def apply_effects_to_generation(
|
||||
raise HTTPException(status_code=400, detail="Generation is not completed")
|
||||
|
||||
from ..services import versions as versions_mod
|
||||
from ..utils.effects import apply_effects, validate_effects_chain
|
||||
from ..utils.audio import load_audio, save_audio
|
||||
from ..utils.effects import apply_effects, validate_effects_chain
|
||||
|
||||
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||
error = validate_effects_chain(chain_dicts)
|
||||
|
||||
@@ -14,7 +14,6 @@ from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from ..mcp_server import events as mcp_events
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,8 +10,8 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..services import history, personality, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services import history, personality, profiles, tts
|
||||
from ..services.generation import run_generation
|
||||
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
|
||||
from ..utils.audio import load_audio
|
||||
@@ -321,7 +321,12 @@ async def stream_speech(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Generate speech and stream the WAV audio directly without saving to disk."""
|
||||
from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
ensure_model_cached_or_raise,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
|
||||
profile = await profiles.get_profile(data.profile_id, db)
|
||||
if not profile:
|
||||
|
||||
@@ -6,13 +6,11 @@ import signal
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..services import tts
|
||||
from ..database import get_db
|
||||
from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows
|
||||
|
||||
router = APIRouter()
|
||||
@@ -56,9 +54,10 @@ async def watchdog_disable():
|
||||
@router.get("/health", response_model=models.HealthResponse)
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from pathlib import Path
|
||||
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
tts_model = tts.get_tts_model()
|
||||
backend_type = get_backend_type()
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..services import export_import, history
|
||||
from ..app import safe_content_disposition
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services import export_import, history
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ column is the same value the MCP client sends in ``X-Voicebox-Client-Id``
|
||||
(or the stdio shim pulls from ``VOICEBOX_CLIENT_ID``).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -15,7 +15,6 @@ from .. import models
|
||||
from ..database import get_db
|
||||
from ..database.models import MCPClientBinding
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -56,7 +55,7 @@ async def upsert_mcp_binding(
|
||||
row.profile_id = data.profile_id
|
||||
row.default_engine = data.default_engine
|
||||
row.default_personality = data.default_personality
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return models.MCPClientBindingResponse.model_validate(row)
|
||||
|
||||
@@ -4,13 +4,12 @@ import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..utils.platform_detect import get_backend_type
|
||||
from ..services.task_queue import create_background_task
|
||||
from ..utils.platform_detect import get_backend_type
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
@@ -171,7 +170,7 @@ async def migrate_models(request: models.ModelMigrateRequest):
|
||||
status="downloading",
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"{item.name}: {str(e)}")
|
||||
errors.append(f"{item.name}: {e!s}")
|
||||
else:
|
||||
total_bytes = sum(_get_dir_size(d) for d in model_dirs)
|
||||
progress_manager.update_progress(
|
||||
@@ -190,7 +189,7 @@ async def migrate_models(request: models.ModelMigrateRequest):
|
||||
await asyncio.to_thread(shutil.rmtree, str(item))
|
||||
moved += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{item.name}: {str(e)}")
|
||||
errors.append(f"{item.name}: {e!s}")
|
||||
|
||||
progress_manager.update_progress("migration", 1, 1, status="complete")
|
||||
progress_manager.mark_complete("migration")
|
||||
@@ -240,7 +239,7 @@ async def get_model_status():
|
||||
except ImportError:
|
||||
use_scan_cache = False
|
||||
|
||||
from ..backends import get_all_model_configs, check_model_loaded
|
||||
from ..backends import check_model_loaded, get_all_model_configs
|
||||
|
||||
registry_configs = get_all_model_configs()
|
||||
model_configs = [
|
||||
@@ -445,6 +444,7 @@ async def cancel_model_download(request: models.ModelDownloadRequest):
|
||||
async def delete_model(model_name: str):
|
||||
"""Delete a downloaded model from the HuggingFace cache."""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
from ..backends import get_model_config, unload_model_by_config
|
||||
|
||||
config = get_model_config(model_name)
|
||||
@@ -465,11 +465,11 @@ async def delete_model(model_name: str):
|
||||
try:
|
||||
shutil.rmtree(repo_cache_dir)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {e!s}")
|
||||
|
||||
return {"message": f"Model {model_name} deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete model: {e!s}")
|
||||
|
||||
@@ -186,7 +186,7 @@ async def add_profile_sample(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to process audio file: {e!s}")
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from ..database import MCPClientBinding, get_db
|
||||
from ..mcp_server import events as mcp_events
|
||||
from ..mcp_server.resolve import resolve_profile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import database, models
|
||||
from ..services import stories
|
||||
from ..app import safe_content_disposition
|
||||
from ..database import get_db
|
||||
from ..services import stories
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .. import models
|
||||
from ..utils.cache import clear_voice_prompt_cache
|
||||
from ..utils.progress import get_progress_manager
|
||||
from ..utils.tasks import get_task_manager
|
||||
from fastapi import HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -39,7 +38,7 @@ async def clear_cache():
|
||||
"files_deleted": deleted_count,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {e!s}")
|
||||
|
||||
|
||||
@router.get("/tasks/active", response_model=models.ActiveTasksResponse)
|
||||
|
||||
@@ -29,8 +29,8 @@ async def transcribe_audio(
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
from ..utils.audio import load_audio
|
||||
from ..backends import WHISPER_HF_REPOS
|
||||
from ..utils.audio import load_audio
|
||||
|
||||
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
+5
-4
@@ -5,9 +5,10 @@ This module provides an entry point that works with PyInstaller by using
|
||||
absolute imports instead of relative imports.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# On Windows with --noconsole (PyInstaller), sys.stdout/stderr are None.
|
||||
# They can also be broken file objects in some edge cases.
|
||||
@@ -30,6 +31,7 @@ if not _is_writable(sys.stderr):
|
||||
# PyInstaller + multiprocessing: child processes re-execute the frozen binary
|
||||
# with internal arguments. freeze_support() handles this and exits early.
|
||||
import multiprocessing
|
||||
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
# In frozen builds, piper_phonemize's espeak-ng C library falls back to
|
||||
@@ -167,9 +169,8 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||
return True # process exists, we just can't open it
|
||||
watchdog_logger.info(f"PID {pid}: OpenProcess failed, error={error}")
|
||||
return False
|
||||
else:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import soundfile as sf
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,7 +34,7 @@ WHISPER_NATIVE_FORMATS = (".wav", ".mp3", ".flac", ".ogg")
|
||||
|
||||
|
||||
def _to_response(row: DBCapture) -> CaptureResponse:
|
||||
flags_model: Optional[RefinementFlagsModel] = None
|
||||
flags_model: RefinementFlagsModel | None = None
|
||||
if row.refinement_flags:
|
||||
try:
|
||||
flags_model = RefinementFlagsModel(**json.loads(row.refinement_flags))
|
||||
@@ -62,8 +61,8 @@ async def create_capture(
|
||||
audio_bytes: bytes,
|
||||
filename: str,
|
||||
source: str,
|
||||
language: Optional[str],
|
||||
stt_model: Optional[str],
|
||||
language: str | None,
|
||||
stt_model: str | None,
|
||||
db: Session,
|
||||
) -> CaptureResponse:
|
||||
"""Persist raw audio, run STT, store the row."""
|
||||
@@ -159,7 +158,7 @@ def list_captures(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[C
|
||||
return [_to_response(r) for r in rows], total
|
||||
|
||||
|
||||
def get_capture(capture_id: str, db: Session) -> Optional[CaptureResponse]:
|
||||
def get_capture(capture_id: str, db: Session) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
return _to_response(row) if row else None
|
||||
|
||||
@@ -184,9 +183,9 @@ def delete_capture(capture_id: str, db: Session) -> bool:
|
||||
async def refine_capture(
|
||||
capture_id: str,
|
||||
flags: RefinementFlags,
|
||||
model_size: Optional[str],
|
||||
model_size: str | None,
|
||||
db: Session,
|
||||
) -> Optional[CaptureResponse]:
|
||||
) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
if not row:
|
||||
return None
|
||||
@@ -207,10 +206,10 @@ async def refine_capture(
|
||||
|
||||
async def retranscribe_capture(
|
||||
capture_id: str,
|
||||
stt_model: Optional[str],
|
||||
language: Optional[str],
|
||||
stt_model: str | None,
|
||||
language: str | None,
|
||||
db: Session,
|
||||
) -> Optional[CaptureResponse]:
|
||||
) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
Audio channel management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
AudioChannelCreate,
|
||||
AudioChannelUpdate,
|
||||
AudioChannelResponse,
|
||||
ChannelVoiceAssignment,
|
||||
ProfileChannelAssignment,
|
||||
)
|
||||
from ..database import (
|
||||
AudioChannel as DBAudioChannel,
|
||||
ChannelDeviceMapping as DBChannelDeviceMapping,
|
||||
ProfileChannelMapping as DBProfileChannelMapping,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
from ..models import (
|
||||
AudioChannelCreate,
|
||||
AudioChannelResponse,
|
||||
AudioChannelUpdate,
|
||||
ChannelVoiceAssignment,
|
||||
ProfileChannelAssignment,
|
||||
)
|
||||
|
||||
|
||||
async def list_channels(db: Session) -> List[AudioChannelResponse]:
|
||||
async def list_channels(db: Session) -> list[AudioChannelResponse]:
|
||||
"""List all audio channels."""
|
||||
channels = db.query(DBAudioChannel).all()
|
||||
result = []
|
||||
@@ -45,7 +45,7 @@ async def list_channels(db: Session) -> List[AudioChannelResponse]:
|
||||
return result
|
||||
|
||||
|
||||
async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]:
|
||||
async def get_channel(channel_id: str, db: Session) -> AudioChannelResponse | None:
|
||||
"""Get a channel by ID."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
@@ -111,7 +111,7 @@ async def update_channel(
|
||||
channel_id: str,
|
||||
data: AudioChannelUpdate,
|
||||
db: Session,
|
||||
) -> Optional[AudioChannelResponse]:
|
||||
) -> AudioChannelResponse | None:
|
||||
"""Update an audio channel."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
@@ -185,7 +185,7 @@ async def delete_channel(channel_id: str, db: Session) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
|
||||
async def get_channel_voices(channel_id: str, db: Session) -> list[str]:
|
||||
"""Get list of profile IDs assigned to a channel."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
channel_id=channel_id
|
||||
@@ -224,7 +224,7 @@ async def set_channel_voices(
|
||||
db.commit()
|
||||
|
||||
|
||||
async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
|
||||
async def get_profile_channels(profile_id: str, db: Session) -> list[str]:
|
||||
"""Get list of channel IDs assigned to a profile."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
profile_id=profile_id
|
||||
|
||||
@@ -19,11 +19,10 @@ import os
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .. import __version__
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,7 +62,7 @@ def get_cuda_exe_name() -> str:
|
||||
return "voicebox-server-cuda"
|
||||
|
||||
|
||||
def get_cuda_binary_path() -> Optional[Path]:
|
||||
def get_cuda_binary_path() -> Path | None:
|
||||
"""Return path to the CUDA executable if it exists inside the onedir."""
|
||||
p = get_cuda_dir() / get_cuda_exe_name()
|
||||
if p.exists():
|
||||
@@ -76,7 +75,7 @@ def get_cuda_libs_manifest_path() -> Path:
|
||||
return get_cuda_dir() / "cuda-libs.json"
|
||||
|
||||
|
||||
def get_installed_cuda_libs_version() -> Optional[str]:
|
||||
def get_installed_cuda_libs_version() -> str | None:
|
||||
"""Read the installed CUDA libs version from cuda-libs.json, or None."""
|
||||
manifest_path = get_cuda_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
@@ -114,7 +113,7 @@ def get_cuda_status() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
def _needs_server_download(version: str | None = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
@@ -138,7 +137,7 @@ def _needs_cuda_libs_download() -> bool:
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
sha256_url: str | None,
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
@@ -223,10 +222,7 @@ async def _download_and_extract_archive(
|
||||
status="downloading",
|
||||
)
|
||||
with tarfile.open(temp_path, "r:gz") as tar:
|
||||
if sys.version_info >= (3, 12):
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
else:
|
||||
tar.extractall(path=dest_dir)
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
|
||||
logger.info(f"{label}: extracted to {dest_dir}")
|
||||
finally:
|
||||
@@ -235,7 +231,7 @@ async def _download_and_extract_archive(
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_cuda_binary(version: Optional[str] = None):
|
||||
async def download_cuda_binary(version: str | None = None):
|
||||
"""Download the CUDA backend (server core + CUDA libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
@@ -255,7 +251,7 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
await _download_cuda_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
async def _download_cuda_binary_locked(version: str | None = None):
|
||||
"""Inner implementation of download_cuda_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
@@ -353,7 +349,7 @@ async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
raise
|
||||
|
||||
|
||||
def get_cuda_binary_version() -> Optional[str]:
|
||||
def get_cuda_binary_version() -> str | None:
|
||||
"""Get the version of the installed CUDA binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..utils.effects import validate_effects_chain
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import EffectPreset as DBEffectPreset
|
||||
from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
|
||||
from ..models import EffectConfig, EffectPresetCreate, EffectPresetResponse, EffectPresetUpdate
|
||||
from ..utils.effects import validate_effects_chain
|
||||
|
||||
|
||||
def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
|
||||
@@ -30,13 +28,13 @@ def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
|
||||
)
|
||||
|
||||
|
||||
def list_presets(db: Session) -> List[EffectPresetResponse]:
|
||||
def list_presets(db: Session) -> list[EffectPresetResponse]:
|
||||
"""List all effect presets (built-in + user-created)."""
|
||||
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
|
||||
return [_preset_response(p) for p in presets]
|
||||
|
||||
|
||||
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def get_preset(preset_id: str, db: Session) -> EffectPresetResponse | None:
|
||||
"""Get a preset by ID."""
|
||||
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||
if not p:
|
||||
@@ -44,7 +42,7 @@ def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
return _preset_response(p)
|
||||
|
||||
|
||||
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def get_preset_by_name(name: str, db: Session) -> EffectPresetResponse | None:
|
||||
"""Get a preset by name."""
|
||||
p = db.query(DBEffectPreset).filter_by(name=name).first()
|
||||
if not p:
|
||||
@@ -82,7 +80,7 @@ def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse
|
||||
return _preset_response(preset)
|
||||
|
||||
|
||||
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> EffectPresetResponse | None:
|
||||
"""Update a user effect preset. Cannot modify built-in presets."""
|
||||
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||
if not preset:
|
||||
|
||||
@@ -5,18 +5,22 @@ Handles exporting profiles to ZIP archives and importing them back.
|
||||
Also handles exporting individual generations.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import VoiceProfileResponse
|
||||
from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion
|
||||
from .profiles import create_profile, add_profile_sample
|
||||
from ..models import VoiceProfileCreate
|
||||
from .. import config
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
ProfileSample as DBProfileSample,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
from ..models import VoiceProfileCreate, VoiceProfileResponse
|
||||
from .profiles import add_profile_sample, create_profile
|
||||
|
||||
|
||||
def _get_unique_profile_name(name: str, db: Session) -> str:
|
||||
@@ -197,7 +201,7 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
|
||||
await upload_avatar(profile.id, tmp_path, db)
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Avatar import is optional - continue even if it fails
|
||||
pass
|
||||
|
||||
@@ -239,7 +243,7 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
|
||||
except Exception as e:
|
||||
if isinstance(e, ValueError):
|
||||
raise
|
||||
raise ValueError(f"Error importing profile: {str(e)}")
|
||||
raise ValueError(f"Error importing profile: {e!s}")
|
||||
|
||||
|
||||
def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
|
||||
@@ -344,10 +348,11 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
|
||||
Raises:
|
||||
ValueError: If ZIP is invalid or missing required files
|
||||
"""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
zip_buffer = io.BytesIO(file_bytes)
|
||||
@@ -458,4 +463,4 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
|
||||
except Exception as e:
|
||||
if isinstance(e, ValueError):
|
||||
raise
|
||||
raise ValueError(f"Error importing generation: {str(e)}")
|
||||
raise ValueError(f"Error importing generation: {e!s}")
|
||||
|
||||
@@ -18,12 +18,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal
|
||||
|
||||
from .. import config
|
||||
from . import history, profiles
|
||||
from ..database import get_db
|
||||
from ..utils.tasks import get_task_manager
|
||||
from . import history, profiles
|
||||
|
||||
|
||||
async def run_generation(
|
||||
@@ -34,23 +34,23 @@ async def run_generation(
|
||||
language: str,
|
||||
engine: str,
|
||||
model_size: str,
|
||||
seed: Optional[int],
|
||||
seed: int | None,
|
||||
normalize: bool = False,
|
||||
effects_chain: Optional[list] = None,
|
||||
instruct: Optional[str] = None,
|
||||
effects_chain: list | None = None,
|
||||
instruct: str | None = None,
|
||||
mode: Literal["generate", "retry", "regenerate"],
|
||||
max_chunk_chars: Optional[int] = None,
|
||||
crossfade_ms: Optional[int] = None,
|
||||
version_id: Optional[str] = None,
|
||||
max_chunk_chars: int | None = None,
|
||||
crossfade_ms: int | None = None,
|
||||
version_id: str | None = None,
|
||||
) -> None:
|
||||
"""Execute TTS inference and persist the result.
|
||||
|
||||
This is the single entry point for all background generation work.
|
||||
It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
|
||||
"""
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..backends import engine_needs_trim, get_tts_backend_for_engine, load_engine_model
|
||||
from ..utils.audio import normalize_audio, save_audio, trim_tts_output
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
|
||||
task_manager = get_task_manager()
|
||||
bg_db = next(get_db())
|
||||
@@ -170,7 +170,7 @@ def _save_generate(
|
||||
generation_id: str,
|
||||
audio,
|
||||
sample_rate: int,
|
||||
effects_chain: Optional[list],
|
||||
effects_chain: list | None,
|
||||
save_audio,
|
||||
db,
|
||||
) -> str:
|
||||
@@ -249,11 +249,11 @@ async def generate_audio_sync(
|
||||
language: str,
|
||||
engine: str,
|
||||
model_size: str,
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
seed: int | None = None,
|
||||
instruct: str | None = None,
|
||||
normalize: bool = True,
|
||||
max_chunk_chars: Optional[int] = None,
|
||||
crossfade_ms: Optional[int] = None,
|
||||
max_chunk_chars: int | None = None,
|
||||
crossfade_ms: int | None = None,
|
||||
) -> bytes:
|
||||
"""Run a TTS generation synchronously and return the resulting wav bytes.
|
||||
|
||||
@@ -267,9 +267,9 @@ async def generate_audio_sync(
|
||||
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
|
||||
(same helper ``/generate/stream`` uses).
|
||||
"""
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..backends import engine_needs_trim, get_tts_backend_for_engine, load_engine_model
|
||||
from ..utils.audio import normalize_audio, trim_tts_output
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from . import tts
|
||||
|
||||
bg_db = next(get_db())
|
||||
@@ -312,7 +312,7 @@ async def generate_audio_sync(
|
||||
def _save_regenerate(
|
||||
*,
|
||||
generation_id: str,
|
||||
version_id: Optional[str],
|
||||
version_id: str | None,
|
||||
audio,
|
||||
sample_rate: int,
|
||||
save_audio,
|
||||
@@ -322,10 +322,10 @@ def _save_regenerate(
|
||||
|
||||
Returns the audio path.
|
||||
"""
|
||||
from . import versions as versions_mod
|
||||
|
||||
import uuid as _uuid
|
||||
|
||||
from . import versions as versions_mod
|
||||
|
||||
suffix = _uuid.uuid4().hex[:8]
|
||||
audio_path = config.get_generations_dir() / f"{generation_id}_{suffix}.wav"
|
||||
save_audio(audio, str(audio_path), sample_rate)
|
||||
|
||||
+26
-18
@@ -2,17 +2,25 @@
|
||||
Generation history management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
|
||||
from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
|
||||
from .. import config
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
from ..models import (
|
||||
EffectConfig,
|
||||
GenerationResponse,
|
||||
GenerationVersionResponse,
|
||||
HistoryListResponse,
|
||||
HistoryQuery,
|
||||
HistoryResponse,
|
||||
)
|
||||
|
||||
|
||||
def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
|
||||
@@ -58,13 +66,13 @@ async def create_generation(
|
||||
language: str,
|
||||
audio_path: str,
|
||||
duration: float,
|
||||
seed: Optional[int],
|
||||
seed: int | None,
|
||||
db: Session,
|
||||
instruct: Optional[str] = None,
|
||||
generation_id: Optional[str] = None,
|
||||
instruct: str | None = None,
|
||||
generation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
engine: Optional[str] = "qwen",
|
||||
model_size: Optional[str] = None,
|
||||
engine: str | None = "qwen",
|
||||
model_size: str | None = None,
|
||||
source: str = "manual",
|
||||
) -> GenerationResponse:
|
||||
"""
|
||||
@@ -118,10 +126,10 @@ async def update_generation_status(
|
||||
generation_id: str,
|
||||
status: str,
|
||||
db: Session,
|
||||
audio_path: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> Optional[GenerationResponse]:
|
||||
audio_path: str | None = None,
|
||||
duration: float | None = None,
|
||||
error: str | None = None,
|
||||
) -> GenerationResponse | None:
|
||||
"""Update the status of a generation (used by async generation flow)."""
|
||||
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not generation:
|
||||
@@ -143,7 +151,7 @@ async def update_generation_status(
|
||||
async def get_generation(
|
||||
generation_id: str,
|
||||
db: Session,
|
||||
) -> Optional[GenerationResponse]:
|
||||
) -> GenerationResponse | None:
|
||||
"""
|
||||
Get a generation by ID.
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ from dataclasses import dataclass
|
||||
from . import llm as llm_service
|
||||
from .refinement import collapse_repetitive_artifacts
|
||||
|
||||
|
||||
# Shared rules block embedded in every mode-specific system prompt. Kept
|
||||
# short because small LLMs (0.6B) degrade when the system prompt is long,
|
||||
# and because the per-mode instructions downstream carry the specifics.
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,7 +13,6 @@ from dataclasses import dataclass
|
||||
|
||||
from . import llm as llm_service
|
||||
|
||||
|
||||
# A run that repeats this many times gets collapsed before the LLM sees
|
||||
# the transcript. Whisper occasionally loops content hundreds of times
|
||||
# when audio trails off — "URL URL URL…" (single word), "thanks for
|
||||
|
||||
@@ -20,11 +20,10 @@ import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .. import __version__
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +63,7 @@ def get_rocm_exe_name() -> str:
|
||||
return "voicebox-server-rocm"
|
||||
|
||||
|
||||
def get_rocm_binary_path() -> Optional[Path]:
|
||||
def get_rocm_binary_path() -> Path | None:
|
||||
"""Return path to the ROCm executable if it exists inside the onedir."""
|
||||
p = get_rocm_dir() / get_rocm_exe_name()
|
||||
if p.exists():
|
||||
@@ -77,7 +76,7 @@ def get_rocm_libs_manifest_path() -> Path:
|
||||
return get_rocm_dir() / "rocm-libs.json"
|
||||
|
||||
|
||||
def get_installed_rocm_libs_version() -> Optional[str]:
|
||||
def get_installed_rocm_libs_version() -> str | None:
|
||||
"""Read the installed ROCm libs version from rocm-libs.json, or None."""
|
||||
manifest_path = get_rocm_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
@@ -115,7 +114,7 @@ def get_rocm_status() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
def _needs_server_download(version: str | None = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
rocm_path = get_rocm_binary_path()
|
||||
if not rocm_path:
|
||||
@@ -139,7 +138,7 @@ def _needs_rocm_libs_download() -> bool:
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
sha256_url: str | None,
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
@@ -233,7 +232,7 @@ async def _download_and_extract_archive(
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_rocm_binary(version: Optional[str] = None):
|
||||
async def download_rocm_binary(version: str | None = None):
|
||||
"""Download the ROCm backend (server core + ROCm libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
@@ -253,7 +252,7 @@ async def download_rocm_binary(version: Optional[str] = None):
|
||||
await _download_rocm_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_rocm_binary_locked(version: Optional[str] = None):
|
||||
async def _download_rocm_binary_locked(version: str | None = None):
|
||||
"""Inner implementation of download_rocm_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
@@ -394,7 +393,7 @@ async def _download_rocm_binary_locked(version: Optional[str] = None):
|
||||
raise
|
||||
|
||||
|
||||
def get_rocm_binary_version() -> Optional[str]:
|
||||
def get_rocm_binary_version() -> str | None:
|
||||
"""Get the version of the installed ROCm binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -11,14 +11,12 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import CaptureSettings as DBCaptureSettings
|
||||
from ..database import GenerationSettings as DBGenerationSettings
|
||||
from ..database import CaptureSettings as DBCaptureSettings, GenerationSettings as DBGenerationSettings
|
||||
from ..utils.capture_chords import (
|
||||
default_push_to_talk_chord,
|
||||
default_toggle_to_talk_chord,
|
||||
)
|
||||
|
||||
|
||||
SINGLETON_ID = 1
|
||||
|
||||
|
||||
|
||||
+33
-33
@@ -2,37 +2,37 @@
|
||||
Story management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..models import (
|
||||
StoryCreate,
|
||||
StoryResponse,
|
||||
StoryDetailResponse,
|
||||
StoryItemDetail,
|
||||
StoryItemCreate,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemVolumeUpdate,
|
||||
StoryItemSplit,
|
||||
StoryItemVersionUpdate,
|
||||
)
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
Story as DBStory,
|
||||
StoryItem as DBStoryItem,
|
||||
Generation as DBGeneration,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
from .history import _get_versions_for_generation
|
||||
from ..models import (
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemDetail,
|
||||
StoryItemMove,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryItemVolumeUpdate,
|
||||
StoryResponse,
|
||||
)
|
||||
from ..utils.audio import load_audio, save_audio
|
||||
import numpy as np
|
||||
from .history import _get_versions_for_generation
|
||||
|
||||
|
||||
def _build_item_detail(
|
||||
@@ -113,7 +113,7 @@ async def create_story(
|
||||
|
||||
async def list_stories(
|
||||
db: Session,
|
||||
) -> List[StoryResponse]:
|
||||
) -> list[StoryResponse]:
|
||||
"""
|
||||
List all stories.
|
||||
|
||||
@@ -139,7 +139,7 @@ async def list_stories(
|
||||
async def get_story(
|
||||
story_id: str,
|
||||
db: Session,
|
||||
) -> Optional[StoryDetailResponse]:
|
||||
) -> StoryDetailResponse | None:
|
||||
"""
|
||||
Get a story with all its items.
|
||||
|
||||
@@ -176,7 +176,7 @@ async def update_story(
|
||||
story_id: str,
|
||||
data: StoryCreate,
|
||||
db: Session,
|
||||
) -> Optional[StoryResponse]:
|
||||
) -> StoryResponse | None:
|
||||
"""
|
||||
Update a story.
|
||||
|
||||
@@ -238,7 +238,7 @@ async def add_item_to_story(
|
||||
story_id: str,
|
||||
data: StoryItemCreate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Add a generation to a story.
|
||||
|
||||
@@ -324,7 +324,7 @@ async def move_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemMove,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Move a story item (update position and/or track).
|
||||
|
||||
@@ -416,7 +416,7 @@ async def trim_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemTrim,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Trim a story item (update trim_start_ms and trim_end_ms).
|
||||
|
||||
@@ -474,7 +474,7 @@ async def update_story_item_volume(
|
||||
item_id: str,
|
||||
data: StoryItemVolumeUpdate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""Update a story item's playback volume (per-clip linear gain)."""
|
||||
item = (
|
||||
db.query(DBStoryItem)
|
||||
@@ -505,7 +505,7 @@ async def split_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemSplit,
|
||||
db: Session,
|
||||
) -> Optional[List[StoryItemDetail]]:
|
||||
) -> list[StoryItemDetail] | None:
|
||||
"""
|
||||
Split a story item at a given time, creating two clips.
|
||||
|
||||
@@ -592,7 +592,7 @@ async def duplicate_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Duplicate a story item, creating a copy with all properties.
|
||||
|
||||
@@ -696,10 +696,10 @@ async def update_story_item_times(
|
||||
|
||||
async def reorder_story_items(
|
||||
story_id: str,
|
||||
generation_ids: List[str],
|
||||
generation_ids: list[str],
|
||||
db: Session,
|
||||
gap_ms: int = 200,
|
||||
) -> Optional[List[StoryItemDetail]]:
|
||||
) -> list[StoryItemDetail] | None:
|
||||
"""
|
||||
Reorder story items and recalculate timecodes.
|
||||
|
||||
@@ -763,7 +763,7 @@ async def set_story_item_version(
|
||||
item_id: str,
|
||||
data: StoryItemVersionUpdate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Pin a story item to a specific generation version.
|
||||
|
||||
@@ -824,7 +824,7 @@ async def set_story_item_version(
|
||||
async def export_story_audio(
|
||||
story_id: str,
|
||||
db: Session,
|
||||
) -> Optional[bytes]:
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Export story as single mixed audio file with timecode-based mixing.
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ to avoid GPU contention.
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Coroutine, Literal
|
||||
from typing import Literal
|
||||
|
||||
# Keep references to fire-and-forget background tasks to prevent GC
|
||||
_background_tasks: set = set()
|
||||
|
||||
@@ -9,17 +9,15 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import (
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
Generation as DBGeneration,
|
||||
)
|
||||
from ..models import GenerationVersionResponse, EffectConfig
|
||||
from .. import config
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
)
|
||||
from ..models import EffectConfig, GenerationVersionResponse
|
||||
|
||||
|
||||
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
|
||||
@@ -40,7 +38,7 @@ def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
|
||||
)
|
||||
|
||||
|
||||
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
|
||||
def list_versions(generation_id: str, db: Session) -> list[GenerationVersionResponse]:
|
||||
"""List all versions for a generation."""
|
||||
versions = (
|
||||
db.query(DBGenerationVersion)
|
||||
@@ -51,7 +49,7 @@ def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResp
|
||||
return [_version_response(v) for v in versions]
|
||||
|
||||
|
||||
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def get_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Get a specific version by ID."""
|
||||
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||
if not v:
|
||||
@@ -59,7 +57,7 @@ def get_version(version_id: str, db: Session) -> Optional[GenerationVersionRespo
|
||||
return _version_response(v)
|
||||
|
||||
|
||||
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def get_default_version(generation_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Get the default version for a generation."""
|
||||
v = (
|
||||
db.query(DBGenerationVersion)
|
||||
@@ -84,9 +82,9 @@ def create_version(
|
||||
label: str,
|
||||
audio_path: str,
|
||||
db: Session,
|
||||
effects_chain: Optional[List[dict]] = None,
|
||||
effects_chain: list[dict] | None = None,
|
||||
is_default: bool = False,
|
||||
source_version_id: Optional[str] = None,
|
||||
source_version_id: str | None = None,
|
||||
) -> GenerationVersionResponse:
|
||||
"""Create a new version for a generation.
|
||||
|
||||
@@ -119,7 +117,7 @@ def create_version(
|
||||
return _version_response(version)
|
||||
|
||||
|
||||
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def set_default_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Set a version as the default for its generation."""
|
||||
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||
if not version:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Shared test setup.
|
||||
|
||||
The suite mixes flat imports (``from utils.progress import ...``) with
|
||||
package imports (``from backend import config``). Both the repo root and
|
||||
the backend directory go on ``sys.path`` here so every test file collects
|
||||
on its own, regardless of which file loads first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BACKEND_DIR.parent
|
||||
|
||||
for _path in (str(BACKEND_DIR), str(REPO_ROOT)):
|
||||
if _path not in sys.path:
|
||||
sys.path.insert(0, _path)
|
||||
@@ -25,14 +25,12 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
DIST_DIR = BACKEND_DIR / "dist"
|
||||
@@ -46,7 +44,7 @@ RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||
class MatrixRow:
|
||||
label: str # human-readable (appears in report)
|
||||
engine: str # /generate engine
|
||||
model_size: Optional[str] # /generate model_size (None = omit)
|
||||
model_size: str | None # /generate model_size (None = omit)
|
||||
profile_kind: str # "cloned" | "preset_kokoro" | "preset_qwen_cv"
|
||||
model_name: str # /models/status key for cache lookup
|
||||
|
||||
@@ -76,22 +74,22 @@ HEALTH_TIMEOUT = 120
|
||||
class ModelResult:
|
||||
label: str
|
||||
engine: str
|
||||
model_size: Optional[str]
|
||||
model_size: str | None
|
||||
status: str # "passed" | "failed" | "timeout"
|
||||
was_cached: Optional[bool] = None
|
||||
generation_id: Optional[str] = None
|
||||
was_cached: bool | None = None
|
||||
generation_id: str | None = None
|
||||
elapsed_seconds: float = 0.0
|
||||
audio_duration: Optional[float] = None
|
||||
audio_path: Optional[str] = None
|
||||
audio_bytes: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
http_status: Optional[int] = None
|
||||
server_log_tail: Optional[list[str]] = None
|
||||
audio_duration: float | None = None
|
||||
audio_path: str | None = None
|
||||
audio_bytes: int | None = None
|
||||
error: str | None = None
|
||||
http_status: int | None = None
|
||||
server_log_tail: list[str] | None = None
|
||||
|
||||
|
||||
# ── Binary resolution ────────────────────────────────────────────────
|
||||
|
||||
def find_binary() -> Optional[Path]:
|
||||
def find_binary() -> Path | None:
|
||||
"""Return the first existing binary in priority order, or None."""
|
||||
is_win = platform.system() == "Windows"
|
||||
exe = ".exe" if is_win else ""
|
||||
@@ -129,9 +127,9 @@ class ServerProcess:
|
||||
self.port = port
|
||||
self.data_dir = data_dir
|
||||
self.log_path = log_path
|
||||
self.proc: Optional[subprocess.Popen] = None
|
||||
self.proc: subprocess.Popen | None = None
|
||||
self._log_buffer: deque[str] = deque(maxlen=500)
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
args = [
|
||||
@@ -227,7 +225,7 @@ def wait_for_health(base_url: str, server: ServerProcess, timeout: int) -> None:
|
||||
raise TimeoutError(f"Server did not become healthy within {timeout}s")
|
||||
|
||||
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> Optional[bool]:
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> bool | None:
|
||||
try:
|
||||
r = client.get(f"{base_url}/models/status", timeout=30.0)
|
||||
r.raise_for_status()
|
||||
@@ -331,7 +329,7 @@ def run_one_generation(
|
||||
|
||||
def fetch_audio_info(
|
||||
client: httpx.Client, base_url: str, generation_id: str, data_dir: Path
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
) -> tuple[str | None, int | None]:
|
||||
"""Return (audio_path, audio_bytes) for a completed generation.
|
||||
|
||||
Server stores audio_path relative to data_dir; resolve it to get a size.
|
||||
@@ -504,8 +502,8 @@ def main() -> int:
|
||||
|
||||
# Reference audio (only required if any cloning row is in the matrix)
|
||||
needs_reference = any(r.profile_kind == "cloned" for r in rows)
|
||||
ref_wav: Optional[Path] = None
|
||||
ref_text: Optional[str] = None
|
||||
ref_wav: Path | None = None
|
||||
ref_text: str | None = None
|
||||
if needs_reference:
|
||||
try:
|
||||
ref_wav, ref_text = resolve_reference(args)
|
||||
@@ -518,14 +516,14 @@ def main() -> int:
|
||||
# Tempdir + log path
|
||||
data_dir = Path(tempfile.mkdtemp(prefix="voicebox-e2e-"))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
log_path = args.output_dir / f"server-{ts}.log"
|
||||
|
||||
port = args.port or pick_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
server = ServerProcess(binary=binary, port=port, data_dir=data_dir, log_path=log_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
started_at = datetime.now(UTC)
|
||||
results: list[ModelResult] = []
|
||||
|
||||
try:
|
||||
@@ -536,9 +534,9 @@ def main() -> int:
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Profile setup (only create what's needed)
|
||||
cloned_profile_id: Optional[str] = None
|
||||
kokoro_profile_id: Optional[str] = None
|
||||
qwen_cv_profile_id: Optional[str] = None
|
||||
cloned_profile_id: str | None = None
|
||||
kokoro_profile_id: str | None = None
|
||||
qwen_cv_profile_id: str | None = None
|
||||
needed_kinds = {r.profile_kind for r in rows}
|
||||
if "cloned" in needed_kinds:
|
||||
assert ref_wav is not None and ref_text is not None
|
||||
@@ -608,7 +606,7 @@ def main() -> int:
|
||||
+ (f" ({result.error})" if result.error else ""), flush=True)
|
||||
results.append(result)
|
||||
finally:
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
finished_at = datetime.now(UTC)
|
||||
server.stop()
|
||||
if not args.keep_data_dir:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
@@ -14,12 +14,11 @@ import soundfile as sf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.audio import ( # noqa: E402
|
||||
from utils.audio import (
|
||||
preprocess_reference_audio,
|
||||
validate_and_load_reference_audio,
|
||||
)
|
||||
|
||||
|
||||
SR = 24000
|
||||
|
||||
|
||||
|
||||
+26
-58
@@ -4,64 +4,34 @@ Tests for CORS origin restrictions.
|
||||
Validates that the CORS middleware only allows known local origins
|
||||
and respects the VOICEBOX_CORS_ORIGINS environment variable.
|
||||
|
||||
Uses a minimal FastAPI app that mirrors the exact CORS configuration
|
||||
from backend/main.py, so tests run without heavy ML dependencies.
|
||||
|
||||
Usage:
|
||||
pip install httpx pytest fastapi starlette
|
||||
python -m pytest backend/tests/test_cors.py -v
|
||||
Builds the app via the real ``backend.app.create_app`` factory so the
|
||||
tests exercise the actual CORS configuration rather than a copy of it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _build_app(env_origins: str = "") -> FastAPI:
|
||||
"""
|
||||
Build a minimal FastAPI app with the same CORS logic as backend/main.py.
|
||||
|
||||
This mirrors the exact code in main.py so the test validates the real
|
||||
configuration without needing torch/numpy/transformers installed.
|
||||
"""
|
||||
app = FastAPI()
|
||||
|
||||
_default_origins = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:17493",
|
||||
"http://127.0.0.1:17493",
|
||||
"tauri://localhost",
|
||||
"https://tauri.localhost",
|
||||
]
|
||||
_cors_origins = _default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
from backend.app import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
return TestClient(_build_app())
|
||||
def _build_client(monkeypatch, env_origins: str | None = None) -> TestClient:
|
||||
if env_origins is None:
|
||||
monkeypatch.delenv("VOICEBOX_CORS_ORIGINS", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("VOICEBOX_CORS_ORIGINS", env_origins)
|
||||
# Plain TestClient (no context manager) skips lifespan startup, so no
|
||||
# model scans or queue workers run — only middleware is exercised.
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_with_custom_origins():
|
||||
return TestClient(_build_app("https://custom.example.com,https://other.example.com"))
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
return _build_client(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_custom_origins(monkeypatch):
|
||||
return _build_client(monkeypatch, "https://custom.example.com,https://other.example.com")
|
||||
|
||||
|
||||
def _get_with_origin(client: TestClient, origin: str) -> dict:
|
||||
@@ -92,6 +62,7 @@ class TestCORSDefaultOrigins:
|
||||
"http://127.0.0.1:17493",
|
||||
"tauri://localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://tauri.localhost",
|
||||
])
|
||||
def test_allowed_origins(self, client, origin):
|
||||
headers = _get_with_origin(client, origin)
|
||||
@@ -143,20 +114,17 @@ class TestCORSCustomOrigins:
|
||||
class TestCORSEnvVarParsing:
|
||||
"""Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
|
||||
|
||||
def test_empty_env_var(self):
|
||||
app = _build_app("")
|
||||
client = TestClient(app)
|
||||
def test_empty_env_var(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, "")
|
||||
headers = _get_with_origin(client, "http://evil.com")
|
||||
assert "access-control-allow-origin" not in headers
|
||||
|
||||
def test_whitespace_trimmed(self):
|
||||
app = _build_app(" https://spaced.example.com ")
|
||||
client = TestClient(app)
|
||||
def test_whitespace_trimmed(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, " https://spaced.example.com ")
|
||||
headers = _get_with_origin(client, "https://spaced.example.com")
|
||||
assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
|
||||
|
||||
def test_trailing_comma_ignored(self):
|
||||
app = _build_app("https://one.example.com,")
|
||||
client = TestClient(app)
|
||||
def test_trailing_comma_ignored(self, monkeypatch):
|
||||
client = _build_client(monkeypatch, "https://one.example.com,")
|
||||
headers = _get_with_origin(client, "https://one.example.com")
|
||||
assert headers.get("access-control-allow-origin") == "https://one.example.com"
|
||||
|
||||
@@ -7,14 +7,14 @@ the model is already cached.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
"""Monitor SSE stream for a model during generation."""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||
@@ -54,7 +54,7 @@ async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(f"[{timestamp}] ♥ heartbeat")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] SSE error: {e}")
|
||||
@@ -91,15 +91,14 @@ async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B
|
||||
print(f" Generation ID: {result.get('id')}")
|
||||
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||
return True, result
|
||||
elif response.status_code == 202:
|
||||
if response.status_code == 202:
|
||||
# Model is being downloaded
|
||||
result = response.json()
|
||||
print(f"[{_timestamp()}] → Model download in progress")
|
||||
print(f" Detail: {result}")
|
||||
return False, result
|
||||
else:
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||
|
||||
@@ -21,7 +21,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.hf_offline_patch import force_offline_if_cached # noqa: E402
|
||||
from utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
|
||||
def _hf_const():
|
||||
@@ -53,7 +53,7 @@ def test_mutates_cached_transformers_constant():
|
||||
def test_sets_env_variable():
|
||||
original = os.environ.get("HF_HUB_OFFLINE")
|
||||
with force_offline_if_cached(True, "t"):
|
||||
assert "1" == os.environ.get("HF_HUB_OFFLINE")
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert original == os.environ.get("HF_HUB_OFFLINE")
|
||||
|
||||
|
||||
@@ -88,14 +88,14 @@ def test_concurrent_threads_share_offline_window():
|
||||
barrier.wait(timeout=5)
|
||||
assert fast_exited.wait(timeout=5), "fast thread did not exit"
|
||||
observations.append(_hf_const().HF_HUB_OFFLINE)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
def fast():
|
||||
try:
|
||||
with force_offline_if_cached(True, "fast"):
|
||||
barrier.wait(timeout=5)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
finally:
|
||||
fast_exited.set()
|
||||
|
||||
@@ -18,10 +18,10 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from huggingface_hub.errors import OfflineModeIsEnabled # noqa: E402
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase # noqa: E402
|
||||
from huggingface_hub.errors import OfflineModeIsEnabled
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
|
||||
|
||||
import utils.hf_offline_patch as hf_offline_patch # noqa: E402
|
||||
import utils.hf_offline_patch as hf_offline_patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -32,11 +32,9 @@ import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
@@ -126,13 +124,13 @@ class Scorecard:
|
||||
refined: str
|
||||
latency_ms: int
|
||||
length_chars: int = 0
|
||||
prompt_leak: Optional[str] = None
|
||||
refusal: Optional[str] = None
|
||||
prompt_leak: str | None = None
|
||||
refusal: str | None = None
|
||||
stage_directions: list[str] = field(default_factory=list)
|
||||
flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def first_match(patterns, text: str) -> Optional[str]:
|
||||
def first_match(patterns, text: str) -> str | None:
|
||||
s = text.lstrip()
|
||||
for pat in patterns:
|
||||
m = pat.search(s)
|
||||
@@ -186,7 +184,7 @@ known-shipping Kokoro voice so the throwaway profile satisfies the
|
||||
preset-engine validator on creation."""
|
||||
|
||||
|
||||
def detect_backend_port(hint: Optional[int]) -> int:
|
||||
def detect_backend_port(hint: int | None) -> int:
|
||||
candidates: list[int] = []
|
||||
if hint is not None:
|
||||
candidates.append(hint)
|
||||
|
||||
@@ -5,20 +5,17 @@ This test suite verifies that the application correctly handles
|
||||
duplicate profile names and provides user-friendly error messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Add parent directory to path to import backend modules
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from database import Base, VoiceProfile as DBVoiceProfile
|
||||
from models import VoiceProfileCreate
|
||||
from profiles import create_profile, update_profile
|
||||
from backend.database import Base
|
||||
from backend.models import VoiceProfileCreate
|
||||
from backend.services.profiles import create_profile, update_profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -4,9 +4,8 @@ Test script to debug model download progress tracking.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Set up logging to see what's happening
|
||||
logging.basicConfig(
|
||||
@@ -14,8 +13,8 @@ logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||
from utils.progress import ProgressManager, get_progress_manager
|
||||
|
||||
|
||||
def test_progress_manager_basic():
|
||||
@@ -61,7 +60,7 @@ async def test_progress_manager_sse():
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProgressManager()
|
||||
collected_events: List[Dict] = []
|
||||
collected_events: list[dict] = []
|
||||
|
||||
# Simulate SSE client
|
||||
async def sse_client():
|
||||
@@ -123,7 +122,7 @@ def test_hf_progress_tracker():
|
||||
print("Test 3: HFProgressTracker tqdm Patching")
|
||||
print("=" * 60)
|
||||
|
||||
captured_progress: List[tuple] = []
|
||||
captured_progress: list[tuple] = []
|
||||
|
||||
def progress_callback(downloaded: int, total: int, filename: str):
|
||||
"""Capture progress updates."""
|
||||
@@ -137,12 +136,14 @@ def test_hf_progress_tracker():
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
||||
# Simulate downloading a file
|
||||
# Simulate downloading a file. The tracker only reports once the
|
||||
# combined total crosses MIN_TOTAL_BYTES (1 MB), so the simulated
|
||||
# file must be larger than that.
|
||||
print(" Simulating download with tqdm...")
|
||||
total_size = 1000
|
||||
total_size = 5_000_000
|
||||
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||
for chunk in range(0, total_size, 100):
|
||||
pbar.update(100)
|
||||
for chunk in range(0, total_size, 500_000):
|
||||
pbar.update(500_000)
|
||||
time.sleep(0.01)
|
||||
|
||||
print(f" Captured {len(captured_progress)} progress updates")
|
||||
@@ -170,7 +171,7 @@ async def test_full_integration():
|
||||
print("=" * 60)
|
||||
|
||||
pm = get_progress_manager()
|
||||
collected_events: List[Dict] = []
|
||||
collected_events: list[dict] = []
|
||||
|
||||
# SSE client
|
||||
async def sse_client():
|
||||
@@ -244,9 +245,8 @@ async def test_full_integration():
|
||||
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||
print("✓ Test 4 PASSED\n")
|
||||
return True
|
||||
else:
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
print("✗ Test 4 FAILED - No events received\n")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
@@ -15,12 +15,12 @@ Prerequisites:
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> list[dict]:
|
||||
"""
|
||||
Monitor SSE stream for a model download.
|
||||
|
||||
@@ -31,7 +31,7 @@ async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
Returns:
|
||||
List of SSE events received
|
||||
"""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
last_progress = -1
|
||||
|
||||
@@ -72,7 +72,7 @@ async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||
# Stop if complete or error
|
||||
if status in ("complete", "error"):
|
||||
if status == "complete":
|
||||
print(f" ✅ Download complete!")
|
||||
print(" ✅ Download complete!")
|
||||
else:
|
||||
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||
break
|
||||
@@ -119,20 +119,19 @@ async def delete_model(model_name: str) -> bool:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(url)
|
||||
if response.status_code == 200:
|
||||
print(f" ✅ Model deleted")
|
||||
print(" ✅ Model deleted")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
print(f" ℹ️ Model not found (already deleted)")
|
||||
if response.status_code == 404:
|
||||
print(" ℹ️ Model not found (already deleted)")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||
async def check_model_status(model_name: str) -> dict | None:
|
||||
"""Check the status of a model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
@@ -271,11 +270,11 @@ async def main():
|
||||
first_event = events[0]
|
||||
last_event = events[-1]
|
||||
|
||||
print(f"\n📊 First event:")
|
||||
print("\n📊 First event:")
|
||||
print(f" Status: {first_event.get('status')}")
|
||||
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||
|
||||
print(f"\n📊 Last event:")
|
||||
print("\n📊 Last event:")
|
||||
print(f" Status: {last_event.get('status')}")
|
||||
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ character-level pass added.
|
||||
|
||||
from backend.services.refinement import collapse_repetitive_artifacts
|
||||
|
||||
|
||||
# ── single-word loops (word-level pass) ─────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -32,28 +32,25 @@ import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
# Point sys.path at the repo root so ``backend.services.refinement`` resolves
|
||||
# as a package. Using backend/ as root breaks the service's own
|
||||
# ``from ..backends import …`` relative imports.
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from backend.services.refinement import ( # noqa: E402
|
||||
build_refinement_prompt,
|
||||
collapse_repetitive_artifacts,
|
||||
from backend.services.refinement import (
|
||||
REFINEMENT_EXAMPLES,
|
||||
RefinementFlags,
|
||||
build_refinement_prompt,
|
||||
collapse_repetitive_artifacts,
|
||||
)
|
||||
|
||||
|
||||
# ── Sample inputs ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -222,8 +219,8 @@ class Scorecard:
|
||||
filler_count_refined: int = 0
|
||||
length_ratio: float = 0.0
|
||||
has_loop_artifact: bool = False
|
||||
prompt_leak: Optional[str] = None
|
||||
answer_leak: Optional[str] = None
|
||||
prompt_leak: str | None = None
|
||||
answer_leak: str | None = None
|
||||
missing_substrings: list[str] = field(default_factory=list)
|
||||
missing_question_mark: bool = False
|
||||
flags: list[str] = field(default_factory=list)
|
||||
@@ -242,7 +239,7 @@ def has_loop_run(text: str, threshold: int = 6) -> bool:
|
||||
if len(tokens) < threshold:
|
||||
return False
|
||||
run = 1
|
||||
prev: Optional[str] = None
|
||||
prev: str | None = None
|
||||
for tok in tokens:
|
||||
key = re.sub(r"[^\w]", "", tok).lower()
|
||||
if key and key == prev:
|
||||
@@ -255,7 +252,7 @@ def has_loop_run(text: str, threshold: int = 6) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def first_match(patterns: Iterable[re.Pattern[str]], text: str) -> Optional[str]:
|
||||
def first_match(patterns: Iterable[re.Pattern[str]], text: str) -> str | None:
|
||||
stripped = text.lstrip()
|
||||
for pat in patterns:
|
||||
m = pat.search(stripped)
|
||||
@@ -319,7 +316,7 @@ def score(sample: Sample, model: str, refined: str, latency_ms: int) -> Scorecar
|
||||
DEFAULT_PORTS = (8000, 8765, 8899, 17493)
|
||||
|
||||
|
||||
def detect_backend_port(hint: Optional[int]) -> int:
|
||||
def detect_backend_port(hint: int | None) -> int:
|
||||
"""Return a port that answers /health, preferring the hint."""
|
||||
candidates: list[int] = []
|
||||
if hint is not None:
|
||||
|
||||
@@ -10,8 +10,6 @@ Usage:
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCheckCudaCompatibility:
|
||||
"""Unit tests for check_cuda_compatibility with ROCm awareness."""
|
||||
@@ -28,41 +26,38 @@ class TestCheckCudaCompatibility:
|
||||
"""On ROCm, the NVIDIA compute-capability check should be skipped."""
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", "6.2.41133"):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", "6.2.41133"):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_compatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86", "sm_89"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86", "sm_89"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_incompatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is False
|
||||
assert warning is not None
|
||||
assert "not supported" in warning
|
||||
with patch("torch.cuda.is_available", return_value=True), patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is False
|
||||
assert warning is not None
|
||||
assert "not supported" in warning
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestRocmBuildCli:
|
||||
build_server(cuda=True, rocm=True)
|
||||
|
||||
|
||||
@pytest.mark.slow()
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="ROCm build E2E only runs on Windows")
|
||||
class TestRocmBuildE2E:
|
||||
"""
|
||||
|
||||
@@ -7,10 +7,9 @@ without hitting the network.
|
||||
|
||||
import json
|
||||
import tarfile
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def _has_amd_hardware():
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def backend_dir():
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
@@ -4,48 +4,47 @@ Test real model download with SSE progress monitoring.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import time
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||
"""Monitor SSE stream for a model download."""
|
||||
events: List[Dict] = []
|
||||
events: list[dict] = []
|
||||
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||
|
||||
print(f"Connecting to SSE endpoint: {url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
async with httpx.AsyncClient(timeout=timeout) as client, client.stream("GET", url) as response:
|
||||
print(f"SSE connected, status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
if response.status_code != 200:
|
||||
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||
return events
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||
events.append(data)
|
||||
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
# Stop if complete or error
|
||||
if data.get("status") in ("complete", "error"):
|
||||
print(f" Download {data['status']}!")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" Error parsing JSON: {e}")
|
||||
print(f" Line was: {line}")
|
||||
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
elif line.startswith(": heartbeat"):
|
||||
print(" ♥ heartbeat")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Audio processing utilities.
|
||||
"""
|
||||
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import librosa
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
def normalize_audio(
|
||||
@@ -48,7 +48,7 @@ def load_audio(
|
||||
path: str,
|
||||
sample_rate: int = 24000,
|
||||
mono: bool = True,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""
|
||||
Load audio file with normalization.
|
||||
|
||||
@@ -84,8 +84,8 @@ def save_audio(
|
||||
Raises:
|
||||
OSError: If file cannot be written
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
temp_path = f"{path}.tmp"
|
||||
try:
|
||||
@@ -264,7 +264,7 @@ def validate_reference_audio(
|
||||
min_duration: float = 2.0,
|
||||
max_duration: float = 30.0,
|
||||
min_rms: float = 0.01,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate reference audio for voice cloning.
|
||||
|
||||
@@ -288,7 +288,7 @@ def validate_and_load_reference_audio(
|
||||
min_duration: float = 2.0,
|
||||
max_duration: float = 30.0,
|
||||
min_rms: float = 0.01,
|
||||
) -> Tuple[bool, Optional[str], Optional[np.ndarray], Optional[int]]:
|
||||
) -> tuple[bool, str | None, np.ndarray | None, int | None]:
|
||||
"""
|
||||
Validate and load reference audio in a single pass.
|
||||
|
||||
@@ -315,4 +315,4 @@ def validate_and_load_reference_audio(
|
||||
|
||||
return True, None, audio, sr
|
||||
except Exception as e:
|
||||
return False, f"Error validating audio: {str(e)}", None, None
|
||||
return False, f"Error validating audio: {e!s}", None, None
|
||||
|
||||
@@ -4,9 +4,10 @@ Voice prompt caching utilities.
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import torch
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from typing import Any, Union
|
||||
|
||||
import torch
|
||||
|
||||
from .. import config
|
||||
|
||||
@@ -19,7 +20,7 @@ def _get_cache_dir() -> Path:
|
||||
|
||||
|
||||
# In-memory cache - can store dict (voice prompt) or tensor (legacy)
|
||||
_memory_cache: dict[str, Union[torch.Tensor, Dict[str, Any]]] = {}
|
||||
_memory_cache: dict[str, Union[torch.Tensor, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def get_cache_key(audio_path: str, reference_text: str) -> str:
|
||||
@@ -46,7 +47,7 @@ def get_cache_key(audio_path: str, reference_text: str) -> str:
|
||||
|
||||
def get_cached_voice_prompt(
|
||||
cache_key: str,
|
||||
) -> Optional[Union[torch.Tensor, Dict[str, Any]]]:
|
||||
) -> Union[torch.Tensor, dict[str, Any]] | None:
|
||||
"""
|
||||
Get cached voice prompt if available.
|
||||
|
||||
@@ -76,7 +77,7 @@ def get_cached_voice_prompt(
|
||||
|
||||
def cache_voice_prompt(
|
||||
cache_key: str,
|
||||
voice_prompt: Union[torch.Tensor, Dict[str, Any]],
|
||||
voice_prompt: Union[torch.Tensor, dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Cache voice prompt to memory and disk.
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
MAC_PUSH_TO_TALK = ["MetaRight", "AltGr"]
|
||||
MAC_TOGGLE_TO_TALK = ["MetaRight", "AltGr", "Space"]
|
||||
NON_MAC_PUSH_TO_TALK = ["ControlRight", "ShiftRight"]
|
||||
|
||||
@@ -11,7 +11,6 @@ overhead.
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -58,7 +57,7 @@ _ABBREVIATIONS = frozenset(
|
||||
_PARA_TAG_RE = re.compile(r"\[[^\]]*\]")
|
||||
|
||||
|
||||
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*.
|
||||
|
||||
Priority: sentence-end (``.!?`` not preceded by an abbreviation and not
|
||||
@@ -73,7 +72,7 @@ def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS)
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
|
||||
while remaining:
|
||||
@@ -170,7 +169,7 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int:
|
||||
|
||||
|
||||
def concatenate_audio_chunks(
|
||||
chunks: List[np.ndarray],
|
||||
chunks: list[np.ndarray],
|
||||
sample_rate: int,
|
||||
crossfade_ms: int = 50,
|
||||
) -> np.ndarray:
|
||||
@@ -211,7 +210,7 @@ async def generate_chunked(
|
||||
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
|
||||
crossfade_ms: int = 50,
|
||||
trim_fn=None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""Generate audio with automatic chunking for long text.
|
||||
|
||||
For text shorter than *max_chunk_chars* this is a thin wrapper around
|
||||
@@ -266,7 +265,7 @@ async def generate_chunked(
|
||||
len(chunks),
|
||||
max_chunk_chars,
|
||||
)
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
audio_chunks: list[np.ndarray] = []
|
||||
sample_rate: int | None = None
|
||||
|
||||
for i, chunk_text in enumerate(chunks):
|
||||
|
||||
@@ -21,7 +21,6 @@ import types
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
|
||||
# ── Snake activation (from dac/nn/layers.py) ────────────────────────
|
||||
|
||||
# NOTE: The original DAC code uses @torch.jit.script here for a 1.4x
|
||||
|
||||
+12
-13
@@ -19,24 +19,23 @@ Supported effect types:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pedalboard import (
|
||||
Pedalboard,
|
||||
Chorus,
|
||||
Reverb,
|
||||
Compressor,
|
||||
Delay,
|
||||
Gain,
|
||||
HighpassFilter,
|
||||
LowpassFilter,
|
||||
Delay,
|
||||
Pedalboard,
|
||||
PitchShift,
|
||||
Reverb,
|
||||
)
|
||||
|
||||
|
||||
# Each param definition: (default, min, max, description)
|
||||
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
EFFECT_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
"chorus": {
|
||||
"cls": Chorus,
|
||||
"label": "Chorus / Flanger",
|
||||
@@ -147,7 +146,7 @@ EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
BUILTIN_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"robotic": {
|
||||
"name": "Robotic",
|
||||
"sort_order": 0,
|
||||
@@ -255,7 +254,7 @@ BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Used by the frontend to build the effects chain editor UI.
|
||||
@@ -273,12 +272,12 @@ def get_available_effects() -> List[Dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
|
||||
def get_builtin_presets() -> dict[str, dict[str, Any]]:
|
||||
"""Return all built-in effect presets."""
|
||||
return BUILTIN_PRESETS
|
||||
|
||||
|
||||
def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
|
||||
def validate_effects_chain(effects_chain: list[dict[str, Any]]) -> str | None:
|
||||
"""Validate an effects chain configuration.
|
||||
|
||||
Returns None if valid, or an error message string.
|
||||
@@ -315,7 +314,7 @@ def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]
|
||||
return None
|
||||
|
||||
|
||||
def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
|
||||
def build_pedalboard(effects_chain: list[dict[str, Any]]) -> Pedalboard:
|
||||
"""Build a Pedalboard instance from an effects chain config.
|
||||
|
||||
Skips effects where ``enabled`` is ``False``.
|
||||
@@ -342,7 +341,7 @@ def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
|
||||
def apply_effects(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int,
|
||||
effects_chain: List[Dict[str, Any]],
|
||||
effects_chain: list[dict[str, Any]],
|
||||
) -> np.ndarray:
|
||||
"""Apply an effects chain to audio data.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,9 +25,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_offline_lock = threading.RLock()
|
||||
_offline_refcount = 0
|
||||
_saved_env: Optional[str] = None
|
||||
_saved_hf_const: Optional[bool] = None
|
||||
_saved_transformers_const: Optional[bool] = None
|
||||
_saved_env: str | None = None
|
||||
_saved_hf_const: bool | None = None
|
||||
_saved_transformers_const: bool | None = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -61,8 +61,8 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
||||
# bumping the refcount — a persistent offline leak that outlives
|
||||
# the process and is miserable to debug.
|
||||
prev_env = os.environ.get("HF_HUB_OFFLINE")
|
||||
prev_hf: Optional[bool] = None
|
||||
prev_tf: Optional[bool] = None
|
||||
prev_hf: bool | None = None
|
||||
prev_tf: bool | None = None
|
||||
try:
|
||||
try:
|
||||
import huggingface_hub.constants as hf_const
|
||||
@@ -206,8 +206,8 @@ def patch_huggingface_hub_offline():
|
||||
repo_id: str,
|
||||
filename: str,
|
||||
cache_dir: Union[str, Path, None] = None,
|
||||
revision: Optional[str] = None,
|
||||
repo_type: Optional[str] = None,
|
||||
revision: str | None = None,
|
||||
repo_type: str | None = None,
|
||||
):
|
||||
result = original_try_load(
|
||||
repo_id=repo_id,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
HuggingFace Hub download progress tracking.
|
||||
"""
|
||||
|
||||
from typing import Optional, Callable
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
import threading
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
class HFProgressTracker:
|
||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||
|
||||
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||
def __init__(self, progress_callback: Callable | None = None, filter_non_downloads: bool = False):
|
||||
self.progress_callback = progress_callback
|
||||
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||
self._original_tqdm_class = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Image processing utilities for avatar uploads."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# JPEG can be reported as 'JPEG' or 'MPO' (for multi-picture format from some cameras)
|
||||
@@ -10,7 +10,7 @@ MAX_SIZE = 512
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
|
||||
def validate_image(file_path: str) -> Tuple[bool, Optional[str]]:
|
||||
def validate_image(file_path: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate image format and file size.
|
||||
|
||||
@@ -41,7 +41,7 @@ def validate_image(file_path: str) -> Tuple[bool, Optional[str]]:
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Invalid image file: {str(e)}"
|
||||
return False, f"Invalid image file: {e!s}"
|
||||
|
||||
|
||||
def process_avatar(input_path: str, output_path: str, max_size: int = MAX_SIZE) -> None:
|
||||
@@ -59,7 +59,7 @@ def process_avatar(input_path: str, output_path: str, max_size: int = MAX_SIZE)
|
||||
# Handle EXIF orientation for JPEG images
|
||||
try:
|
||||
from PIL import ExifTags
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
for orientation in ExifTags.TAGS:
|
||||
if ExifTags.TAGS[orientation] == 'Orientation':
|
||||
break
|
||||
exif = img._getexif()
|
||||
|
||||
+13
-15
@@ -2,8 +2,6 @@
|
||||
Progress tracking for model downloads using Server-Sent Events.
|
||||
"""
|
||||
|
||||
from typing import Optional, Callable, Dict, List
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
@@ -21,18 +19,18 @@ class ProgressManager:
|
||||
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||
|
||||
def __init__(self):
|
||||
self._progress: Dict[str, Dict] = {}
|
||||
self._listeners: Dict[str, list] = {}
|
||||
self._progress: dict[str, dict] = {}
|
||||
self._listeners: dict[str, list] = {}
|
||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||
self._main_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._last_notify_time: dict[str, float] = {} # Last notification time per model
|
||||
self._last_notify_progress: dict[str, float] = {} # Last notified progress per model
|
||||
|
||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the main event loop for thread-safe operations."""
|
||||
self._main_loop = loop
|
||||
|
||||
def _notify_listeners_threadsafe(self, model_name: str, progress_data: Dict):
|
||||
def _notify_listeners_threadsafe(self, model_name: str, progress_data: dict):
|
||||
"""Notify listeners in a thread-safe manner."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,7 +64,7 @@ class ProgressManager:
|
||||
model_name: str,
|
||||
current: int,
|
||||
total: int,
|
||||
filename: Optional[str] = None,
|
||||
filename: str | None = None,
|
||||
status: str = "downloading",
|
||||
):
|
||||
"""
|
||||
@@ -143,13 +141,13 @@ class ProgressManager:
|
||||
else:
|
||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||
|
||||
def get_progress(self, model_name: str) -> Optional[Dict]:
|
||||
def get_progress(self, model_name: str) -> dict | None:
|
||||
"""Get current progress for a model. Thread-safe."""
|
||||
with self._lock:
|
||||
progress = self._progress.get(model_name)
|
||||
return progress.copy() if progress else None
|
||||
|
||||
def get_all_active(self) -> List[Dict]:
|
||||
def get_all_active(self) -> list[dict]:
|
||||
"""Get all active downloads (status is 'downloading' or 'extracting'). Thread-safe."""
|
||||
active = []
|
||||
with self._lock:
|
||||
@@ -159,7 +157,7 @@ class ProgressManager:
|
||||
active.append(progress.copy())
|
||||
return active
|
||||
|
||||
def create_progress_callback(self, model_name: str, filename: Optional[str] = None):
|
||||
def create_progress_callback(self, model_name: str, filename: str | None = None):
|
||||
"""
|
||||
Create a progress callback function for HuggingFace downloads.
|
||||
|
||||
@@ -170,7 +168,7 @@ class ProgressManager:
|
||||
Returns:
|
||||
Callback function
|
||||
"""
|
||||
def callback(progress: Dict):
|
||||
def callback(progress: dict):
|
||||
"""HuggingFace Hub progress callback."""
|
||||
if "total" in progress and "current" in progress:
|
||||
current = progress.get("current", 0)
|
||||
@@ -242,7 +240,7 @@ class ProgressManager:
|
||||
if progress.get("status") in ("complete", "error"):
|
||||
logger.info(f"Download {progress.get('status')} for {model_name}, closing SSE connection")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
# Send heartbeat
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
@@ -304,7 +302,7 @@ class ProgressManager:
|
||||
|
||||
|
||||
# Global progress manager instance
|
||||
_progress_manager: Optional[ProgressManager] = None
|
||||
_progress_manager: ProgressManager | None = None
|
||||
|
||||
|
||||
def get_progress_manager() -> ProgressManager:
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
Task tracking for active downloads and generations.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -13,7 +12,7 @@ class DownloadTask:
|
||||
model_name: str
|
||||
status: str = "downloading" # downloading, extracting, complete, error
|
||||
started_at: datetime = field(default_factory=datetime.utcnow)
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,8 +28,8 @@ class TaskManager:
|
||||
"""Manages active downloads and generations."""
|
||||
|
||||
def __init__(self):
|
||||
self._active_downloads: Dict[str, DownloadTask] = {}
|
||||
self._active_generations: Dict[str, GenerationTask] = {}
|
||||
self._active_downloads: dict[str, DownloadTask] = {}
|
||||
self._active_generations: dict[str, GenerationTask] = {}
|
||||
|
||||
def start_download(self, model_name: str) -> None:
|
||||
"""Mark a download as started."""
|
||||
@@ -64,11 +63,11 @@ class TaskManager:
|
||||
if task_id in self._active_generations:
|
||||
del self._active_generations[task_id]
|
||||
|
||||
def get_active_downloads(self) -> List[DownloadTask]:
|
||||
def get_active_downloads(self) -> list[DownloadTask]:
|
||||
"""Get all active downloads."""
|
||||
return list(self._active_downloads.values())
|
||||
|
||||
def get_active_generations(self) -> List[GenerationTask]:
|
||||
def get_active_generations(self) -> list[GenerationTask]:
|
||||
"""Get all active generations."""
|
||||
return list(self._active_generations.values())
|
||||
|
||||
@@ -91,7 +90,7 @@ class TaskManager:
|
||||
|
||||
|
||||
# Global task manager instance
|
||||
_task_manager: Optional[TaskManager] = None
|
||||
_task_manager: TaskManager | None = None
|
||||
|
||||
|
||||
def get_task_manager() -> TaskManager:
|
||||
|
||||
Reference in New Issue
Block a user