mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
chore(backend): repair test suite and bring ruff to green
The suite hadn't run green since the routes refactor: - test_profile_duplicate_names.py imported the pre-refactor module layout and broke collection; now imports backend.services.profiles - tests/conftest.py puts the repo root and backend dir on sys.path so files collect standalone instead of depending on run order - test_cors.py tested a hand-copied mirror of the origin list that had drifted from app.py (missing http://tauri.localhost); it now builds the app via the real create_app() factory - test_progress.py simulated a 1KB download, below the tracker's 1MB reporting threshold; simulation raised to 5MB - slow/timeout markers registered in pyproject Ruff: ~900 violations auto-fixed (typing modernization, import sorting, unused imports, whitespace). The remaining rules are baselined in pyproject.toml with per-rule counts to burn down, plus per-file carve-outs for deliberate env-before-import ordering. ruff check is now clean; suite is 134 passed, 2 skipped.
This commit is contained in:
@@ -12,7 +12,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import soundfile as sf
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,7 +34,7 @@ WHISPER_NATIVE_FORMATS = (".wav", ".mp3", ".flac", ".ogg")
|
||||
|
||||
|
||||
def _to_response(row: DBCapture) -> CaptureResponse:
|
||||
flags_model: Optional[RefinementFlagsModel] = None
|
||||
flags_model: RefinementFlagsModel | None = None
|
||||
if row.refinement_flags:
|
||||
try:
|
||||
flags_model = RefinementFlagsModel(**json.loads(row.refinement_flags))
|
||||
@@ -62,8 +61,8 @@ async def create_capture(
|
||||
audio_bytes: bytes,
|
||||
filename: str,
|
||||
source: str,
|
||||
language: Optional[str],
|
||||
stt_model: Optional[str],
|
||||
language: str | None,
|
||||
stt_model: str | None,
|
||||
db: Session,
|
||||
) -> CaptureResponse:
|
||||
"""Persist raw audio, run STT, store the row."""
|
||||
@@ -159,7 +158,7 @@ def list_captures(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[C
|
||||
return [_to_response(r) for r in rows], total
|
||||
|
||||
|
||||
def get_capture(capture_id: str, db: Session) -> Optional[CaptureResponse]:
|
||||
def get_capture(capture_id: str, db: Session) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
return _to_response(row) if row else None
|
||||
|
||||
@@ -184,9 +183,9 @@ def delete_capture(capture_id: str, db: Session) -> bool:
|
||||
async def refine_capture(
|
||||
capture_id: str,
|
||||
flags: RefinementFlags,
|
||||
model_size: Optional[str],
|
||||
model_size: str | None,
|
||||
db: Session,
|
||||
) -> Optional[CaptureResponse]:
|
||||
) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
if not row:
|
||||
return None
|
||||
@@ -207,10 +206,10 @@ async def refine_capture(
|
||||
|
||||
async def retranscribe_capture(
|
||||
capture_id: str,
|
||||
stt_model: Optional[str],
|
||||
language: Optional[str],
|
||||
stt_model: str | None,
|
||||
language: str | None,
|
||||
db: Session,
|
||||
) -> Optional[CaptureResponse]:
|
||||
) -> CaptureResponse | None:
|
||||
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
@@ -2,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()
|
||||
|
||||
@@ -19,11 +19,10 @@ import os
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .. import __version__
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,7 +62,7 @@ def get_cuda_exe_name() -> str:
|
||||
return "voicebox-server-cuda"
|
||||
|
||||
|
||||
def get_cuda_binary_path() -> Optional[Path]:
|
||||
def get_cuda_binary_path() -> Path | None:
|
||||
"""Return path to the CUDA executable if it exists inside the onedir."""
|
||||
p = get_cuda_dir() / get_cuda_exe_name()
|
||||
if p.exists():
|
||||
@@ -76,7 +75,7 @@ def get_cuda_libs_manifest_path() -> Path:
|
||||
return get_cuda_dir() / "cuda-libs.json"
|
||||
|
||||
|
||||
def get_installed_cuda_libs_version() -> Optional[str]:
|
||||
def get_installed_cuda_libs_version() -> str | None:
|
||||
"""Read the installed CUDA libs version from cuda-libs.json, or None."""
|
||||
manifest_path = get_cuda_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
@@ -114,7 +113,7 @@ def get_cuda_status() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
def _needs_server_download(version: str | None = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
@@ -138,7 +137,7 @@ def _needs_cuda_libs_download() -> bool:
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
sha256_url: str | None,
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
@@ -223,10 +222,7 @@ async def _download_and_extract_archive(
|
||||
status="downloading",
|
||||
)
|
||||
with tarfile.open(temp_path, "r:gz") as tar:
|
||||
if sys.version_info >= (3, 12):
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
else:
|
||||
tar.extractall(path=dest_dir)
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
|
||||
logger.info(f"{label}: extracted to {dest_dir}")
|
||||
finally:
|
||||
@@ -235,7 +231,7 @@ async def _download_and_extract_archive(
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_cuda_binary(version: Optional[str] = None):
|
||||
async def download_cuda_binary(version: str | None = None):
|
||||
"""Download the CUDA backend (server core + CUDA libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
@@ -255,7 +251,7 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
await _download_cuda_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
async def _download_cuda_binary_locked(version: str | None = None):
|
||||
"""Inner implementation of download_cuda_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
@@ -353,7 +349,7 @@ async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
raise
|
||||
|
||||
|
||||
def get_cuda_binary_version() -> Optional[str]:
|
||||
def get_cuda_binary_version() -> str | None:
|
||||
"""Get the version of the installed CUDA binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..utils.effects import validate_effects_chain
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import EffectPreset as DBEffectPreset
|
||||
from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
|
||||
from ..models import EffectConfig, EffectPresetCreate, EffectPresetResponse, EffectPresetUpdate
|
||||
from ..utils.effects import validate_effects_chain
|
||||
|
||||
|
||||
def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
|
||||
@@ -30,13 +28,13 @@ def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
|
||||
)
|
||||
|
||||
|
||||
def list_presets(db: Session) -> List[EffectPresetResponse]:
|
||||
def list_presets(db: Session) -> list[EffectPresetResponse]:
|
||||
"""List all effect presets (built-in + user-created)."""
|
||||
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
|
||||
return [_preset_response(p) for p in presets]
|
||||
|
||||
|
||||
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def get_preset(preset_id: str, db: Session) -> EffectPresetResponse | None:
|
||||
"""Get a preset by ID."""
|
||||
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||
if not p:
|
||||
@@ -44,7 +42,7 @@ def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
return _preset_response(p)
|
||||
|
||||
|
||||
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def get_preset_by_name(name: str, db: Session) -> EffectPresetResponse | None:
|
||||
"""Get a preset by name."""
|
||||
p = db.query(DBEffectPreset).filter_by(name=name).first()
|
||||
if not p:
|
||||
@@ -82,7 +80,7 @@ def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse
|
||||
return _preset_response(preset)
|
||||
|
||||
|
||||
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
|
||||
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> EffectPresetResponse | None:
|
||||
"""Update a user effect preset. Cannot modify built-in presets."""
|
||||
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||
if not preset:
|
||||
|
||||
@@ -5,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}")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -24,7 +24,6 @@ from dataclasses import dataclass
|
||||
from . import llm as llm_service
|
||||
from .refinement import collapse_repetitive_artifacts
|
||||
|
||||
|
||||
# Shared rules block embedded in every mode-specific system prompt. Kept
|
||||
# short because small LLMs (0.6B) degrade when the system prompt is long,
|
||||
# and because the per-mode instructions downstream carry the specifics.
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,7 +13,6 @@ from dataclasses import dataclass
|
||||
|
||||
from . import llm as llm_service
|
||||
|
||||
|
||||
# A run that repeats this many times gets collapsed before the LLM sees
|
||||
# the transcript. Whisper occasionally loops content hundreds of times
|
||||
# when audio trails off — "URL URL URL…" (single word), "thanks for
|
||||
|
||||
@@ -20,11 +20,10 @@ import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .. import __version__
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +63,7 @@ def get_rocm_exe_name() -> str:
|
||||
return "voicebox-server-rocm"
|
||||
|
||||
|
||||
def get_rocm_binary_path() -> Optional[Path]:
|
||||
def get_rocm_binary_path() -> Path | None:
|
||||
"""Return path to the ROCm executable if it exists inside the onedir."""
|
||||
p = get_rocm_dir() / get_rocm_exe_name()
|
||||
if p.exists():
|
||||
@@ -77,7 +76,7 @@ def get_rocm_libs_manifest_path() -> Path:
|
||||
return get_rocm_dir() / "rocm-libs.json"
|
||||
|
||||
|
||||
def get_installed_rocm_libs_version() -> Optional[str]:
|
||||
def get_installed_rocm_libs_version() -> str | None:
|
||||
"""Read the installed ROCm libs version from rocm-libs.json, or None."""
|
||||
manifest_path = get_rocm_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
@@ -115,7 +114,7 @@ def get_rocm_status() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
def _needs_server_download(version: str | None = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
rocm_path = get_rocm_binary_path()
|
||||
if not rocm_path:
|
||||
@@ -139,7 +138,7 @@ def _needs_rocm_libs_download() -> bool:
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
sha256_url: str | None,
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
@@ -233,7 +232,7 @@ async def _download_and_extract_archive(
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_rocm_binary(version: Optional[str] = None):
|
||||
async def download_rocm_binary(version: str | None = None):
|
||||
"""Download the ROCm backend (server core + ROCm libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
@@ -253,7 +252,7 @@ async def download_rocm_binary(version: Optional[str] = None):
|
||||
await _download_rocm_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_rocm_binary_locked(version: Optional[str] = None):
|
||||
async def _download_rocm_binary_locked(version: str | None = None):
|
||||
"""Inner implementation of download_rocm_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
@@ -394,7 +393,7 @@ async def _download_rocm_binary_locked(version: Optional[str] = None):
|
||||
raise
|
||||
|
||||
|
||||
def get_rocm_binary_version() -> Optional[str]:
|
||||
def get_rocm_binary_version() -> str | None:
|
||||
"""Get the version of the installed ROCm binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -11,14 +11,12 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import CaptureSettings as DBCaptureSettings
|
||||
from ..database import GenerationSettings as DBGenerationSettings
|
||||
from ..database import CaptureSettings as DBCaptureSettings, GenerationSettings as DBGenerationSettings
|
||||
from ..utils.capture_chords import (
|
||||
default_push_to_talk_chord,
|
||||
default_toggle_to_talk_chord,
|
||||
)
|
||||
|
||||
|
||||
SINGLETON_ID = 1
|
||||
|
||||
|
||||
|
||||
+33
-33
@@ -2,37 +2,37 @@
|
||||
Story management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..models import (
|
||||
StoryCreate,
|
||||
StoryResponse,
|
||||
StoryDetailResponse,
|
||||
StoryItemDetail,
|
||||
StoryItemCreate,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemVolumeUpdate,
|
||||
StoryItemSplit,
|
||||
StoryItemVersionUpdate,
|
||||
)
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
Story as DBStory,
|
||||
StoryItem as DBStoryItem,
|
||||
Generation as DBGeneration,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
from .history import _get_versions_for_generation
|
||||
from ..models import (
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemDetail,
|
||||
StoryItemMove,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryItemVolumeUpdate,
|
||||
StoryResponse,
|
||||
)
|
||||
from ..utils.audio import load_audio, save_audio
|
||||
import numpy as np
|
||||
from .history import _get_versions_for_generation
|
||||
|
||||
|
||||
def _build_item_detail(
|
||||
@@ -113,7 +113,7 @@ async def create_story(
|
||||
|
||||
async def list_stories(
|
||||
db: Session,
|
||||
) -> List[StoryResponse]:
|
||||
) -> list[StoryResponse]:
|
||||
"""
|
||||
List all stories.
|
||||
|
||||
@@ -139,7 +139,7 @@ async def list_stories(
|
||||
async def get_story(
|
||||
story_id: str,
|
||||
db: Session,
|
||||
) -> Optional[StoryDetailResponse]:
|
||||
) -> StoryDetailResponse | None:
|
||||
"""
|
||||
Get a story with all its items.
|
||||
|
||||
@@ -176,7 +176,7 @@ async def update_story(
|
||||
story_id: str,
|
||||
data: StoryCreate,
|
||||
db: Session,
|
||||
) -> Optional[StoryResponse]:
|
||||
) -> StoryResponse | None:
|
||||
"""
|
||||
Update a story.
|
||||
|
||||
@@ -238,7 +238,7 @@ async def add_item_to_story(
|
||||
story_id: str,
|
||||
data: StoryItemCreate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Add a generation to a story.
|
||||
|
||||
@@ -324,7 +324,7 @@ async def move_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemMove,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Move a story item (update position and/or track).
|
||||
|
||||
@@ -416,7 +416,7 @@ async def trim_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemTrim,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Trim a story item (update trim_start_ms and trim_end_ms).
|
||||
|
||||
@@ -474,7 +474,7 @@ async def update_story_item_volume(
|
||||
item_id: str,
|
||||
data: StoryItemVolumeUpdate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""Update a story item's playback volume (per-clip linear gain)."""
|
||||
item = (
|
||||
db.query(DBStoryItem)
|
||||
@@ -505,7 +505,7 @@ async def split_story_item(
|
||||
item_id: str,
|
||||
data: StoryItemSplit,
|
||||
db: Session,
|
||||
) -> Optional[List[StoryItemDetail]]:
|
||||
) -> list[StoryItemDetail] | None:
|
||||
"""
|
||||
Split a story item at a given time, creating two clips.
|
||||
|
||||
@@ -592,7 +592,7 @@ async def duplicate_story_item(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Duplicate a story item, creating a copy with all properties.
|
||||
|
||||
@@ -696,10 +696,10 @@ async def update_story_item_times(
|
||||
|
||||
async def reorder_story_items(
|
||||
story_id: str,
|
||||
generation_ids: List[str],
|
||||
generation_ids: list[str],
|
||||
db: Session,
|
||||
gap_ms: int = 200,
|
||||
) -> Optional[List[StoryItemDetail]]:
|
||||
) -> list[StoryItemDetail] | None:
|
||||
"""
|
||||
Reorder story items and recalculate timecodes.
|
||||
|
||||
@@ -763,7 +763,7 @@ async def set_story_item_version(
|
||||
item_id: str,
|
||||
data: StoryItemVersionUpdate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
) -> StoryItemDetail | None:
|
||||
"""
|
||||
Pin a story item to a specific generation version.
|
||||
|
||||
@@ -824,7 +824,7 @@ async def set_story_item_version(
|
||||
async def export_story_audio(
|
||||
story_id: str,
|
||||
db: Session,
|
||||
) -> Optional[bytes]:
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Export story as single mixed audio file with timecode-based mixing.
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ to avoid GPU contention.
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Coroutine, Literal
|
||||
from typing import Literal
|
||||
|
||||
# Keep references to fire-and-forget background tasks to prevent GC
|
||||
_background_tasks: set = set()
|
||||
|
||||
@@ -9,17 +9,15 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import (
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
Generation as DBGeneration,
|
||||
)
|
||||
from ..models import GenerationVersionResponse, EffectConfig
|
||||
from .. import config
|
||||
from ..database import (
|
||||
Generation as DBGeneration,
|
||||
GenerationVersion as DBGenerationVersion,
|
||||
)
|
||||
from ..models import EffectConfig, GenerationVersionResponse
|
||||
|
||||
|
||||
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
|
||||
@@ -40,7 +38,7 @@ def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
|
||||
)
|
||||
|
||||
|
||||
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
|
||||
def list_versions(generation_id: str, db: Session) -> list[GenerationVersionResponse]:
|
||||
"""List all versions for a generation."""
|
||||
versions = (
|
||||
db.query(DBGenerationVersion)
|
||||
@@ -51,7 +49,7 @@ def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResp
|
||||
return [_version_response(v) for v in versions]
|
||||
|
||||
|
||||
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def get_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Get a specific version by ID."""
|
||||
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||
if not v:
|
||||
@@ -59,7 +57,7 @@ def get_version(version_id: str, db: Session) -> Optional[GenerationVersionRespo
|
||||
return _version_response(v)
|
||||
|
||||
|
||||
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def get_default_version(generation_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Get the default version for a generation."""
|
||||
v = (
|
||||
db.query(DBGenerationVersion)
|
||||
@@ -84,9 +82,9 @@ def create_version(
|
||||
label: str,
|
||||
audio_path: str,
|
||||
db: Session,
|
||||
effects_chain: Optional[List[dict]] = None,
|
||||
effects_chain: list[dict] | None = None,
|
||||
is_default: bool = False,
|
||||
source_version_id: Optional[str] = None,
|
||||
source_version_id: str | None = None,
|
||||
) -> GenerationVersionResponse:
|
||||
"""Create a new version for a generation.
|
||||
|
||||
@@ -119,7 +117,7 @@ def create_version(
|
||||
return _version_response(version)
|
||||
|
||||
|
||||
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||
def set_default_version(version_id: str, db: Session) -> GenerationVersionResponse | None:
|
||||
"""Set a version as the default for its generation."""
|
||||
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||
if not version:
|
||||
|
||||
Reference in New Issue
Block a user