mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
add style guide, ruff config, generation service extraction, remove Makefile
- Add backend/STYLE_GUIDE.md covering formatting, imports, types, docstrings, comments, error handling, async, logging, and naming conventions - Add pyproject.toml with ruff linter/formatter config (ERA, FIX, isort, pyupgrade) - Extract generation service (Phase 3): unified run_generation() replaces three duplicated closures, serial queue moved to services/task_queue.py - Delete Makefile in favor of justfile; update all references - Add Python lint/format/test commands to justfile (check-python, fix-python, test) - Install ruff, pytest, pytest-asyncio as dev tools in setup-python - Update REFACTOR_PLAN.md with Phase 3 and Phase 7 completion
This commit is contained in:
+8
-6
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 [email protected])$(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)"
|
||||
+4
-4
@@ -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
|
||||
|
||||
+29
-29
@@ -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
|
||||
|
||||
@@ -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_<module>.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 |
|
||||
@@ -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()
|
||||
+47
-290
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Services layer — generation orchestration and background task management.
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user