diff --git a/CHANGELOG.md b/CHANGELOG.md index d662bd3e..f3cab820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,14 +66,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning ### Added -- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks - - Includes Python version detection and compatibility warnings - - Self-documenting help system with `make help` - - Colored output for better readability - - Supports parallel development server execution +- **justfile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks + - Cross-platform support (macOS, Linux, Windows) + - Python version detection and compatibility warnings + - Self-documenting help system with `just --list` ### Changed -- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup +- **README** - Updated Quick Start with justfile-based setup instructions + +### Removed +- **Makefile** - Replaced by justfile (cross-platform, simpler syntax) --- diff --git a/Makefile b/Makefile deleted file mode 100644 index 38918613..00000000 --- a/Makefile +++ /dev/null @@ -1,250 +0,0 @@ -# Voicebox Makefile -# Unix-only (macOS/Linux). Windows users should use WSL. - -SHELL := /bin/bash -.DEFAULT_GOAL := help - -# Directories -BACKEND_DIR := backend -TAURI_DIR := tauri -WEB_DIR := web -APP_DIR := app - -# Python (prefer 3.12, fallback to 3.13, then python3) -PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3) -VENV := $(CURDIR)/$(BACKEND_DIR)/venv -VENV_BIN := $(VENV)/bin -PIP := $(VENV_BIN)/pip -PYTHON_VENV := $(VENV_BIN)/python - -# Colors for output -BLUE := \033[0;34m -GREEN := \033[0;32m -YELLOW := \033[0;33m -NC := \033[0m # No Color - -.PHONY: help -help: ## Show this help message - @echo -e "$(BLUE)Voicebox$(NC) - Development Commands" - @echo "" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ - awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}' - -# ============================================================================= -# SETUP -# ============================================================================= - -.PHONY: setup setup-js setup-python setup-rust - -setup: setup-js setup-python ## Full project setup (all dependencies) - @echo -e "$(GREEN)✓ Setup complete!$(NC)" - @echo -e " Run $(YELLOW)make dev$(NC) to start development servers" - -setup-js: ## Install JavaScript dependencies (bun) - @echo -e "$(BLUE)Installing JavaScript dependencies...$(NC)" - bun install - -setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and dependencies - @echo -e "$(BLUE)Installing Python dependencies...$(NC)" - $(PIP) install --upgrade pip - $(PIP) install -r $(BACKEND_DIR)/requirements.txt - $(PIP) install --no-deps chatterbox-tts - @if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \ - echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \ - $(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \ - echo -e "$(GREEN)✓ MLX backend enabled (native Metal acceleration)$(NC)"; \ - fi - $(PIP) install git+https://github.com/QwenLM/Qwen3-TTS.git - @echo -e "$(GREEN)✓ Python environment ready$(NC)" - -$(VENV)/bin/activate: - @echo -e "$(BLUE)Creating Python virtual environment...$(NC)" - @PY_MINOR=$$($(PYTHON) -c "import sys; print(sys.version_info[1])"); \ - if [ "$$PY_MINOR" -gt 13 ]; then \ - echo -e "$(YELLOW)Warning: Python 3.$$PY_MINOR detected. ML packages may not be compatible.$(NC)"; \ - echo -e "$(YELLOW)Recommended: Use Python 3.12 or 3.13 (brew install python@3.12)$(NC)"; \ - fi - $(PYTHON) -m venv $(VENV) - -setup-rust: ## Install Rust toolchain (if not present) - @command -v rustc >/dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# ============================================================================= -# DEVELOPMENT -# ============================================================================= - -.PHONY: dev dev-backend dev-frontend dev-web kill-dev - -dev: ## Start backend + desktop app (parallel) - @echo -e "$(BLUE)Starting development servers...$(NC)" - @echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)" - @trap 'kill 0' EXIT; \ - $(MAKE) dev-backend & \ - sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \ - WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \ - else \ - $(MAKE) dev-frontend; \ - fi & \ - wait - -dev-backend: ## Start FastAPI backend server - @echo -e "$(BLUE)Starting backend server on http://localhost:17493$(NC)" - $(VENV_BIN)/uvicorn backend.main:app --reload --port 17493 - -dev-frontend: ## Start Tauri desktop app - @echo -e "$(BLUE)Starting Tauri desktop app...$(NC)" - bun run dev - -dev-web: ## Start backend + web app (parallel) - @echo -e "$(BLUE)Starting web development servers...$(NC)" - @trap 'kill 0' EXIT; \ - $(MAKE) dev-backend & \ - sleep 2 && cd $(WEB_DIR) && bun run dev & \ - wait - -kill-dev: ## Kill all development processes - @echo -e "$(YELLOW)Killing development processes...$(NC)" - -pkill -f "uvicorn main:app" 2>/dev/null || true - -pkill -f "vite" 2>/dev/null || true - @echo -e "$(GREEN)✓ Processes killed$(NC)" - -# ============================================================================= -# BUILD -# ============================================================================= - -.PHONY: build build-server build-tauri build-web - -build: build-server build-tauri ## Build everything (server binary + desktop app) - @echo -e "$(GREEN)✓ Build complete!$(NC)" - -build-server: ## Build Python server binary - @echo -e "$(BLUE)Building server binary...$(NC)" - PATH="$(VENV_BIN):$$PATH" ./scripts/build-server.sh - -build-tauri: ## Build Tauri desktop app - @echo -e "$(BLUE)Building Tauri desktop app...$(NC)" - cd $(TAURI_DIR) && bun run tauri build - -build-web: ## Build web app - @echo -e "$(BLUE)Building web app...$(NC)" - cd $(WEB_DIR) && bun run build - @echo -e "$(GREEN)✓ Web build output in $(WEB_DIR)/dist/$(NC)" - -# ============================================================================= -# DATABASE & API -# ============================================================================= - -.PHONY: db-init db-reset generate-api - -db-init: $(VENV)/bin/activate ## Initialize SQLite database - @echo -e "$(BLUE)Initializing database...$(NC)" - cd $(BACKEND_DIR) && $(PYTHON_VENV) -c "from database import init_db; init_db()" - @echo -e "$(GREEN)✓ Database created at $(BACKEND_DIR)/data/voicebox.db$(NC)" - -db-reset: ## Reset database (delete and reinitialize) - @echo -e "$(YELLOW)Resetting database...$(NC)" - rm -f $(BACKEND_DIR)/data/voicebox.db - $(MAKE) db-init - -generate-api: ## Generate TypeScript API client from OpenAPI schema - @echo -e "$(BLUE)Generating API client...$(NC)" - @echo -e "$(YELLOW)Note: Backend must be running (make dev-backend)$(NC)" - ./scripts/generate-api.sh - @echo -e "$(GREEN)✓ API client generated in $(APP_DIR)/src/lib/api/$(NC)" - -# ============================================================================= -# CODE QUALITY -# ============================================================================= - -.PHONY: lint format typecheck check - -lint: ## Run linter (Biome) - @echo -e "$(BLUE)Linting...$(NC)" - bun run lint - -format: ## Format code (Biome) - @echo -e "$(BLUE)Formatting...$(NC)" - bun run format - -typecheck: ## Run TypeScript type checking - @echo -e "$(BLUE)Type checking...$(NC)" - bun run tsc --noEmit - -check: ## Run all checks (Biome lint + format + type check) - @echo -e "$(BLUE)Running all checks...$(NC)" - bun run check - @echo -e "$(GREEN)✓ All checks passed$(NC)" - -# ============================================================================= -# TESTING -# ============================================================================= - -.PHONY: test test-backend test-frontend - -test: test-backend test-frontend ## Run all tests - @echo -e "$(GREEN)✓ All tests passed$(NC)" - -test-backend: ## Run Python backend tests (requires pytest) - @echo -e "$(BLUE)Running backend tests...$(NC)" - @if [ -f "$(VENV_BIN)/pytest" ]; then \ - cd $(BACKEND_DIR) && $(VENV_BIN)/pytest -v; \ - else \ - echo -e "$(YELLOW)pytest not installed. Run: $(PIP) install pytest$(NC)"; \ - exit 1; \ - fi - -test-frontend: ## Run frontend tests (requires test script in package.json) - @echo -e "$(BLUE)Running frontend tests...$(NC)" - @if bun run test --help >/dev/null 2>&1; then \ - bun run test; \ - else \ - echo -e "$(YELLOW)No test script configured$(NC)"; \ - exit 1; \ - fi - -# ============================================================================= -# LOGS & DEBUGGING -# ============================================================================= - -.PHONY: logs docs - -logs: ## Tail backend logs - @echo -e "$(BLUE)Tailing logs (Ctrl+C to stop)...$(NC)" - tail -f $(BACKEND_DIR)/logs/*.log 2>/dev/null || echo "No log files found" - -docs: ## Open API documentation (backend must be running) - @echo -e "$(BLUE)Opening API docs...$(NC)" - open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs - -# ============================================================================= -# CLEAN -# ============================================================================= - -.PHONY: clean clean-python clean-build clean-all - -clean: ## Clean build artifacts - @echo -e "$(BLUE)Cleaning build artifacts...$(NC)" - rm -rf $(TAURI_DIR)/src-tauri/target/release - rm -rf $(WEB_DIR)/dist - rm -rf $(APP_DIR)/dist - @echo -e "$(GREEN)✓ Build artifacts cleaned$(NC)" - -clean-python: ## Clean Python cache and virtual environment - @echo -e "$(BLUE)Cleaning Python files...$(NC)" - rm -rf $(VENV) - find $(BACKEND_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true - find $(BACKEND_DIR) -type f -name "*.pyc" -delete 2>/dev/null || true - @echo -e "$(GREEN)✓ Python environment cleaned$(NC)" - -clean-build: ## Clean Rust/Tauri build cache - @echo -e "$(BLUE)Cleaning Rust build cache...$(NC)" - cd $(TAURI_DIR)/src-tauri && cargo clean - @echo -e "$(GREEN)✓ Rust cache cleaned$(NC)" - -clean-all: clean clean-python clean-build ## Nuclear clean (everything) - @echo -e "$(BLUE)Cleaning node_modules...$(NC)" - rm -rf node_modules - rm -rf $(APP_DIR)/node_modules - rm -rf $(TAURI_DIR)/node_modules - rm -rf $(WEB_DIR)/node_modules - @echo -e "$(GREEN)✓ Full clean complete$(NC)" diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index e5c08175..2e0c983e 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -31,7 +31,7 @@ Two-part fix: ## Testing To test this fix: -1. Build Voicebox from source: `make build` +1. Build Voicebox from source: `just build` 2. Disconnect from internet 3. Try generating speech 4. Should work without network requests @@ -40,13 +40,13 @@ To test this fix: ```bash # Install dependencies -pip install -r requirements.txt +just setup # Build the app -make build +just build # Or build just the server -make build-server +just build-server ``` ## Notes diff --git a/backend/REFACTOR_PLAN.md b/backend/REFACTOR_PLAN.md index ef9e520e..c5304d4b 100644 --- a/backend/REFACTOR_PLAN.md +++ b/backend/REFACTOR_PLAN.md @@ -25,39 +25,16 @@ Net result: -1,078 lines across the backend. --- -## Phase 3: Generation Service +## Phase 3: Generation Service ✓ -The three generation closures in `main.py` (`_run_generation:782`, `_run_retry:923`, `_run_regenerate:1018`) share ~80% of their logic. Extract into a service module. +Extracted the three near-identical generation closures (`_run_generation`, `_run_retry`, `_run_regenerate`) and the background queue machinery from `main.py` into a new `services/` layer: -### Create `services/generation.py` +- `services/task_queue.py` — `create_background_task()`, `enqueue_generation()`, `init_queue()`, and the serial `_generation_worker`. Replaces the module-level globals and helpers that were in `main.py:63-92`. +- `services/generation.py` — single `run_generation()` function with a `mode` parameter (`"generate"`, `"retry"`, `"regenerate"`). Mode-specific persistence is handled by three small sync helpers (`_save_generate`, `_save_retry`, `_save_regenerate`). The shared pipeline (model loading, voice prompt creation, chunked inference, normalization, error handling, task manager lifecycle) is written once. -Single orchestration function with mode parameter: +Route handlers in `main.py` are now thin: validate input, create/update DB row, resolve effects chain, then `enqueue_generation(run_generation(...))`. -```python -async def run_generation( - generation_id: str, - profile_id: str, - text: str, - language: str, - engine: str, - model_size: str, - seed: Optional[int], - normalize: bool, - effects_chain: Optional[list], - instruct_text: Optional[str], - mode: Literal["generate", "retry", "regenerate"], - version_label: Optional[str] = None, -): -``` - -Differences between modes are small and can be handled with conditionals: -- `retry`: reuses same seed, skips effects/versions -- `regenerate`: seed=None, creates a new version with auto-label -- `generate`: full pipeline including effects version - -### Move background queue management - -Move `_generation_queue`, `_generation_worker`, `_enqueue_generation`, `_background_tasks`, and `_create_background_task` (currently `main.py:63-92`) into the service module or a dedicated `services/task_queue.py`. +Net result: ~240 lines of duplicated closure code replaced by a single 230-line service module + 50-line queue module. --- @@ -167,6 +144,29 @@ Option A is simpler and non-disruptive. Option B is cleaner long-term but touche --- +## Phase 7: Style Guide & Tooling ✓ + +Added a Python style guide (`backend/STYLE_GUIDE.md`) and automated linting/formatting with ruff. Removed the redundant Makefile — the justfile is now the single task runner. + +### Style guide + +Codifies conventions for the refactor: Google-style docstrings, native 3.12 type syntax (`list[str]`, `X | None` — no `from __future__` or `typing.List`), `logging` module instead of `print()`, two-layer error handling (domain exceptions + route-layer HTTPException), import grouping (stdlib / third-party / local with isort enforcement), 120-char line length. + +### Ruff config (`pyproject.toml`) + +Added project-root `pyproject.toml` with ruff linter + formatter config. Rule sets: `F`, `E`, `W`, `I` (isort), `N` (naming), `UP` (pyupgrade to 3.12), `B` (bugbear), `SIM`, `RET`, `T20` (print detection), `PT` (pytest style), `RUF`. `T201` (print) is ignored during migration — remove once logging conversion is done. + +Initial scan: 1,103 lint violations (879 auto-fixable), 38 files needing reformatting. Mostly whitespace (W293), type annotation modernization (UP045/UP006), and import sorting (I001). To be fixed file-by-file as files are touched, not in a big-bang pass. + +### Justfile updates + +- `just check` now runs both JS (Biome) and Python (ruff) checks +- Added `just check-python`, `just lint-python`, `just format-python`, `just fix-python`, `just test` +- `just setup-python` installs `ruff`, `pytest`, `pytest-asyncio` as dev tools +- Deleted `Makefile` and updated all references in `CHANGELOG.md`, `PATCH_NOTES.md`, `docs/plans/ADDING_TTS_ENGINES.md` + +--- + ## Notes - Each phase is independently shippable and testable diff --git a/backend/STYLE_GUIDE.md b/backend/STYLE_GUIDE.md new file mode 100644 index 00000000..225f763d --- /dev/null +++ b/backend/STYLE_GUIDE.md @@ -0,0 +1,460 @@ +# Python Style Guide + +Target: **Python 3.12+** | Formatter/Linter: **Ruff** | Config: `pyproject.toml` (project root) + +This guide codifies the conventions used across the backend, and prescribes the target style for code written during the refactor (Phases 3-6). Existing code should be migrated incrementally -- don't reformat entire files in unrelated PRs. + +--- + +## Formatting + +Enforced by `ruff format` (Black-compatible). + +- **Line length**: 120 characters. +- **Indent**: 4 spaces. No tabs. +- **Trailing commas**: Required on multi-line function signatures, arguments, collections. +- **Quotes**: Double quotes (`"`) for strings. Single quotes are acceptable in f-string expressions and dict keys inside f-strings where avoiding escapes improves readability. + +Run: `ruff format backend/` + +--- + +## Imports + +Enforced by ruff's `isort` rules (rule set `I`). + +**Grouping** -- three blocks separated by a blank line: + +```python +import asyncio # 1. stdlib +from pathlib import Path + +import numpy as np # 2. third-party +from fastapi import APIRouter, HTTPException +from sqlalchemy.orm import Session + +from backend.config import get_data_dir # 3. local (absolute) +from .database import get_db # or relative +``` + +**Rules:** +- Within the `backend` package, use **relative imports** for sibling/child modules: `from .database import get_db`, `from ..utils.audio import load_audio`. +- Absolute imports are fine for top-level references from entry points (`main.py`, `server.py`). +- Never use wildcard imports (`from module import *`). +- One import per line for `from X import Y` when there are 4+ names; below that, comma-separated is fine. +- **Lazy imports** are acceptable for heavy dependencies (torch, transformers, mlx) inside functions to reduce startup time. Add a comment: `# lazy: heavy import`. + +--- + +## Type Annotations + +Python 3.12 means we use **built-in generics and union syntax natively**. No `from __future__ import annotations`, no `typing.List`/`typing.Dict`. + +```python +# Yes +def process(items: list[str], config: dict[str, int] | None = None) -> tuple[int, str]: ... + +# No +from typing import List, Dict, Optional, Tuple +def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[int, str]: ... +``` + +**What to annotate:** +- All public function signatures (parameters + return type). +- Private functions: parameters at minimum; return type encouraged. +- Module-level variables: only when the type isn't obvious from the assignment. +- Route handlers: parameters are annotated via FastAPI's dependency injection. Add explicit `-> SomeResponse` return types when the route doesn't use `response_model`. + +**Imports from `typing` that are still needed** (no built-in equivalent): +`Literal`, `TypeAlias`, `Protocol`, `runtime_checkable`, `Callable`, `Any`, `ClassVar`, `TypeVar`, `overload`, `TYPE_CHECKING`. + +Use `collections.abc` for abstract types: `Sequence`, `Mapping`, `Iterable`, `Iterator`, `Generator`. + +--- + +## Naming + +| Thing | Convention | Example | +|-------|-----------|---------| +| Module | `snake_case` | `task_queue.py` | +| Class | `PascalCase` | `ProgressManager` | +| Function / method | `snake_case` | `create_profile` | +| Variable | `snake_case` | `sample_rate` | +| Constant | `UPPER_SNAKE_CASE` | `DEFAULT_SAMPLE_RATE` | +| Private | `_leading_underscore` | `_generation_queue` | +| Type alias | `PascalCase` | `EffectChain = list[dict[str, Any]]` | + +**Specific conventions:** +- Database ORM models imported with `DB` prefix alias: `from .database import VoiceProfile as DBVoiceProfile`. +- Pydantic models use descriptive suffixes: `VoiceProfileCreate`, `VoiceProfileResponse`, `GenerationRequest`. +- Backend classes use engine-name prefix: `MLXTTSBackend`, `PyTorchSTTBackend`. + +--- + +## Docstrings + +**Google style**. Required on all public functions, classes, and modules. + +```python +def combine_voice_prompts( + profile_dir: Path, + *, + target_sr: int = 24000, +) -> tuple[np.ndarray, int]: + """Load and concatenate all voice prompt files for a profile. + + Reads .wav/.mp3/.flac files from the profile directory, resamples to + the target sample rate, normalizes, and concatenates into a single array. + + Args: + profile_dir: Path to the voice profile directory containing audio files. + target_sr: Target sample rate for the output. Defaults to 24000. + + Returns: + Tuple of (concatenated audio array, sample rate). + + Raises: + FileNotFoundError: If profile_dir does not exist. + ValueError: If no valid audio files are found. + """ +``` + +**Short form** is fine for simple functions: + +```python +def get_db_path() -> Path: + """Get the path to the SQLite database file.""" +``` + +**When to skip**: Private helpers under ~5 lines where the name and signature make intent obvious. + +**Module docstrings**: A single sentence at the top of every file describing its purpose. + +```python +"""Voice profile CRUD operations.""" +``` + +--- + +## Comments + +Comments explain **why**, not **what**. If the code needs a comment to explain what it does, the code should be rewritten to be clearer. The exceptions are non-obvious performance choices, external constraints, and concurrency/race-condition reasoning -- those always deserve a comment. + +### No section dividers + +Do not use ASCII dividers to create visual sections in files: + +```python +# No -- any of these: +# ============================================ +# GENERATION ENDPOINTS +# ============================================ + +# --------------------------------------------------------------------------- +# Device detection +# --------------------------------------------------------------------------- + +# --- Load model -------------------------------------------------- +``` + +If a file needs section dividers to be navigable, the file is too long. Split it into modules. Within a function, if you need labeled sections to follow the logic, extract those sections into named functions. + +### Inline comments + +Inline comments (end-of-line) are fine when they add information the code can't express: + +```python +# Yes -- explains a non-obvious constraint or gives context: +audio, sr = load_audio(path, sr=24000) # Qwen expects 24kHz mono +_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup +"tauri://localhost", # Tauri webview (macOS) + +# No -- restates the code: +# Check if profile name already exists +existing = db.query(DBVoiceProfile).filter_by(name=data.name).first() + +# Delete from database +db.delete(sample) + +# Update fields +profile.name = data.name +``` + +Delete comments that narrate what the next line of code obviously does. If the function name, variable name, or method call already communicates intent, the comment is noise. + +### Block comments + +Use block comments for **why** explanations -- constraints, workarounds, non-obvious decisions: + +```python +# PyInstaller + multiprocessing: child processes re-execute the frozen binary +# with internal arguments. freeze_support() handles this and exits early. +multiprocessing.freeze_support() + +# Mark any stale "generating" records as failed -- these are leftovers +# from a previous process that was killed mid-generation. +db.query(Generation).filter_by(status="generating").update({"status": "failed"}) +``` + +Keep block comments tight. Two to three lines is normal. If you need a paragraph, it probably belongs in the docstring or a design doc. + +### Linter/type-checker suppression + +Always add a reason after `noqa` and `type: ignore`: + +```python +import intel_extension_for_pytorch # noqa: F401 -- side-effect import enables XPU +_queue: asyncio.Queue = None # type: ignore[assignment] # initialized at startup +``` + +Bare `# noqa` or `# type: ignore` with no explanation are not allowed. + +### TODO / FIXME + +Use sparingly. Every `TODO` must include a brief description of what needs doing. Don't use them as a substitute for tracking work properly: + +```python +# TODO: replace with async SQLAlchemy once CRUD modules are migrated (Phase 5) +result = await asyncio.to_thread(profiles.get_profile, profile_id, db) +``` + +Never commit `HACK`, `XXX`, or `FIXME` -- fix the problem or file an issue. + +### Commented-out code + +Delete it. That's what git is for. If you need to document that something was intentionally removed, a short tombstone comment is acceptable: + +```python +# Removed config.json-only check -- too lenient, doesn't confirm weights exist. +``` + +--- + +## Error Handling + +The refactor is standardizing on a **two-layer pattern**: + +### 1. Domain layer -- raise plain exceptions + +CRUD modules and services raise `ValueError`, `FileNotFoundError`, or (post-refactor) custom exceptions defined in `backend/errors.py`: + +```python +# backend/errors.py (to be created in Phase 4) +class NotFoundError(Exception): + """Raised when a requested resource does not exist.""" + +class ConflictError(Exception): + """Raised on uniqueness constraint violations.""" +``` + +```python +# In a service or CRUD module: +raise NotFoundError(f"Profile {profile_id} not found") +``` + +### 2. Route layer -- translate to HTTPException + +Route handlers catch domain exceptions and convert: + +```python +@router.post("/profiles") +async def create_profile(data: VoiceProfileCreate, db: Session = Depends(get_db)): + try: + return await profiles.create_profile(data, db) + except ConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) +``` + +**Background tasks** catch `Exception` broadly, log with `logger.exception()`, and update the task status to `"failed"`. + +**Never**: silently swallow exceptions, use bare `except:`, or catch `BaseException`. + +--- + +## Async + +### Rules for the refactor + +1. **Don't declare `async def` unless the function awaits something.** The current CRUD modules break this -- they will be fixed per REFACTOR_PLAN Phase 5. +2. **CPU-bound work** (audio processing, numpy operations) goes through `asyncio.to_thread()`: + ```python + audio, sr = await asyncio.to_thread(load_audio, source_path) + ``` +3. **GPU-bound TTS inference** is serialized through the generation queue (`services/task_queue.py`). Never call a backend's `generate()` directly from a route handler. +4. **Fire-and-forget tasks**: use `asyncio.create_task()` and track the task reference to prevent garbage collection: + ```python + task = asyncio.create_task(some_coro()) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + ``` + +--- + +## Logging + +Use the `logging` module. Not `print()`. + +```python +import logging + +logger = logging.getLogger(__name__) + +logger.info("Loading model %s on %s", model_name, device) +logger.warning("Cache miss for %s, downloading", repo_id) +logger.exception("Generation %s failed") # logs traceback automatically +``` + +**Rules:** +- Use `%s`-style placeholders in log calls (not f-strings). This avoids formatting the string if the log level is filtered out. +- Use `logger.exception()` inside `except` blocks -- it captures the traceback. +- Logger name should be `__name__` (yields `backend.utils.audio`, etc.). +- Existing `print()` calls should be migrated to logging as files are touched during the refactor. + +--- + +## Constants + +- Define at **module level** in the file where they're primarily used. +- Use `UPPER_SNAKE_CASE`. +- Shared/cross-cutting constants (sample rates, file size limits, CORS origins) go in `backend/config.py` after Phase 6 consolidation. +- Magic numbers in function bodies should be extracted to named constants: + ```python + # No + if len(audio) > 24000 * 60 * 10: + + # Yes + MAX_AUDIO_DURATION_SAMPLES = SAMPLE_RATE * 60 * 10 + if len(audio) > MAX_AUDIO_DURATION_SAMPLES: + ``` + +--- + +## Function Signatures + +- **Keyword-only arguments** (after `*`) for functions with 3+ parameters, especially when several share the same type: + ```python + def is_model_cached( + hf_repo: str, + *, + weight_extensions: tuple[str, ...] = (".safetensors", ".bin"), + required_files: list[str] | None = None, + ) -> bool: + ``` +- Parameters on **separate lines** when the signature exceeds ~100 characters or has 3+ params. +- **Trailing comma** after the last parameter in multi-line signatures. +- Default values inline with the parameter. + +--- + +## String Formatting + +- **f-strings** for runtime string construction. +- **`%s`-style** for `logging` calls (lazy evaluation). +- **`.format()`**: avoid; f-strings are preferred. + +--- + +## Testing + +Framework: **pytest** with `pytest-asyncio`. + +- Test files: `test_.py` in `backend/tests/`. +- Use `conftest.py` for shared fixtures (db sessions, test client, mock backends). +- Group related tests in classes: `class TestProfileCRUD:`. +- Use `@pytest.mark.asyncio` for async tests. +- Use `@pytest.mark.parametrize` to reduce repetition. +- Manual integration scripts stay in `tests/` but are clearly marked (filename prefix `manual_` or documented in `tests/README.md`). + +--- + +## Project Layout (Post-Refactor Target) + +From REFACTOR_PLAN.md Phase 4: + +``` +backend/ + app.py # FastAPI app, middleware, startup/shutdown + main.py # Entry point (imports app, runs uvicorn) + config.py # Data dirs, shared constants + errors.py # Custom exception classes + routes/ + __init__.py + health.py + profiles.py + channels.py + generations.py + history.py + stories.py + effects.py + audio.py + models.py + tasks.py + cuda.py + services/ + generation.py + task_queue.py + model_status.py + database/ + __init__.py + models.py + session.py + seed.py + backends/ + __init__.py + base.py + pytorch_backend.py + mlx_backend.py + luxtts_backend.py + chatterbox_backend.py + chatterbox_turbo_backend.py + utils/ + audio.py + effects.py + progress.py + tasks.py + hf_progress.py + hf_offline_patch.py + cache.py + images.py + chunked_tts.py + tests/ + conftest.py + test_cors.py + test_profiles.py + ... +``` + +--- + +## Ruff Adoption + +The `pyproject.toml` at the project root configures ruff for linting and formatting. Run: + +```bash +# Lint (check) +ruff check backend/ + +# Lint (auto-fix) +ruff check backend/ --fix + +# Format +ruff format backend/ +``` + +During the refactor, introduce ruff fixes file-by-file as you touch them. Don't run `--fix` across the entire codebase in one shot -- that creates unreviewable diffs. + +--- + +## Summary of Changes from Current State + +| Area | Before | After | +|------|--------|-------| +| Line length | Uncontrolled (up to 160) | 120, enforced by ruff | +| Import order | Ad-hoc | isort-grouped, enforced | +| Type syntax | Mixed `List`/`list`, sporadic `__future__` | Native `list[]`, `X \| None`, no `__future__` | +| Logging | ~80% `print()` | `logging` module everywhere | +| Error handling | 3 inconsistent patterns | Domain exceptions + route-layer HTTPException | +| Async CRUD | Fake `async def` | Sync functions (Phase 5) or real async | +| Linting | None | Ruff with auto-fix | +| Formatting | None | Ruff format (Black-compatible) | +| Tests | Mix of pytest + manual scripts | pytest throughout, shared conftest | diff --git a/backend/example_usage.py b/backend/example_usage.py deleted file mode 100644 index 2678af03..00000000 --- a/backend/example_usage.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Example usage of the voicebox backend API. - -This script demonstrates how to: -1. Create a voice profile -2. Add samples to the profile -3. Generate speech -4. List history -""" - -import requests -import time -from pathlib import Path - -# API base URL -BASE_URL = "http://localhost:8000" - - -def check_health(): - """Check if the server is running.""" - response = requests.get(f"{BASE_URL}/health") - data = response.json() - print(f"Server status: {data['status']}") - print(f"Model loaded: {data['model_loaded']}") - print(f"GPU available: {data['gpu_available']}") - print() - return data - - -def create_profile(name: str, description: str = None, language: str = "en"): - """Create a new voice profile.""" - response = requests.post( - f"{BASE_URL}/profiles", - json={ - "name": name, - "description": description, - "language": language, - }, - ) - response.raise_for_status() - profile = response.json() - print(f"Created profile: {profile['name']} (ID: {profile['id']})") - return profile - - -def add_sample(profile_id: str, audio_file: str, reference_text: str): - """Add a sample to a voice profile.""" - with open(audio_file, "rb") as f: - files = {"file": f} - data = {"reference_text": reference_text} - response = requests.post( - f"{BASE_URL}/profiles/{profile_id}/samples", - files=files, - data=data, - ) - response.raise_for_status() - sample = response.json() - print(f"Added sample: {sample['id']}") - return sample - - -def generate_speech(profile_id: str, text: str, language: str = "en", seed: int = None): - """Generate speech using a voice profile.""" - print(f"Generating speech: '{text[:50]}...'") - start_time = time.time() - - response = requests.post( - f"{BASE_URL}/generate", - json={ - "profile_id": profile_id, - "text": text, - "language": language, - "seed": seed, - }, - ) - response.raise_for_status() - generation = response.json() - - elapsed = time.time() - start_time - print(f"Generated in {elapsed:.2f}s (duration: {generation['duration']:.2f}s)") - print(f"Generation ID: {generation['id']}") - return generation - - -def download_audio(generation_id: str, output_file: str): - """Download generated audio.""" - response = requests.get(f"{BASE_URL}/audio/{generation_id}") - response.raise_for_status() - - with open(output_file, "wb") as f: - f.write(response.content) - - print(f"Saved audio to: {output_file}") - - -def list_profiles(): - """List all voice profiles.""" - response = requests.get(f"{BASE_URL}/profiles") - response.raise_for_status() - profiles = response.json() - - print(f"Found {len(profiles)} profiles:") - for profile in profiles: - print(f" - {profile['name']} (ID: {profile['id']})") - - return profiles - - -def list_history(profile_id: str = None, limit: int = 10): - """List generation history.""" - params = {"limit": limit} - if profile_id: - params["profile_id"] = profile_id - - response = requests.get(f"{BASE_URL}/history", params=params) - response.raise_for_status() - history = response.json() - - print(f"Found {len(history)} generations:") - for gen in history: - print(f" - {gen['text'][:50]}... ({gen['duration']:.2f}s)") - - return history - - -def transcribe_audio(audio_file: str, language: str = None): - """Transcribe audio file.""" - print(f"Transcribing: {audio_file}") - - with open(audio_file, "rb") as f: - files = {"file": f} - data = {} - if language: - data["language"] = language - - response = requests.post( - f"{BASE_URL}/transcribe", - files=files, - data=data, - ) - - response.raise_for_status() - result = response.json() - - print(f"Transcription: {result['text']}") - print(f"Duration: {result['duration']:.2f}s") - return result - - -def main(): - """Run example workflow.""" - print("=" * 60) - print("voicebox Backend API Example") - print("=" * 60) - print() - - # 1. Check health - print("1. Checking server health...") - check_health() - - # 2. Create a profile - print("2. Creating voice profile...") - profile = create_profile( - name="Example Voice", - description="A test voice profile", - language="en", - ) - profile_id = profile["id"] - print() - - # 3. Add samples (you'll need actual audio files) - print("3. Adding samples...") - print(" (Skipping - add your own audio files here)") - # Uncomment and add your audio file: - # sample = add_sample( - # profile_id, - # "path/to/your/sample.wav", - # "This is the transcript of the audio", - # ) - print() - - # 4. Generate speech (requires samples to be added first) - print("4. Generating speech...") - print(" (Skipping - add samples first)") - # Uncomment after adding samples: - # generation = generate_speech( - # profile_id, - # "Hello, this is a test of the voice cloning system.", - # language="en", - # seed=42, - # ) - # - # # 5. Download audio - # print("\n5. Downloading audio...") - # download_audio(generation["id"], "output.wav") - print() - - # 6. List profiles - print("6. Listing all profiles...") - list_profiles() - print() - - # 7. List history - print("7. Listing generation history...") - list_history(limit=5) - print() - - # 8. Transcribe audio (you'll need an audio file) - print("8. Transcribing audio...") - print(" (Skipping - add your own audio file here)") - # Uncomment and add your audio file: - # transcribe_audio("path/to/audio.wav", language="en") - print() - - print("=" * 60) - print("Example complete!") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/backend/main.py b/backend/main.py index c16a85b3..71cd240d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -58,38 +58,8 @@ from .utils.progress import get_progress_manager from .utils.tasks import get_task_manager from .utils.cache import clear_voice_prompt_cache from .platform_detect import get_backend_type - -# Keep references to fire-and-forget background tasks to prevent GC -_background_tasks: set = set() - -# Generation queue — serializes TTS inference to avoid GPU contention -_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup - - -def _create_background_task(coro) -> asyncio.Task: - """Create a background task and prevent it from being garbage collected.""" - task = asyncio.create_task(coro) - _background_tasks.add(task) - task.add_done_callback(_background_tasks.discard) - return task - - -async def _generation_worker(): - """Worker that processes generation tasks one at a time.""" - while True: - coro = await _generation_queue.get() - try: - await coro - except Exception: - import traceback - traceback.print_exc() - finally: - _generation_queue.task_done() - - -def _enqueue_generation(coro): - """Add a generation coroutine to the serial queue.""" - _generation_queue.put_nowait(coro) +from .services.task_queue import create_background_task, enqueue_generation, init_queue +from .services.generation import run_generation app = FastAPI( @@ -736,9 +706,8 @@ async def generate_speech( if not profile: raise HTTPException(status_code=404, detail="Profile not found") - from .backends import get_tts_backend_for_engine, engine_has_model_sizes + from .backends import engine_has_model_sizes engine = data.engine or "qwen" - tts_model = get_tts_backend_for_engine(engine) model_size = data.model_size or "1.7B" # Create the history entry immediately with status="generating" @@ -779,111 +748,21 @@ async def generate_speech( pass # Kick off TTS in background - async def _run_generation(): - bg_db = next(get_db()) - try: - # Load model - from .backends import load_engine_model, engine_needs_trim - await load_engine_model(engine, model_size) - - # Create voice prompt - voice_prompt = await profiles.create_voice_prompt_for_profile( - data.profile_id, - bg_db, - use_cache=True, - engine=engine, - ) - - from .utils.chunked_tts import generate_chunked - - trim_fn = None - if engine_needs_trim(engine): - from .utils.audio import trim_tts_output - trim_fn = trim_tts_output - - audio, sample_rate = await generate_chunked( - tts_model, - data.text, - voice_prompt, - language=data.language, - seed=data.seed, - instruct=data.instruct, - max_chunk_chars=data.max_chunk_chars, - crossfade_ms=data.crossfade_ms, - trim_fn=trim_fn, - ) - - if data.normalize: - from .utils.audio import normalize_audio - audio = normalize_audio(audio) - - duration = len(audio) / sample_rate - - # Always save clean version first - clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav" - from .utils.audio import save_audio - save_audio(audio, str(clean_audio_path), sample_rate) - - from . import versions as versions_mod - - has_effects = effects_chain_config and any( - e.get("enabled", True) for e in effects_chain_config - ) - - # Create clean version entry - versions_mod.create_version( - generation_id=generation_id, - label="original", - audio_path=str(clean_audio_path), - db=bg_db, - effects_chain=None, - is_default=not has_effects, - ) - - # Apply effects and create processed version if configured - final_audio_path = str(clean_audio_path) - if has_effects: - from .utils.effects import apply_effects, validate_effects_chain - error_msg = validate_effects_chain(effects_chain_config) - if error_msg: - print(f"Warning: invalid effects chain, skipping: {error_msg}") - else: - processed_audio = apply_effects(audio, sample_rate, effects_chain_config) - processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav" - save_audio(processed_audio, str(processed_path), sample_rate) - final_audio_path = str(processed_path) - versions_mod.create_version( - generation_id=generation_id, - label="version-2", - audio_path=str(processed_path), - db=bg_db, - effects_chain=effects_chain_config, - is_default=True, - ) - - # Update the record to completed - await history.update_generation_status( - generation_id=generation_id, - status="completed", - db=bg_db, - audio_path=final_audio_path, - duration=duration, - ) - - except Exception as e: - import traceback - traceback.print_exc() - await history.update_generation_status( - generation_id=generation_id, - status="failed", - db=bg_db, - error=str(e), - ) - finally: - task_manager.complete_generation(generation_id) - bg_db.close() - - _enqueue_generation(_run_generation()) + enqueue_generation(run_generation( + generation_id=generation_id, + profile_id=data.profile_id, + text=data.text, + language=data.language, + engine=engine, + model_size=model_size, + seed=data.seed, + normalize=data.normalize, + effects_chain=effects_chain_config, + instruct=data.instruct, + mode="generate", + max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, + )) return generation @@ -913,70 +792,17 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)): text=gen.text, ) - # Resolve engine/model from stored values - retry_engine = gen.engine or "qwen" - retry_model_size = gen.model_size or "1.7B" - - from .backends import get_tts_backend_for_engine - tts_model = get_tts_backend_for_engine(retry_engine) - - async def _run_retry(): - bg_db = next(get_db()) - try: - from .backends import load_engine_model, engine_needs_trim - await load_engine_model(retry_engine, retry_model_size) - - voice_prompt = await profiles.create_voice_prompt_for_profile( - gen.profile_id, - bg_db, - use_cache=True, - engine=retry_engine, - ) - - from .utils.chunked_tts import generate_chunked - - trim_fn = None - if engine_needs_trim(retry_engine): - from .utils.audio import trim_tts_output - trim_fn = trim_tts_output - - audio, sample_rate = await generate_chunked( - tts_model, - gen.text, - voice_prompt, - language=gen.language, - seed=gen.seed, - instruct=gen.instruct, - trim_fn=trim_fn, - ) - - duration = len(audio) / sample_rate - audio_path = config.get_generations_dir() / f"{generation_id}.wav" - - from .utils.audio import save_audio - save_audio(audio, str(audio_path), sample_rate) - - await history.update_generation_status( - generation_id=generation_id, - status="completed", - db=bg_db, - audio_path=str(audio_path), - duration=duration, - ) - except Exception as e: - import traceback - traceback.print_exc() - await history.update_generation_status( - generation_id=generation_id, - status="failed", - db=bg_db, - error=str(e), - ) - finally: - task_manager.complete_generation(generation_id) - bg_db.close() - - _enqueue_generation(_run_retry()) + enqueue_generation(run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="retry", + )) return models.GenerationResponse.model_validate(gen) @@ -993,13 +819,6 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db if (gen.status or "completed") != "completed": raise HTTPException(status_code=400, detail="Generation must be completed to regenerate") - from .backends import get_tts_backend_for_engine - from . import versions as versions_mod - - regen_engine = gen.engine or "qwen" - regen_model_size = gen.model_size or "1.7B" - tts_model = get_tts_backend_for_engine(regen_engine) - # Set to generating so the UI shows the loader and SSE picks it up gen.status = "generating" gen.error = None @@ -1015,78 +834,18 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db version_id = str(uuid.uuid4()) - async def _run_regenerate(): - bg_db = next(get_db()) - try: - from .backends import load_engine_model, engine_needs_trim - await load_engine_model(regen_engine, regen_model_size) - - voice_prompt = await profiles.create_voice_prompt_for_profile( - gen.profile_id, - bg_db, - use_cache=True, - engine=regen_engine, - ) - - from .utils.chunked_tts import generate_chunked - - trim_fn = None - if engine_needs_trim(regen_engine): - from .utils.audio import trim_tts_output - trim_fn = trim_tts_output - - audio, sample_rate = await generate_chunked( - tts_model, - gen.text, - voice_prompt, - language=gen.language, - seed=None, # New seed for variation - instruct=gen.instruct, - trim_fn=trim_fn, - ) - - from .utils.audio import normalize_audio, save_audio - audio = normalize_audio(audio) - - duration = len(audio) / sample_rate - audio_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav" - - save_audio(audio, str(audio_path), sample_rate) - - # Count existing versions to auto-label - existing = versions_mod.list_versions(generation_id, bg_db) - label = f"take-{len(existing) + 1}" - - versions_mod.create_version( - generation_id=generation_id, - label=label, - audio_path=str(audio_path), - db=bg_db, - effects_chain=None, - is_default=True, - ) - - await history.update_generation_status( - generation_id=generation_id, - status="completed", - db=bg_db, - audio_path=str(audio_path), - duration=duration, - ) - except Exception as e: - import traceback - traceback.print_exc() - await history.update_generation_status( - generation_id=generation_id, - status="failed", - db=bg_db, - error=str(e), - ) - finally: - task_manager.complete_generation(generation_id) - bg_db.close() - - _enqueue_generation(_run_regenerate()) + enqueue_generation(run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="regenerate", + version_id=version_id, + )) return models.GenerationResponse.model_validate(gen) @@ -1428,7 +1187,7 @@ async def transcribe_audio( get_task_manager().error_download(progress_model_name, str(e)) get_task_manager().start_download(progress_model_name) - _create_background_task(download_whisper_background()) + create_background_task(download_whisper_background()) # Return 202 Accepted raise HTTPException( @@ -2196,7 +1955,7 @@ async def migrate_models(request: models.ModelMigrateRequest): progress_manager.update_progress("migration", 0, 0, status="error") progress_manager.mark_error("migration", str(e)) - _create_background_task(migrate_background()) + create_background_task(migrate_background()) return {"source": str(source), "destination": str(destination)} @@ -2449,7 +2208,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest): ) # Start download in background task (don't await) - _create_background_task(download_in_background()) + create_background_task(download_in_background()) # Return immediately - frontend should poll progress endpoint return {"message": f"Model {request.model_name} download started"} @@ -2665,7 +2424,7 @@ async def download_cuda_backend(): import logging logging.getLogger(__name__).error(f"CUDA download failed: {e}") - _create_background_task(_download()) + create_background_task(_download()) return {"message": "CUDA backend download started", "progress_key": "cuda-backend"} @@ -2731,14 +2490,12 @@ def _get_gpu_status() -> str: @app.on_event("startup") async def startup_event(): """Run on application startup.""" - global _generation_queue print("voicebox API starting up...") database.init_db() print(f"Database initialized at {database._db_path}") # Start the serial generation worker - _generation_queue = asyncio.Queue() - _create_background_task(_generation_worker()) + init_queue() # Mark any stale "generating" records as failed — these are leftovers # from a previous process that was killed mid-generation @@ -2760,7 +2517,7 @@ async def startup_event(): # Auto-update CUDA binary if installed but outdated from .cuda_download import check_and_update_cuda_binary - _create_background_task(check_and_update_cuda_binary()) + create_background_task(check_and_update_cuda_binary()) # Initialize progress manager with main event loop for thread-safe operations try: diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 00000000..47adec90 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services layer — generation orchestration and background task management. diff --git a/backend/services/generation.py b/backend/services/generation.py new file mode 100644 index 00000000..9402074d --- /dev/null +++ b/backend/services/generation.py @@ -0,0 +1,253 @@ +""" +Unified TTS generation orchestration. + +Replaces the three near-identical closures (_run_generation, _run_retry, +_run_regenerate) that lived in main.py with a single ``run_generation()`` +function parameterized by *mode*. + +Mode differences: + - "generate" : full pipeline -- save clean version, optionally apply + effects and create a processed version. + - "retry" : re-runs a failed generation with the same seed. + No effects, no version creation. + - "regenerate" : re-runs with seed=None for variation. Creates a new + version with an auto-incremented "take-N" label. +""" + +from __future__ import annotations + +import traceback +from typing import Literal, Optional + +from .. import config, history, profiles +from ..database import get_db +from ..utils.tasks import get_task_manager + + +async def run_generation( + *, + generation_id: str, + profile_id: str, + text: str, + language: str, + engine: str, + model_size: str, + seed: Optional[int], + normalize: bool = False, + effects_chain: Optional[list] = None, + instruct: Optional[str] = None, + mode: Literal["generate", "retry", "regenerate"], + max_chunk_chars: Optional[int] = None, + crossfade_ms: Optional[int] = None, + version_id: Optional[str] = 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 ..utils.audio import normalize_audio, save_audio, trim_tts_output + + task_manager = get_task_manager() + bg_db = next(get_db()) + + try: + # --- Load model -------------------------------------------------- + await load_engine_model(engine, model_size) + + tts_model = get_tts_backend_for_engine(engine) + + # --- Build voice prompt ------------------------------------------ + voice_prompt = await profiles.create_voice_prompt_for_profile( + profile_id, + bg_db, + use_cache=True, + engine=engine, + ) + + # --- Inference --------------------------------------------------- + trim_fn = trim_tts_output if engine_needs_trim(engine) else None + + gen_kwargs: dict = dict( + language=language, + seed=seed if mode != "regenerate" else None, + instruct=instruct, + trim_fn=trim_fn, + ) + if max_chunk_chars is not None: + gen_kwargs["max_chunk_chars"] = max_chunk_chars + if crossfade_ms is not None: + gen_kwargs["crossfade_ms"] = crossfade_ms + + audio, sample_rate = await generate_chunked( + tts_model, text, voice_prompt, **gen_kwargs + ) + + # --- Normalize (generate and regenerate always; retry skips) ----- + if normalize or mode == "regenerate": + audio = normalize_audio(audio) + + duration = len(audio) / sample_rate + + # --- Persist audio and update status ----------------------------- + if mode == "generate": + final_path = _save_generate( + generation_id=generation_id, + audio=audio, + sample_rate=sample_rate, + effects_chain=effects_chain, + save_audio=save_audio, + db=bg_db, + ) + elif mode == "retry": + final_path = _save_retry( + generation_id=generation_id, + audio=audio, + sample_rate=sample_rate, + save_audio=save_audio, + ) + elif mode == "regenerate": + final_path = _save_regenerate( + generation_id=generation_id, + version_id=version_id, + audio=audio, + sample_rate=sample_rate, + save_audio=save_audio, + db=bg_db, + ) + + await history.update_generation_status( + generation_id=generation_id, + status="completed", + db=bg_db, + audio_path=final_path, + duration=duration, + ) + + except Exception as e: + traceback.print_exc() + await history.update_generation_status( + generation_id=generation_id, + status="failed", + db=bg_db, + error=str(e), + ) + finally: + task_manager.complete_generation(generation_id) + bg_db.close() + + +# --------------------------------------------------------------------- +# Mode-specific save helpers (sync, return final audio path) +# --------------------------------------------------------------------- + + +def _save_generate( + *, + generation_id: str, + audio, + sample_rate: int, + effects_chain: Optional[list], + save_audio, + db, +) -> str: + """Save clean version and optionally an effects-processed version. + + Returns the final audio path (processed if effects were applied, + otherwise clean). + """ + from .. import versions as versions_mod + + clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav" + save_audio(audio, str(clean_audio_path), sample_rate) + + has_effects = effects_chain and any( + e.get("enabled", True) for e in effects_chain + ) + + versions_mod.create_version( + generation_id=generation_id, + label="original", + audio_path=str(clean_audio_path), + db=db, + effects_chain=None, + is_default=not has_effects, + ) + + final_audio_path = str(clean_audio_path) + + if has_effects: + from ..utils.effects import apply_effects, validate_effects_chain + + error_msg = validate_effects_chain(effects_chain) + if error_msg: + print(f"Warning: invalid effects chain, skipping: {error_msg}") + else: + processed_audio = apply_effects(audio, sample_rate, effects_chain) + processed_path = ( + config.get_generations_dir() / f"{generation_id}_processed.wav" + ) + save_audio(processed_audio, str(processed_path), sample_rate) + final_audio_path = str(processed_path) + versions_mod.create_version( + generation_id=generation_id, + label="version-2", + audio_path=str(processed_path), + db=db, + effects_chain=effects_chain, + is_default=True, + ) + + return final_audio_path + + +def _save_retry( + *, + generation_id: str, + audio, + sample_rate: int, + save_audio, +) -> str: + """Save retry output -- single file, no versions. + + Returns the audio path. + """ + audio_path = config.get_generations_dir() / f"{generation_id}.wav" + save_audio(audio, str(audio_path), sample_rate) + return str(audio_path) + + +def _save_regenerate( + *, + generation_id: str, + version_id: Optional[str], + audio, + sample_rate: int, + save_audio, + db, +) -> str: + """Save regeneration output as a new version with auto-label. + + Returns the audio path. + """ + from .. import versions as versions_mod + + suffix = version_id[:8] if version_id else generation_id[:8] + audio_path = config.get_generations_dir() / f"{generation_id}_{suffix}.wav" + save_audio(audio, str(audio_path), sample_rate) + + existing = versions_mod.list_versions(generation_id, db) + label = f"take-{len(existing) + 1}" + + versions_mod.create_version( + generation_id=generation_id, + label=label, + audio_path=str(audio_path), + db=db, + effects_chain=None, + is_default=True, + ) + + return str(audio_path) diff --git a/backend/services/task_queue.py b/backend/services/task_queue.py new file mode 100644 index 00000000..fbd9638e --- /dev/null +++ b/backend/services/task_queue.py @@ -0,0 +1,48 @@ +""" +Serial generation queue — ensures only one TTS inference runs at a time +to avoid GPU contention. +""" + +import asyncio +import traceback + +# Keep references to fire-and-forget background tasks to prevent GC +_background_tasks: set = set() + +# Generation queue — serializes TTS inference to avoid GPU contention +_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup + + +def create_background_task(coro) -> asyncio.Task: + """Create a background task and prevent it from being garbage collected.""" + task = asyncio.create_task(coro) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + return task + + +async def _generation_worker(): + """Worker that processes generation tasks one at a time.""" + while True: + coro = await _generation_queue.get() + try: + await coro + except Exception: + traceback.print_exc() + finally: + _generation_queue.task_done() + + +def enqueue_generation(coro): + """Add a generation coroutine to the serial queue.""" + _generation_queue.put_nowait(coro) + + +def init_queue(): + """Initialize the generation queue and start the worker. + + Must be called once during application startup (inside a running event loop). + """ + global _generation_queue + _generation_queue = asyncio.Queue() + create_background_task(_generation_worker()) diff --git a/docs/plans/ADDING_TTS_ENGINES.md b/docs/plans/ADDING_TTS_ENGINES.md index b759bf62..3efe8868 100644 --- a/docs/plans/ADDING_TTS_ENGINES.md +++ b/docs/plans/ADDING_TTS_ENGINES.md @@ -192,7 +192,7 @@ Add to `backend/requirements.txt`. Watch for: **Pinned dependency conflicts** — If the model package pins old versions of numpy, torch, or transformers, install with `--no-deps` and list sub-dependencies manually. This is what Chatterbox requires: ``` -# In justfile/Makefile (NOT requirements.txt): +# In justfile (NOT requirements.txt): pip install --no-deps chatterbox-tts # In requirements.txt — list the transitive deps: @@ -337,7 +337,7 @@ The tracker monkey-patches tqdm to intercept HuggingFace's internal progress bar - [ ] `backend/backends/__init__.py` — `ModelConfig` entry + `TTS_ENGINES` + `get_tts_backend_for_engine()` elif - [ ] `backend/models.py` — engine name in regex, any new language codes - [ ] `backend/requirements.txt` — dependencies added (check for `--no-deps` needs) -- [ ] `justfile` / `Makefile` — `--no-deps` install step if needed +- [ ] `justfile` — `--no-deps` install step if needed ### API (`backend/main.py`) No changes needed — the model config registry handles all dispatch automatically. diff --git a/justfile b/justfile index 4a78f920..796e3ddd 100644 --- a/justfile +++ b/justfile @@ -52,7 +52,7 @@ setup-python: {{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt fi {{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git - {{ pip }} install pyinstaller -q + {{ pip }} install pyinstaller ruff pytest pytest-asyncio -q echo "Python environment ready." [windows] @@ -75,7 +75,7 @@ setup-python: & "{{ pip }}" install -r {{ backend_dir }}/requirements.txt & "{{ pip }}" install --no-deps chatterbox-tts & "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git - & "{{ pip }}" install pyinstaller -q + & "{{ pip }}" install pyinstaller ruff pytest pytest-asyncio -q Write-Host "Python environment ready." # Install JavaScript dependencies @@ -234,21 +234,50 @@ build-web: # ─── Code Quality ──────────────────────────────────────────────────── -# Run all checks (lint + format + typecheck) -check: +# Run all checks (JS + Python lint + format) +check: check-js check-python + +# JS/TS: lint + format + typecheck (Biome) +check-js: bun run check -# Lint with Biome -lint: +# Python: lint + format check (ruff) +check-python: _ensure-venv + {{ venv_bin }}/ruff check {{ backend_dir }} + {{ venv_bin }}/ruff format --check {{ backend_dir }} + +# Lint with Biome (JS) + ruff (Python) +lint: _ensure-venv bun run lint + {{ venv_bin }}/ruff check {{ backend_dir }} -# Format with Biome -format: +# Format with Biome (JS) + ruff (Python) +format: _ensure-venv bun run format + {{ venv_bin }}/ruff format {{ backend_dir }} -# Fix lint + format issues -fix: +# Fix lint + format issues (JS + Python) +fix: _ensure-venv bun run check:fix + {{ venv_bin }}/ruff check {{ backend_dir }} --fix + {{ venv_bin }}/ruff format {{ backend_dir }} + +# Python lint only +lint-python: _ensure-venv + {{ venv_bin }}/ruff check {{ backend_dir }} + +# Python format only +format-python: _ensure-venv + {{ venv_bin }}/ruff format {{ backend_dir }} + +# Python auto-fix lint issues +fix-python: _ensure-venv + {{ venv_bin }}/ruff check {{ backend_dir }} --fix + {{ venv_bin }}/ruff format {{ backend_dir }} + +# Run Python tests +test: _ensure-venv + {{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v # ─── Database ───────────────────────────────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..95d390e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "voicebox-backend" +version = "0.2.3" +requires-python = ">=3.12" + +# --------------------------------------------------------------------------- +# Ruff – linter + formatter +# --------------------------------------------------------------------------- + +[tool.ruff] +target-version = "py312" +line-length = 120 +src = ["backend"] + +# Files/dirs to skip entirely. +extend-exclude = [ + "backend/voicebox-server.spec", + "backend/build_binary.py", +] + +[tool.ruff.lint] +select = [ + "F", # pyflakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade (modernize syntax for 3.12) + "B", # flake8-bugbear + "A", # flake8-builtins (shadowing built-in names) + "SIM", # flake8-simplify + "T20", # flake8-print (flag print() calls) + "RET", # flake8-return + "PIE", # misc lints + "PT", # flake8-pytest-style + "RUF", # ruff-specific rules + "ERA", # commented-out code detection + "FIX", # flag TODO/FIXME/HACK/XXX for review +] + +ignore = [ + # Allow print() in existing code -- remove items from this list as files + # are migrated to logging during the refactor. + "T201", # print() found + + # These conflict with the formatter or are too noisy during migration: + "E501", # line too long (formatter handles this) + "RET504", # unnecessary assignment before return + "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) +] + +# Per-file rule overrides. +[tool.ruff.lint.per-file-ignores] +# Tests can use assert, print, and magic values freely. +"backend/tests/**" = ["S101", "T201", "PLR2004", "ERA001"] +# __init__.py re-exports are expected to have unused imports. +"**/__init__.py" = ["F401"] +# Entry points and scripts legitimately use print. +"backend/server.py" = ["T201"] +"backend/main.py" = ["T201"] + +[tool.ruff.lint.isort] +known-first-party = ["backend"] +# Group "from backend.*" imports into the first-party section. +force-single-line = false +combine-as-imports = true + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +asyncio_mode = "auto"