mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 07:10: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
|
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
- **justfile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||||
- Includes Python version detection and compatibility warnings
|
- Cross-platform support (macOS, Linux, Windows)
|
||||||
- Self-documenting help system with `make help`
|
- Python version detection and compatibility warnings
|
||||||
- Colored output for better readability
|
- Self-documenting help system with `just --list`
|
||||||
- Supports parallel development server execution
|
|
||||||
|
|
||||||
### Changed
|
### 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
|
## Testing
|
||||||
To test this fix:
|
To test this fix:
|
||||||
1. Build Voicebox from source: `make build`
|
1. Build Voicebox from source: `just build`
|
||||||
2. Disconnect from internet
|
2. Disconnect from internet
|
||||||
3. Try generating speech
|
3. Try generating speech
|
||||||
4. Should work without network requests
|
4. Should work without network requests
|
||||||
@@ -40,13 +40,13 @@ To test this fix:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
just setup
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
make build
|
just build
|
||||||
|
|
||||||
# Or build just the server
|
# Or build just the server
|
||||||
make build-server
|
just build-server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## 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
|
Net result: ~240 lines of duplicated closure code replaced by a single 230-line service module + 50-line queue module.
|
||||||
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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
## Notes
|
||||||
|
|
||||||
- Each phase is independently shippable and testable
|
- 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.tasks import get_task_manager
|
||||||
from .utils.cache import clear_voice_prompt_cache
|
from .utils.cache import clear_voice_prompt_cache
|
||||||
from .platform_detect import get_backend_type
|
from .platform_detect import get_backend_type
|
||||||
|
from .services.task_queue import create_background_task, enqueue_generation, init_queue
|
||||||
# Keep references to fire-and-forget background tasks to prevent GC
|
from .services.generation import run_generation
|
||||||
_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)
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -736,9 +706,8 @@ async def generate_speech(
|
|||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(status_code=404, detail="Profile not found")
|
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"
|
engine = data.engine or "qwen"
|
||||||
tts_model = get_tts_backend_for_engine(engine)
|
|
||||||
model_size = data.model_size or "1.7B"
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
# Create the history entry immediately with status="generating"
|
# Create the history entry immediately with status="generating"
|
||||||
@@ -779,111 +748,21 @@ async def generate_speech(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Kick off TTS in background
|
# Kick off TTS in background
|
||||||
async def _run_generation():
|
enqueue_generation(run_generation(
|
||||||
bg_db = next(get_db())
|
generation_id=generation_id,
|
||||||
try:
|
profile_id=data.profile_id,
|
||||||
# Load model
|
text=data.text,
|
||||||
from .backends import load_engine_model, engine_needs_trim
|
language=data.language,
|
||||||
await load_engine_model(engine, model_size)
|
engine=engine,
|
||||||
|
model_size=model_size,
|
||||||
# Create voice prompt
|
seed=data.seed,
|
||||||
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
normalize=data.normalize,
|
||||||
data.profile_id,
|
effects_chain=effects_chain_config,
|
||||||
bg_db,
|
instruct=data.instruct,
|
||||||
use_cache=True,
|
mode="generate",
|
||||||
engine=engine,
|
max_chunk_chars=data.max_chunk_chars,
|
||||||
)
|
crossfade_ms=data.crossfade_ms,
|
||||||
|
))
|
||||||
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())
|
|
||||||
|
|
||||||
return generation
|
return generation
|
||||||
|
|
||||||
@@ -913,70 +792,17 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
|||||||
text=gen.text,
|
text=gen.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve engine/model from stored values
|
enqueue_generation(run_generation(
|
||||||
retry_engine = gen.engine or "qwen"
|
generation_id=generation_id,
|
||||||
retry_model_size = gen.model_size or "1.7B"
|
profile_id=gen.profile_id,
|
||||||
|
text=gen.text,
|
||||||
from .backends import get_tts_backend_for_engine
|
language=gen.language,
|
||||||
tts_model = get_tts_backend_for_engine(retry_engine)
|
engine=gen.engine or "qwen",
|
||||||
|
model_size=gen.model_size or "1.7B",
|
||||||
async def _run_retry():
|
seed=gen.seed,
|
||||||
bg_db = next(get_db())
|
instruct=gen.instruct,
|
||||||
try:
|
mode="retry",
|
||||||
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())
|
|
||||||
|
|
||||||
return models.GenerationResponse.model_validate(gen)
|
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":
|
if (gen.status or "completed") != "completed":
|
||||||
raise HTTPException(status_code=400, detail="Generation must be completed to regenerate")
|
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
|
# Set to generating so the UI shows the loader and SSE picks it up
|
||||||
gen.status = "generating"
|
gen.status = "generating"
|
||||||
gen.error = None
|
gen.error = None
|
||||||
@@ -1015,78 +834,18 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
|||||||
|
|
||||||
version_id = str(uuid.uuid4())
|
version_id = str(uuid.uuid4())
|
||||||
|
|
||||||
async def _run_regenerate():
|
enqueue_generation(run_generation(
|
||||||
bg_db = next(get_db())
|
generation_id=generation_id,
|
||||||
try:
|
profile_id=gen.profile_id,
|
||||||
from .backends import load_engine_model, engine_needs_trim
|
text=gen.text,
|
||||||
await load_engine_model(regen_engine, regen_model_size)
|
language=gen.language,
|
||||||
|
engine=gen.engine or "qwen",
|
||||||
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
model_size=gen.model_size or "1.7B",
|
||||||
gen.profile_id,
|
seed=gen.seed,
|
||||||
bg_db,
|
instruct=gen.instruct,
|
||||||
use_cache=True,
|
mode="regenerate",
|
||||||
engine=regen_engine,
|
version_id=version_id,
|
||||||
)
|
))
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
return models.GenerationResponse.model_validate(gen)
|
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().error_download(progress_model_name, str(e))
|
||||||
|
|
||||||
get_task_manager().start_download(progress_model_name)
|
get_task_manager().start_download(progress_model_name)
|
||||||
_create_background_task(download_whisper_background())
|
create_background_task(download_whisper_background())
|
||||||
|
|
||||||
# Return 202 Accepted
|
# Return 202 Accepted
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -2196,7 +1955,7 @@ async def migrate_models(request: models.ModelMigrateRequest):
|
|||||||
progress_manager.update_progress("migration", 0, 0, status="error")
|
progress_manager.update_progress("migration", 0, 0, status="error")
|
||||||
progress_manager.mark_error("migration", str(e))
|
progress_manager.mark_error("migration", str(e))
|
||||||
|
|
||||||
_create_background_task(migrate_background())
|
create_background_task(migrate_background())
|
||||||
|
|
||||||
return {"source": str(source), "destination": str(destination)}
|
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)
|
# 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 immediately - frontend should poll progress endpoint
|
||||||
return {"message": f"Model {request.model_name} download started"}
|
return {"message": f"Model {request.model_name} download started"}
|
||||||
@@ -2665,7 +2424,7 @@ async def download_cuda_backend():
|
|||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).error(f"CUDA download failed: {e}")
|
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"}
|
return {"message": "CUDA backend download started", "progress_key": "cuda-backend"}
|
||||||
|
|
||||||
|
|
||||||
@@ -2731,14 +2490,12 @@ def _get_gpu_status() -> str:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Run on application startup."""
|
"""Run on application startup."""
|
||||||
global _generation_queue
|
|
||||||
print("voicebox API starting up...")
|
print("voicebox API starting up...")
|
||||||
database.init_db()
|
database.init_db()
|
||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
|
|
||||||
# Start the serial generation worker
|
# Start the serial generation worker
|
||||||
_generation_queue = asyncio.Queue()
|
init_queue()
|
||||||
_create_background_task(_generation_worker())
|
|
||||||
|
|
||||||
# Mark any stale "generating" records as failed — these are leftovers
|
# Mark any stale "generating" records as failed — these are leftovers
|
||||||
# from a previous process that was killed mid-generation
|
# 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
|
# Auto-update CUDA binary if installed but outdated
|
||||||
from .cuda_download import check_and_update_cuda_binary
|
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
|
# Initialize progress manager with main event loop for thread-safe operations
|
||||||
try:
|
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:
|
**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
|
pip install --no-deps chatterbox-tts
|
||||||
|
|
||||||
# In requirements.txt — list the transitive deps:
|
# 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/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/models.py` — engine name in regex, any new language codes
|
||||||
- [ ] `backend/requirements.txt` — dependencies added (check for `--no-deps` needs)
|
- [ ] `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`)
|
### API (`backend/main.py`)
|
||||||
No changes needed — the model config registry handles all dispatch automatically.
|
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
|
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||||
fi
|
fi
|
||||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
{{ 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."
|
echo "Python environment ready."
|
||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
@@ -75,7 +75,7 @@ setup-python:
|
|||||||
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
|
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
|
||||||
& "{{ pip }}" install --no-deps chatterbox-tts
|
& "{{ pip }}" install --no-deps chatterbox-tts
|
||||||
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
|
& "{{ 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."
|
Write-Host "Python environment ready."
|
||||||
|
|
||||||
# Install JavaScript dependencies
|
# Install JavaScript dependencies
|
||||||
@@ -234,21 +234,50 @@ build-web:
|
|||||||
|
|
||||||
# ─── Code Quality ────────────────────────────────────────────────────
|
# ─── Code Quality ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Run all checks (lint + format + typecheck)
|
# Run all checks (JS + Python lint + format)
|
||||||
check:
|
check: check-js check-python
|
||||||
|
|
||||||
|
# JS/TS: lint + format + typecheck (Biome)
|
||||||
|
check-js:
|
||||||
bun run check
|
bun run check
|
||||||
|
|
||||||
# Lint with Biome
|
# Python: lint + format check (ruff)
|
||||||
lint:
|
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
|
bun run lint
|
||||||
|
{{ venv_bin }}/ruff check {{ backend_dir }}
|
||||||
|
|
||||||
# Format with Biome
|
# Format with Biome (JS) + ruff (Python)
|
||||||
format:
|
format: _ensure-venv
|
||||||
bun run format
|
bun run format
|
||||||
|
{{ venv_bin }}/ruff format {{ backend_dir }}
|
||||||
|
|
||||||
# Fix lint + format issues
|
# Fix lint + format issues (JS + Python)
|
||||||
fix:
|
fix: _ensure-venv
|
||||||
bun run check:fix
|
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 ─────────────────────────────────────────────────────────
|
# ─── 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