Files
voicebox/backend/profiles.py

565 lines
15 KiB
Python

"""
Voice profile management module.
"""
from typing import List, Optional
from datetime import datetime
import uuid
import shutil
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func, select
from .models import (
VoiceProfileCreate,
VoiceProfileResponse,
ProfileSampleCreate,
ProfileSampleResponse,
)
from .database import (
VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample,
Generation as DBGeneration,
)
from .models import EffectConfig
from .utils.audio import validate_reference_audio, load_audio, save_audio
from .utils.images import validate_image, process_avatar
from .utils.cache import _get_cache_dir, clear_profile_cache
from .tts import get_tts_model
from . import config
import json as _json
def _profile_to_response(
profile: DBVoiceProfile,
generation_count: int = 0,
sample_count: int = 0,
) -> VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain."""
effects_chain = None
if profile.effects_chain:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
except Exception as e:
import logging
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return VoiceProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description,
language=profile.language,
avatar_path=profile.avatar_path,
effects_chain=effects_chain,
generation_count=generation_count,
sample_count=sample_count,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _get_profiles_dir() -> Path:
"""Get profiles directory from config."""
return config.get_profiles_dir()
async def create_profile(
data: VoiceProfileCreate,
db: Session,
) -> VoiceProfileResponse:
"""
Create a new voice profile.
Args:
data: Profile creation data
db: Database session
Returns:
Created profile
Raises:
ValueError: If a profile with the same name already exists
"""
# Check if profile name already exists
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Create profile in database
db_profile = DBVoiceProfile(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
language=data.language,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(db_profile)
db.commit()
db.refresh(db_profile)
# Create profile directory
profile_dir = _get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
return _profile_to_response(db_profile)
async def add_profile_sample(
profile_id: str,
audio_path: str,
reference_text: str,
db: Session,
) -> ProfileSampleResponse:
"""
Add a sample to a voice profile.
Args:
profile_id: Profile ID
audio_path: Path to temporary audio file
reference_text: Transcript of audio
db: Database session
Returns:
Created sample
"""
# Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
# Validate audio
is_valid, error_msg = validate_reference_audio(audio_path)
if not is_valid:
raise ValueError(f"Invalid reference audio: {error_msg}")
# Create sample ID and directory
sample_id = str(uuid.uuid4())
profile_dir = _get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True)
# Copy audio file to profile directory
dest_path = profile_dir / f"{sample_id}.wav"
audio, sr = load_audio(audio_path)
save_audio(audio, str(dest_path), sr)
# Create database entry
db_sample = DBProfileSample(
id=sample_id,
profile_id=profile_id,
audio_path=str(dest_path),
reference_text=reference_text,
)
db.add(db_sample)
# Update profile timestamp
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(db_sample)
# Invalidate combined audio cache for this profile
# Since a new sample was added, any cached combined audio is now stale
clear_profile_cache(profile_id)
return ProfileSampleResponse.model_validate(db_sample)
async def get_profile(
profile_id: str,
db: Session,
) -> Optional[VoiceProfileResponse]:
"""
Get a voice profile by ID.
Args:
profile_id: Profile ID
db: Database session
Returns:
Profile or None if not found
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return None
return _profile_to_response(profile)
async def get_profile_samples(
profile_id: str,
db: Session,
) -> List[ProfileSampleResponse]:
"""
Get all samples for a profile.
Args:
profile_id: Profile ID
db: Database session
Returns:
List of samples
"""
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
return [ProfileSampleResponse.model_validate(s) for s in samples]
async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
"""
List all voice profiles with generation and sample counts.
Args:
db: Database session
Returns:
List of profiles
"""
profiles = db.query(DBVoiceProfile).order_by(
DBVoiceProfile.created_at.desc()
).all()
if not profiles:
return []
# Batch-fetch generation counts
gen_counts_rows = (
db.query(DBGeneration.profile_id, func.count(DBGeneration.id))
.group_by(DBGeneration.profile_id)
.all()
)
gen_counts = {row[0]: row[1] for row in gen_counts_rows}
# Batch-fetch sample counts
sample_counts_rows = (
db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id))
.group_by(DBProfileSample.profile_id)
.all()
)
sample_counts = {row[0]: row[1] for row in sample_counts_rows}
return [
_profile_to_response(
p,
generation_count=gen_counts.get(p.id, 0),
sample_count=sample_counts.get(p.id, 0),
)
for p in profiles
]
async def update_profile(
profile_id: str,
data: VoiceProfileCreate,
db: Session,
) -> Optional[VoiceProfileResponse]:
"""
Update a voice profile.
Args:
profile_id: Profile ID
data: Updated profile data
db: Database session
Returns:
Updated profile or None if not found
Raises:
ValueError: If a profile with the same name already exists (different profile)
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return None
# Check if the new name conflicts with another profile
if profile.name != data.name:
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Update fields
profile.name = data.name
profile.description = data.description
profile.language = data.language
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return _profile_to_response(profile)
async def delete_profile(
profile_id: str,
db: Session,
) -> bool:
"""
Delete a voice profile and all associated data.
Args:
profile_id: Profile ID
db: Database session
Returns:
True if deleted, False if not found
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return False
# Delete samples from database
db.query(DBProfileSample).filter_by(profile_id=profile_id).delete()
# Delete profile from database
db.delete(profile)
db.commit()
# Delete profile directory
profile_dir = _get_profiles_dir() / profile_id
if profile_dir.exists():
shutil.rmtree(profile_dir)
# Clean up combined audio cache files for this profile
clear_profile_cache(profile_id)
return True
async def delete_profile_sample(
sample_id: str,
db: Session,
) -> bool:
"""
Delete a profile sample.
Args:
sample_id: Sample ID
db: Database session
Returns:
True if deleted, False if not found
"""
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample:
return False
# Store profile_id before deleting
profile_id = sample.profile_id
# Delete audio file
audio_path = Path(sample.audio_path)
if audio_path.exists():
audio_path.unlink()
# Delete from database
db.delete(sample)
db.commit()
# Invalidate combined audio cache for this profile
# Since the sample set changed, any cached combined audio is now stale
clear_profile_cache(profile_id)
return True
async def update_profile_sample(
sample_id: str,
reference_text: str,
db: Session,
) -> Optional[ProfileSampleResponse]:
"""
Update a profile sample's reference text.
Args:
sample_id: Sample ID
reference_text: Updated reference text
db: Database session
Returns:
Updated sample or None if not found
"""
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample:
return None
# Store profile_id before updating
profile_id = sample.profile_id
sample.reference_text = reference_text
db.commit()
db.refresh(sample)
# Invalidate combined audio cache for this profile
# Since the reference text changed, cache keys and combined text are now stale
clear_profile_cache(profile_id)
return ProfileSampleResponse.model_validate(sample)
async def create_voice_prompt_for_profile(
profile_id: str,
db: Session,
use_cache: bool = True,
engine: str = "qwen",
) -> dict:
"""
Create a combined voice prompt from all samples in a profile.
Args:
profile_id: Profile ID
db: Database session
use_cache: Whether to use cached prompts
engine: TTS engine to create prompt for ("qwen" or "luxtts")
Returns:
Voice prompt dictionary
"""
from .backends import get_tts_backend_for_engine
# Get all samples for profile
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples:
raise ValueError(f"No samples found for profile {profile_id}")
tts_model = get_tts_backend_for_engine(engine)
if len(samples) == 1:
# Single sample - use directly
sample = samples[0]
voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path,
sample.reference_text,
use_cache=use_cache,
)
return voice_prompt
else:
# Multiple samples - combine them
audio_paths = [s.audio_path for s in samples]
reference_texts = [s.reference_text for s in samples]
# Combine audio
combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths,
reference_texts,
)
# Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination
import hashlib
sample_ids_str = "-".join(sorted([s.id for s in samples]))
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
# Store in cache directory
cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
# Save combined audio
save_audio(combined_audio, str(combined_path), 24000)
# Create prompt from combined audio
voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path),
combined_text,
use_cache=use_cache,
)
return voice_prompt
async def upload_avatar(
profile_id: str,
image_path: str,
db: Session,
) -> VoiceProfileResponse:
"""
Upload and process avatar image for a profile.
Args:
profile_id: Profile ID
image_path: Path to uploaded image file
db: Database session
Returns:
Updated profile
"""
# Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
# Validate image
is_valid, error_msg = validate_image(image_path)
if not is_valid:
raise ValueError(error_msg)
# Delete existing avatar if present
if profile.avatar_path:
old_avatar = Path(profile.avatar_path)
if old_avatar.exists():
old_avatar.unlink()
# Determine file extension from uploaded file
from PIL import Image
with Image.open(image_path) as img:
# Normalize JPEG variants (MPO is multi-picture format from some cameras)
img_format = img.format
if img_format in ('MPO', 'JPG'):
img_format = 'JPEG'
ext_map = {
'PNG': '.png',
'JPEG': '.jpg',
'WEBP': '.webp'
}
ext = ext_map.get(img_format, '.png')
# Save processed image to profile directory
profile_dir = _get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / f"avatar{ext}"
process_avatar(image_path, str(output_path))
# Update database
profile.avatar_path = str(output_path)
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return _profile_to_response(profile)
async def delete_avatar(
profile_id: str,
db: Session,
) -> bool:
"""
Delete avatar image for a profile.
Args:
profile_id: Profile ID
db: Database session
Returns:
True if deleted, False if not found or no avatar
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile or not profile.avatar_path:
return False
# Delete avatar file
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path.unlink()
# Update database
profile.avatar_path = None
profile.updated_at = datetime.utcnow()
db.commit()
return True