chore(backend): repair test suite and bring ruff to green

The suite hadn't run green since the routes refactor:
- test_profile_duplicate_names.py imported the pre-refactor module
  layout and broke collection; now imports backend.services.profiles
- tests/conftest.py puts the repo root and backend dir on sys.path so
  files collect standalone instead of depending on run order
- test_cors.py tested a hand-copied mirror of the origin list that had
  drifted from app.py (missing http://tauri.localhost); it now builds
  the app via the real create_app() factory
- test_progress.py simulated a 1KB download, below the tracker's 1MB
  reporting threshold; simulation raised to 5MB
- slow/timeout markers registered in pyproject

Ruff: ~900 violations auto-fixed (typing modernization, import
sorting, unused imports, whitespace). The remaining rules are baselined
in pyproject.toml with per-rule counts to burn down, plus per-file
carve-outs for deliberate env-before-import ordering. ruff check is
now clean; suite is 134 passed, 2 skipped.
This commit is contained in:
Jamie Pine
2026-07-26 23:16:09 -07:00
parent 766c51a8a1
commit b434db22f6
82 changed files with 970 additions and 999 deletions
+9 -10
View File
@@ -12,7 +12,6 @@ import json
import logging
import uuid
from pathlib import Path
from typing import Optional
import soundfile as sf
from sqlalchemy.orm import Session
@@ -35,7 +34,7 @@ WHISPER_NATIVE_FORMATS = (".wav", ".mp3", ".flac", ".ogg")
def _to_response(row: DBCapture) -> CaptureResponse:
flags_model: Optional[RefinementFlagsModel] = None
flags_model: RefinementFlagsModel | None = None
if row.refinement_flags:
try:
flags_model = RefinementFlagsModel(**json.loads(row.refinement_flags))
@@ -62,8 +61,8 @@ async def create_capture(
audio_bytes: bytes,
filename: str,
source: str,
language: Optional[str],
stt_model: Optional[str],
language: str | None,
stt_model: str | None,
db: Session,
) -> CaptureResponse:
"""Persist raw audio, run STT, store the row."""
@@ -159,7 +158,7 @@ def list_captures(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[C
return [_to_response(r) for r in rows], total
def get_capture(capture_id: str, db: Session) -> Optional[CaptureResponse]:
def get_capture(capture_id: str, db: Session) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
return _to_response(row) if row else None
@@ -184,9 +183,9 @@ def delete_capture(capture_id: str, db: Session) -> bool:
async def refine_capture(
capture_id: str,
flags: RefinementFlags,
model_size: Optional[str],
model_size: str | None,
db: Session,
) -> Optional[CaptureResponse]:
) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
return None
@@ -207,10 +206,10 @@ async def refine_capture(
async def retranscribe_capture(
capture_id: str,
stt_model: Optional[str],
language: Optional[str],
stt_model: str | None,
language: str | None,
db: Session,
) -> Optional[CaptureResponse]:
) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
return None
+43 -43
View File
@@ -2,38 +2,38 @@
Audio channel management module.
"""
from typing import List, Optional
from datetime import datetime
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from ..models import (
AudioChannelCreate,
AudioChannelUpdate,
AudioChannelResponse,
ChannelVoiceAssignment,
ProfileChannelAssignment,
)
from ..database import (
AudioChannel as DBAudioChannel,
ChannelDeviceMapping as DBChannelDeviceMapping,
ProfileChannelMapping as DBProfileChannelMapping,
VoiceProfile as DBVoiceProfile,
)
from ..models import (
AudioChannelCreate,
AudioChannelResponse,
AudioChannelUpdate,
ChannelVoiceAssignment,
ProfileChannelAssignment,
)
async def list_channels(db: Session) -> List[AudioChannelResponse]:
async def list_channels(db: Session) -> list[AudioChannelResponse]:
"""List all audio channels."""
channels = db.query(DBAudioChannel).all()
result = []
for channel in channels:
# Get device IDs for this channel
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
channel_id=channel.id
).all()
device_ids = [m.device_id for m in device_mappings]
result.append(AudioChannelResponse(
id=channel.id,
name=channel.name,
@@ -41,22 +41,22 @@ async def list_channels(db: Session) -> List[AudioChannelResponse]:
device_ids=device_ids,
created_at=channel.created_at,
))
return result
async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]:
async def get_channel(channel_id: str, db: Session) -> AudioChannelResponse | None:
"""Get a channel by ID."""
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
if not channel:
return None
# Get device IDs
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
channel_id=channel.id
).all()
device_ids = [m.device_id for m in device_mappings]
return AudioChannelResponse(
id=channel.id,
name=channel.name,
@@ -75,7 +75,7 @@ async def create_channel(
existing = db.query(DBAudioChannel).filter_by(name=data.name).first()
if existing:
raise ValueError(f"Channel with name '{data.name}' already exists")
# Create channel
channel = DBAudioChannel(
id=str(uuid.uuid4()),
@@ -85,7 +85,7 @@ async def create_channel(
)
db.add(channel)
db.flush()
# Add device mappings
for device_id in data.device_ids:
mapping = DBChannelDeviceMapping(
@@ -94,10 +94,10 @@ async def create_channel(
device_id=device_id,
)
db.add(mapping)
db.commit()
db.refresh(channel)
return AudioChannelResponse(
id=channel.id,
name=channel.name,
@@ -111,15 +111,15 @@ async def update_channel(
channel_id: str,
data: AudioChannelUpdate,
db: Session,
) -> Optional[AudioChannelResponse]:
) -> AudioChannelResponse | None:
"""Update an audio channel."""
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
if not channel:
return None
if channel.is_default:
raise ValueError("Cannot modify the default channel")
# Update name if provided
if data.name is not None:
# Check if name already exists (excluding current channel)
@@ -130,12 +130,12 @@ async def update_channel(
if existing:
raise ValueError(f"Channel with name '{data.name}' already exists")
channel.name = data.name
# Update device mappings if provided
if data.device_ids is not None:
# Delete existing mappings
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
# Add new mappings
for device_id in data.device_ids:
mapping = DBChannelDeviceMapping(
@@ -144,16 +144,16 @@ async def update_channel(
device_id=device_id,
)
db.add(mapping)
db.commit()
db.refresh(channel)
# Get updated device IDs
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
channel_id=channel.id
).all()
device_ids = [m.device_id for m in device_mappings]
return AudioChannelResponse(
id=channel.id,
name=channel.name,
@@ -168,24 +168,24 @@ async def delete_channel(channel_id: str, db: Session) -> bool:
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
if not channel:
return False
if channel.is_default:
raise ValueError("Cannot delete the default channel")
# Delete device mappings
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
# Delete profile-channel mappings
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
# Delete channel
db.delete(channel)
db.commit()
return True
async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
async def get_channel_voices(channel_id: str, db: Session) -> list[str]:
"""Get list of profile IDs assigned to a channel."""
mappings = db.query(DBProfileChannelMapping).filter_by(
channel_id=channel_id
@@ -203,16 +203,16 @@ async def set_channel_voices(
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
if not channel:
raise ValueError(f"Channel {channel_id} not found")
# Verify all profiles exist
for profile_id in data.profile_ids:
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
# Delete existing mappings for this channel
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
# Add new mappings
for profile_id in data.profile_ids:
mapping = DBProfileChannelMapping(
@@ -220,11 +220,11 @@ async def set_channel_voices(
channel_id=channel_id,
)
db.add(mapping)
db.commit()
async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
async def get_profile_channels(profile_id: str, db: Session) -> list[str]:
"""Get list of channel IDs assigned to a profile."""
mappings = db.query(DBProfileChannelMapping).filter_by(
profile_id=profile_id
@@ -242,16 +242,16 @@ async def set_profile_channels(
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
# Verify all channels exist
for channel_id in data.channel_ids:
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
if not channel:
raise ValueError(f"Channel {channel_id} not found")
# Delete existing mappings for this profile
db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete()
# Add new mappings
for channel_id in data.channel_ids:
mapping = DBProfileChannelMapping(
@@ -259,5 +259,5 @@ async def set_profile_channels(
channel_id=channel_id,
)
db.add(mapping)
db.commit()
+9 -13
View File
@@ -19,11 +19,10 @@ import os
import sys
import tarfile
from pathlib import Path
from typing import Optional
from .. import __version__
from ..config import get_data_dir
from ..utils.progress import get_progress_manager
from .. import __version__
logger = logging.getLogger(__name__)
@@ -63,7 +62,7 @@ def get_cuda_exe_name() -> str:
return "voicebox-server-cuda"
def get_cuda_binary_path() -> Optional[Path]:
def get_cuda_binary_path() -> Path | None:
"""Return path to the CUDA executable if it exists inside the onedir."""
p = get_cuda_dir() / get_cuda_exe_name()
if p.exists():
@@ -76,7 +75,7 @@ def get_cuda_libs_manifest_path() -> Path:
return get_cuda_dir() / "cuda-libs.json"
def get_installed_cuda_libs_version() -> Optional[str]:
def get_installed_cuda_libs_version() -> str | None:
"""Read the installed CUDA libs version from cuda-libs.json, or None."""
manifest_path = get_cuda_libs_manifest_path()
if not manifest_path.exists():
@@ -114,7 +113,7 @@ def get_cuda_status() -> dict:
}
def _needs_server_download(version: Optional[str] = None) -> bool:
def _needs_server_download(version: str | None = None) -> bool:
"""Check if the server core archive needs to be (re)downloaded."""
cuda_path = get_cuda_binary_path()
if not cuda_path:
@@ -138,7 +137,7 @@ def _needs_cuda_libs_download() -> bool:
async def _download_and_extract_archive(
client,
url: str,
sha256_url: Optional[str],
sha256_url: str | None,
dest_dir: Path,
label: str,
progress_offset: int,
@@ -223,10 +222,7 @@ async def _download_and_extract_archive(
status="downloading",
)
with tarfile.open(temp_path, "r:gz") as tar:
if sys.version_info >= (3, 12):
tar.extractall(path=dest_dir, filter="data")
else:
tar.extractall(path=dest_dir)
tar.extractall(path=dest_dir, filter="data")
logger.info(f"{label}: extracted to {dest_dir}")
finally:
@@ -235,7 +231,7 @@ async def _download_and_extract_archive(
return downloaded
async def download_cuda_binary(version: Optional[str] = None):
async def download_cuda_binary(version: str | None = None):
"""Download the CUDA backend (server core + CUDA libs if needed).
Downloads both archives from GitHub Releases, extracts them into
@@ -255,7 +251,7 @@ async def download_cuda_binary(version: Optional[str] = None):
await _download_cuda_binary_locked(version)
async def _download_cuda_binary_locked(version: Optional[str] = None):
async def _download_cuda_binary_locked(version: str | None = None):
"""Inner implementation of download_cuda_binary, called under _download_lock."""
import httpx
@@ -353,7 +349,7 @@ async def _download_cuda_binary_locked(version: Optional[str] = None):
raise
def get_cuda_binary_version() -> Optional[str]:
def get_cuda_binary_version() -> str | None:
"""Get the version of the installed CUDA binary, or None if not installed."""
import subprocess
+7 -9
View File
@@ -6,15 +6,13 @@ from __future__ import annotations
import json
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from ..utils.effects import validate_effects_chain
from sqlalchemy.orm import Session
from ..database import EffectPreset as DBEffectPreset
from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
from ..models import EffectConfig, EffectPresetCreate, EffectPresetResponse, EffectPresetUpdate
from ..utils.effects import validate_effects_chain
def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
@@ -30,13 +28,13 @@ def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
)
def list_presets(db: Session) -> List[EffectPresetResponse]:
def list_presets(db: Session) -> list[EffectPresetResponse]:
"""List all effect presets (built-in + user-created)."""
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
return [_preset_response(p) for p in presets]
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
def get_preset(preset_id: str, db: Session) -> EffectPresetResponse | None:
"""Get a preset by ID."""
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not p:
@@ -44,7 +42,7 @@ def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
return _preset_response(p)
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
def get_preset_by_name(name: str, db: Session) -> EffectPresetResponse | None:
"""Get a preset by name."""
p = db.query(DBEffectPreset).filter_by(name=name).first()
if not p:
@@ -82,7 +80,7 @@ def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse
return _preset_response(preset)
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> EffectPresetResponse | None:
"""Update a user effect preset. Cannot modify built-in presets."""
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not preset:
+81 -76
View File
@@ -5,39 +5,43 @@ Handles exporting profiles to ZIP archives and importing them back.
Also handles exporting individual generations.
"""
import io
import json
import zipfile
import io
from pathlib import Path
from typing import Optional
from sqlalchemy.orm import Session
from ..models import VoiceProfileResponse
from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion
from .profiles import create_profile, add_profile_sample
from ..models import VoiceProfileCreate
from .. import config
from ..database import (
Generation as DBGeneration,
GenerationVersion as DBGenerationVersion,
ProfileSample as DBProfileSample,
VoiceProfile as DBVoiceProfile,
)
from ..models import VoiceProfileCreate, VoiceProfileResponse
from .profiles import add_profile_sample, create_profile
def _get_unique_profile_name(name: str, db: Session) -> str:
"""
Get a unique profile name by appending a number if needed.
Args:
name: Original profile name
db: Database session
Returns:
Unique profile name
"""
base_name = name
counter = 1
while True:
existing = db.query(DBVoiceProfile).filter_by(name=name).first()
if not existing:
return name
name = f"{base_name} ({counter})"
counter += 1
@@ -45,14 +49,14 @@ def _get_unique_profile_name(name: str, db: Session) -> str:
def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
"""
Export a voice profile to a ZIP archive.
Args:
profile_id: Profile ID to export
db: Database session
Returns:
ZIP file contents as bytes
Raises:
ValueError: If profile not found or has no samples
"""
@@ -60,15 +64,15 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
# Get all samples
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples:
raise ValueError(f"Profile {profile_id} has no samples")
# Create ZIP in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Check if profile has avatar
has_avatar = False
@@ -115,7 +119,7 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
samples_data[filename] = sample.reference_text
zip_file.writestr("samples.json", json.dumps(samples_data, indent=2))
zip_buffer.seek(0)
return zip_buffer.read()
@@ -123,58 +127,58 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfileResponse:
"""
Import a voice profile from a ZIP archive.
Args:
file_bytes: ZIP file contents
db: Database session
Returns:
Created profile
Raises:
ValueError: If ZIP is invalid or missing required files
"""
zip_buffer = io.BytesIO(file_bytes)
try:
with zipfile.ZipFile(zip_buffer, 'r') as zip_file:
# Validate ZIP structure
namelist = zip_file.namelist()
if "manifest.json" not in namelist:
raise ValueError("ZIP archive missing manifest.json")
if "samples.json" not in namelist:
raise ValueError("ZIP archive missing samples.json")
# Read manifest
manifest_data = json.loads(zip_file.read("manifest.json"))
if "version" not in manifest_data:
raise ValueError("Invalid manifest.json: missing version")
if "profile" not in manifest_data:
raise ValueError("Invalid manifest.json: missing profile")
profile_data = manifest_data["profile"]
# Read samples mapping
samples_data = json.loads(zip_file.read("samples.json"))
if not isinstance(samples_data, dict):
raise ValueError("Invalid samples.json: must be a dictionary")
# Get unique profile name
original_name = profile_data.get("name", "Imported Profile")
unique_name = _get_unique_profile_name(original_name, db)
# Create profile
profile_create = VoiceProfileCreate(
name=unique_name,
description=profile_data.get("description"),
language=profile_data.get("language", "en"),
)
profile = await create_profile(profile_create, db)
# Extract and add samples
@@ -197,7 +201,7 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
await upload_avatar(profile.id, tmp_path, db)
finally:
Path(tmp_path).unlink(missing_ok=True)
except Exception as e:
except Exception:
# Avatar import is optional - continue even if it fails
pass
@@ -205,19 +209,19 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
# Validate filename
if not filename.endswith('.wav'):
raise ValueError(f"Invalid sample filename: {filename} (must be .wav)")
# Extract audio file to temp location
zip_path = f"samples/{filename}"
if zip_path not in namelist:
raise ValueError(f"Sample file not found in ZIP: {zip_path}")
# Extract to temporary file
import tempfile
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp.write(zip_file.read(zip_path))
tmp_path = tmp.name
try:
# Add sample to profile
await add_profile_sample(
@@ -229,9 +233,9 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
return profile
except zipfile.BadZipFile:
raise ValueError("Invalid ZIP file")
except json.JSONDecodeError as e:
@@ -239,20 +243,20 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Error importing profile: {str(e)}")
raise ValueError(f"Error importing profile: {e!s}")
def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"""
Export a generation to a ZIP archive.
Args:
generation_id: Generation ID to export
db: Database session
Returns:
ZIP file contents as bytes
Raises:
ValueError: If generation not found
"""
@@ -260,12 +264,12 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
raise ValueError(f"Generation {generation_id} not found")
# Get profile info
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
if not profile:
raise ValueError(f"Profile {generation.profile_id} not found")
# Get all versions for this generation
versions = (
db.query(DBGenerationVersion)
@@ -276,7 +280,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Create ZIP in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Build version manifest entries
version_entries = []
@@ -313,7 +317,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"versions": version_entries,
}
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
# Add all version audio files
for v in versions:
v_path = config.resolve_storage_path(v.audio_path)
@@ -325,7 +329,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0)
return zip_buffer.read()
@@ -333,68 +337,69 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
"""
Import a generation from a ZIP archive.
Args:
file_bytes: ZIP file contents
db: Database session
Returns:
Dictionary with generation ID and profile info
Raises:
ValueError: If ZIP is invalid or missing required files
"""
from pathlib import Path
import tempfile
import shutil
import tempfile
from datetime import datetime
from pathlib import Path
from .. import config
zip_buffer = io.BytesIO(file_bytes)
try:
with zipfile.ZipFile(zip_buffer, 'r') as zip_file:
# Validate ZIP structure
namelist = zip_file.namelist()
if "manifest.json" not in namelist:
raise ValueError("ZIP archive missing manifest.json")
# Read manifest
manifest_data = json.loads(zip_file.read("manifest.json"))
if "version" not in manifest_data:
raise ValueError("Invalid manifest.json: missing version")
if "generation" not in manifest_data:
raise ValueError("Invalid manifest.json: missing generation data")
generation_data = manifest_data["generation"]
profile_data = manifest_data.get("profile", {})
# Validate required fields
required_fields = ["text", "language", "duration"]
for field in required_fields:
if field not in generation_data:
raise ValueError(f"Invalid manifest.json: missing generation.{field}")
# Find audio file in archive
audio_files = [f for f in namelist if f.startswith("audio/") and f.endswith(".wav")]
if not audio_files:
raise ValueError("No audio file found in ZIP archive")
audio_file_path = audio_files[0]
# Check if we should match an existing profile or create metadata
profile_id = None
profile_name = profile_data.get("name", "Unknown Profile")
# Try to find matching profile by name
if profile_name and profile_name != "Unknown Profile":
existing_profile = db.query(DBVoiceProfile).filter_by(name=profile_name).first()
if existing_profile:
profile_id = existing_profile.id
# If no matching profile, use a placeholder or the first available profile
if not profile_id:
# Get any profile, or None if no profiles exist
@@ -404,24 +409,24 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
profile_name = any_profile.name
else:
raise ValueError("No voice profiles found. Please create a profile before importing generations.")
# Extract audio file to temporary location
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp.write(zip_file.read(audio_file_path))
tmp_path = tmp.name
try:
# Create generations directory
generations_dir = config.get_generations_dir()
generations_dir.mkdir(parents=True, exist_ok=True)
# Generate new ID for this generation
new_generation_id = str(__import__('uuid').uuid4())
# Copy audio to generations directory
audio_dest = generations_dir / f"{new_generation_id}.wav"
shutil.copy(tmp_path, audio_dest)
# Create generation record
db_generation = DBGeneration(
id=new_generation_id,
@@ -434,11 +439,11 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
instruct=generation_data.get("instruct"),
created_at=datetime.utcnow(),
)
db.add(db_generation)
db.commit()
db.refresh(db_generation)
return {
"id": db_generation.id,
"profile_id": profile_id,
@@ -446,11 +451,11 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
"text": db_generation.text,
"message": f"Generation imported successfully (assigned to profile: {profile_name})"
}
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
except zipfile.BadZipFile:
raise ValueError("Invalid ZIP file")
except json.JSONDecodeError as e:
@@ -458,4 +463,4 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Error importing generation: {str(e)}")
raise ValueError(f"Error importing generation: {e!s}")
+20 -20
View File
@@ -18,12 +18,12 @@ from __future__ import annotations
import asyncio
import traceback
from typing import Literal, Optional
from typing import Literal
from .. import config
from . import history, profiles
from ..database import get_db
from ..utils.tasks import get_task_manager
from . import history, profiles
async def run_generation(
@@ -34,23 +34,23 @@ async def run_generation(
language: str,
engine: str,
model_size: str,
seed: Optional[int],
seed: int | None,
normalize: bool = False,
effects_chain: Optional[list] = None,
instruct: Optional[str] = None,
effects_chain: list | None = None,
instruct: str | None = None,
mode: Literal["generate", "retry", "regenerate"],
max_chunk_chars: Optional[int] = None,
crossfade_ms: Optional[int] = None,
version_id: Optional[str] = None,
max_chunk_chars: int | None = None,
crossfade_ms: int | None = None,
version_id: str | None = None,
) -> None:
"""Execute TTS inference and persist the result.
This is the single entry point for all background generation work.
It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
"""
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
from ..utils.chunked_tts import generate_chunked
from ..backends import engine_needs_trim, get_tts_backend_for_engine, load_engine_model
from ..utils.audio import normalize_audio, save_audio, trim_tts_output
from ..utils.chunked_tts import generate_chunked
task_manager = get_task_manager()
bg_db = next(get_db())
@@ -170,7 +170,7 @@ def _save_generate(
generation_id: str,
audio,
sample_rate: int,
effects_chain: Optional[list],
effects_chain: list | None,
save_audio,
db,
) -> str:
@@ -249,11 +249,11 @@ async def generate_audio_sync(
language: str,
engine: str,
model_size: str,
seed: Optional[int] = None,
instruct: Optional[str] = None,
seed: int | None = None,
instruct: str | None = None,
normalize: bool = True,
max_chunk_chars: Optional[int] = None,
crossfade_ms: Optional[int] = None,
max_chunk_chars: int | None = None,
crossfade_ms: int | None = None,
) -> bytes:
"""Run a TTS generation synchronously and return the resulting wav bytes.
@@ -267,9 +267,9 @@ async def generate_audio_sync(
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
(same helper ``/generate/stream`` uses).
"""
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
from ..utils.chunked_tts import generate_chunked
from ..backends import engine_needs_trim, get_tts_backend_for_engine, load_engine_model
from ..utils.audio import normalize_audio, trim_tts_output
from ..utils.chunked_tts import generate_chunked
from . import tts
bg_db = next(get_db())
@@ -312,7 +312,7 @@ async def generate_audio_sync(
def _save_regenerate(
*,
generation_id: str,
version_id: Optional[str],
version_id: str | None,
audio,
sample_rate: int,
save_audio,
@@ -322,10 +322,10 @@ def _save_regenerate(
Returns the audio path.
"""
from . import versions as versions_mod
import uuid as _uuid
from . import versions as versions_mod
suffix = _uuid.uuid4().hex[:8]
audio_path = config.get_generations_dir() / f"{generation_id}_{suffix}.wav"
save_audio(audio, str(audio_path), sample_rate)
+54 -46
View File
@@ -2,17 +2,25 @@
Generation history management module.
"""
from typing import List, Optional, Tuple
from datetime import datetime
import uuid
import shutil
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import or_
from datetime import datetime
from sqlalchemy.orm import Session
from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
from .. import config
from ..database import (
Generation as DBGeneration,
GenerationVersion as DBGenerationVersion,
VoiceProfile as DBVoiceProfile,
)
from ..models import (
EffectConfig,
GenerationResponse,
GenerationVersionResponse,
HistoryListResponse,
HistoryQuery,
HistoryResponse,
)
def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
@@ -58,13 +66,13 @@ async def create_generation(
language: str,
audio_path: str,
duration: float,
seed: Optional[int],
seed: int | None,
db: Session,
instruct: Optional[str] = None,
generation_id: Optional[str] = None,
instruct: str | None = None,
generation_id: str | None = None,
status: str = "completed",
engine: Optional[str] = "qwen",
model_size: Optional[str] = None,
engine: str | None = "qwen",
model_size: str | None = None,
source: str = "manual",
) -> GenerationResponse:
"""
@@ -118,10 +126,10 @@ async def update_generation_status(
generation_id: str,
status: str,
db: Session,
audio_path: Optional[str] = None,
duration: Optional[float] = None,
error: Optional[str] = None,
) -> Optional[GenerationResponse]:
audio_path: str | None = None,
duration: float | None = None,
error: str | None = None,
) -> GenerationResponse | None:
"""Update the status of a generation (used by async generation flow)."""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
@@ -143,21 +151,21 @@ async def update_generation_status(
async def get_generation(
generation_id: str,
db: Session,
) -> Optional[GenerationResponse]:
) -> GenerationResponse | None:
"""
Get a generation by ID.
Args:
generation_id: Generation ID
db: Database session
Returns:
Generation or None if not found
"""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
return None
return GenerationResponse.model_validate(generation)
@@ -167,11 +175,11 @@ async def list_generations(
) -> HistoryListResponse:
"""
List generations with optional filters.
Args:
query: Query parameters (filters, pagination)
db: Database session
Returns:
HistoryListResponse with items and total count
"""
@@ -183,28 +191,28 @@ async def list_generations(
DBVoiceProfile,
DBGeneration.profile_id == DBVoiceProfile.id
)
# Apply profile filter
if query.profile_id:
q = q.filter(DBGeneration.profile_id == query.profile_id)
# Apply search filter (searches in text content)
if query.search:
search_pattern = f"%{query.search}%"
q = q.filter(DBGeneration.text.like(search_pattern))
# Get total count before pagination
total_count = q.count()
# Apply ordering (newest first)
q = q.order_by(DBGeneration.created_at.desc())
# Apply pagination
q = q.offset(query.offset).limit(query.limit)
# Execute query
results = q.all()
# Convert to HistoryResponse with profile_name
items = []
for generation, profile_name in results:
@@ -228,7 +236,7 @@ async def list_generations(
versions=versions,
active_version_id=active_version_id,
))
return HistoryListResponse(
items=items,
total=total_count,
@@ -241,11 +249,11 @@ async def delete_generation(
) -> bool:
"""
Delete a generation.
Args:
generation_id: Generation ID
db: Database session
Returns:
True if deleted, False if not found
"""
@@ -266,7 +274,7 @@ async def delete_generation(
# Delete from database
db.delete(generation)
db.commit()
return True
@@ -313,16 +321,16 @@ async def delete_generations_by_profile(
) -> int:
"""
Delete all generations for a profile.
Args:
profile_id: Profile ID
db: Database session
Returns:
Number of generations deleted
"""
generations = db.query(DBGeneration).filter_by(profile_id=profile_id).all()
count = 0
for generation in generations:
# Delete associated version files and rows first
@@ -333,38 +341,38 @@ async def delete_generations_by_profile(
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
# Delete from database
db.delete(generation)
count += 1
db.commit()
return count
async def get_generation_stats(db: Session) -> dict:
"""
Get generation statistics.
Args:
db: Database session
Returns:
Statistics dictionary
"""
from sqlalchemy import func
total = db.query(func.count(DBGeneration.id)).scalar()
total_duration = db.query(func.sum(DBGeneration.duration)).scalar() or 0
# Get generations by profile
by_profile = db.query(
DBGeneration.profile_id,
func.count(DBGeneration.id).label('count')
).group_by(DBGeneration.profile_id).all()
return {
"total_generations": total,
"total_duration_seconds": total_duration,
-1
View File
@@ -24,7 +24,6 @@ from dataclasses import dataclass
from . import llm as llm_service
from .refinement import collapse_repetitive_artifacts
# Shared rules block embedded in every mode-specific system prompt. Kept
# short because small LLMs (0.6B) degrade when the system prompt is long,
# and because the per-mode instructions downstream carry the specifics.
-1
View File
@@ -5,7 +5,6 @@ import logging
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from sqlalchemy import func
from sqlalchemy.orm import Session
-1
View File
@@ -13,7 +13,6 @@ from dataclasses import dataclass
from . import llm as llm_service
# A run that repeats this many times gets collapsed before the LLM sees
# the transcript. Whisper occasionally loops content hundreds of times
# when audio trails off — "URL URL URL…" (single word), "thanks for
+8 -9
View File
@@ -20,11 +20,10 @@ import shutil
import sys
import tarfile
from pathlib import Path
from typing import Optional
from .. import __version__
from ..config import get_data_dir
from ..utils.progress import get_progress_manager
from .. import __version__
logger = logging.getLogger(__name__)
@@ -64,7 +63,7 @@ def get_rocm_exe_name() -> str:
return "voicebox-server-rocm"
def get_rocm_binary_path() -> Optional[Path]:
def get_rocm_binary_path() -> Path | None:
"""Return path to the ROCm executable if it exists inside the onedir."""
p = get_rocm_dir() / get_rocm_exe_name()
if p.exists():
@@ -77,7 +76,7 @@ def get_rocm_libs_manifest_path() -> Path:
return get_rocm_dir() / "rocm-libs.json"
def get_installed_rocm_libs_version() -> Optional[str]:
def get_installed_rocm_libs_version() -> str | None:
"""Read the installed ROCm libs version from rocm-libs.json, or None."""
manifest_path = get_rocm_libs_manifest_path()
if not manifest_path.exists():
@@ -115,7 +114,7 @@ def get_rocm_status() -> dict:
}
def _needs_server_download(version: Optional[str] = None) -> bool:
def _needs_server_download(version: str | None = None) -> bool:
"""Check if the server core archive needs to be (re)downloaded."""
rocm_path = get_rocm_binary_path()
if not rocm_path:
@@ -139,7 +138,7 @@ def _needs_rocm_libs_download() -> bool:
async def _download_and_extract_archive(
client,
url: str,
sha256_url: Optional[str],
sha256_url: str | None,
dest_dir: Path,
label: str,
progress_offset: int,
@@ -233,7 +232,7 @@ async def _download_and_extract_archive(
return downloaded
async def download_rocm_binary(version: Optional[str] = None):
async def download_rocm_binary(version: str | None = None):
"""Download the ROCm backend (server core + ROCm libs if needed).
Downloads both archives from GitHub Releases, extracts them into
@@ -253,7 +252,7 @@ async def download_rocm_binary(version: Optional[str] = None):
await _download_rocm_binary_locked(version)
async def _download_rocm_binary_locked(version: Optional[str] = None):
async def _download_rocm_binary_locked(version: str | None = None):
"""Inner implementation of download_rocm_binary, called under _download_lock."""
import httpx
@@ -394,7 +393,7 @@ async def _download_rocm_binary_locked(version: Optional[str] = None):
raise
def get_rocm_binary_version() -> Optional[str]:
def get_rocm_binary_version() -> str | None:
"""Get the version of the installed ROCm binary, or None if not installed."""
import subprocess
+1 -3
View File
@@ -11,14 +11,12 @@ from typing import Any
from sqlalchemy.orm import Session
from ..database import CaptureSettings as DBCaptureSettings
from ..database import GenerationSettings as DBGenerationSettings
from ..database import CaptureSettings as DBCaptureSettings, GenerationSettings as DBGenerationSettings
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
SINGLETON_ID = 1
+33 -33
View File
@@ -2,37 +2,37 @@
Story management module.
"""
from typing import List, Optional
from datetime import datetime
import uuid
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
from sqlalchemy.orm import Session
import numpy as np
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import config
from ..models import (
StoryCreate,
StoryResponse,
StoryDetailResponse,
StoryItemDetail,
StoryItemCreate,
StoryItemBatchUpdate,
StoryItemMove,
StoryItemTrim,
StoryItemVolumeUpdate,
StoryItemSplit,
StoryItemVersionUpdate,
)
from ..database import (
Generation as DBGeneration,
Story as DBStory,
StoryItem as DBStoryItem,
Generation as DBGeneration,
VoiceProfile as DBVoiceProfile,
)
from .history import _get_versions_for_generation
from ..models import (
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
StoryItemMove,
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
StoryResponse,
)
from ..utils.audio import load_audio, save_audio
import numpy as np
from .history import _get_versions_for_generation
def _build_item_detail(
@@ -113,7 +113,7 @@ async def create_story(
async def list_stories(
db: Session,
) -> List[StoryResponse]:
) -> list[StoryResponse]:
"""
List all stories.
@@ -139,7 +139,7 @@ async def list_stories(
async def get_story(
story_id: str,
db: Session,
) -> Optional[StoryDetailResponse]:
) -> StoryDetailResponse | None:
"""
Get a story with all its items.
@@ -176,7 +176,7 @@ async def update_story(
story_id: str,
data: StoryCreate,
db: Session,
) -> Optional[StoryResponse]:
) -> StoryResponse | None:
"""
Update a story.
@@ -238,7 +238,7 @@ async def add_item_to_story(
story_id: str,
data: StoryItemCreate,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""
Add a generation to a story.
@@ -324,7 +324,7 @@ async def move_story_item(
item_id: str,
data: StoryItemMove,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""
Move a story item (update position and/or track).
@@ -416,7 +416,7 @@ async def trim_story_item(
item_id: str,
data: StoryItemTrim,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""
Trim a story item (update trim_start_ms and trim_end_ms).
@@ -474,7 +474,7 @@ async def update_story_item_volume(
item_id: str,
data: StoryItemVolumeUpdate,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""Update a story item's playback volume (per-clip linear gain)."""
item = (
db.query(DBStoryItem)
@@ -505,7 +505,7 @@ async def split_story_item(
item_id: str,
data: StoryItemSplit,
db: Session,
) -> Optional[List[StoryItemDetail]]:
) -> list[StoryItemDetail] | None:
"""
Split a story item at a given time, creating two clips.
@@ -592,7 +592,7 @@ async def duplicate_story_item(
story_id: str,
item_id: str,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""
Duplicate a story item, creating a copy with all properties.
@@ -696,10 +696,10 @@ async def update_story_item_times(
async def reorder_story_items(
story_id: str,
generation_ids: List[str],
generation_ids: list[str],
db: Session,
gap_ms: int = 200,
) -> Optional[List[StoryItemDetail]]:
) -> list[StoryItemDetail] | None:
"""
Reorder story items and recalculate timecodes.
@@ -763,7 +763,7 @@ async def set_story_item_version(
item_id: str,
data: StoryItemVersionUpdate,
db: Session,
) -> Optional[StoryItemDetail]:
) -> StoryItemDetail | None:
"""
Pin a story item to a specific generation version.
@@ -824,7 +824,7 @@ async def set_story_item_version(
async def export_story_audio(
story_id: str,
db: Session,
) -> Optional[bytes]:
) -> bytes | None:
"""
Export story as single mixed audio file with timecode-based mixing.
+2 -1
View File
@@ -5,8 +5,9 @@ to avoid GPU contention.
import asyncio
import traceback
from collections.abc import Coroutine
from dataclasses import dataclass
from typing import Coroutine, Literal
from typing import Literal
# Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set()
+11 -13
View File
@@ -9,17 +9,15 @@ from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import List, Optional
from sqlalchemy.orm import Session
from ..database import (
GenerationVersion as DBGenerationVersion,
Generation as DBGeneration,
)
from ..models import GenerationVersionResponse, EffectConfig
from .. import config
from ..database import (
Generation as DBGeneration,
GenerationVersion as DBGenerationVersion,
)
from ..models import EffectConfig, GenerationVersionResponse
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
@@ -40,7 +38,7 @@ def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
)
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
def list_versions(generation_id: str, db: Session) -> list[GenerationVersionResponse]:
"""List all versions for a generation."""
versions = (
db.query(DBGenerationVersion)
@@ -51,7 +49,7 @@ def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResp
return [_version_response(v) for v in versions]
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
def get_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
"""Get a specific version by ID."""
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not v:
@@ -59,7 +57,7 @@ def get_version(version_id: str, db: Session) -> Optional[GenerationVersionRespo
return _version_response(v)
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
def get_default_version(generation_id: str, db: Session) -> GenerationVersionResponse | None:
"""Get the default version for a generation."""
v = (
db.query(DBGenerationVersion)
@@ -84,9 +82,9 @@ def create_version(
label: str,
audio_path: str,
db: Session,
effects_chain: Optional[List[dict]] = None,
effects_chain: list[dict] | None = None,
is_default: bool = False,
source_version_id: Optional[str] = None,
source_version_id: str | None = None,
) -> GenerationVersionResponse:
"""Create a new version for a generation.
@@ -119,7 +117,7 @@ def create_version(
return _version_response(version)
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
def set_default_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
"""Set a version as the default for its generation."""
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not version: