Fix review findings: toggle logic, preset saving, version lookup, async audio ops

- Fix inverted effects toggle in FloatingGenerateBox
- Add Save button + API method for editing custom effect presets
- Return early on effects save failure in ProfileForm
- Fix no-op ternary in effectsStore
- Handle duplicate preset names with proper 400 response
- Use effects_chain is None instead of label for clean version lookup
- Move blocking audio ops to asyncio.to_thread in async endpoints
- Log warnings instead of silently swallowing parse errors
This commit is contained in:
Jamie Pine
2026-03-14 07:47:06 -07:00
parent 638820c839
commit 3d922ec846
9 changed files with 95 additions and 31 deletions
+11 -1
View File
@@ -9,6 +9,7 @@ import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .database import EffectPreset as DBEffectPreset
from .models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
@@ -58,6 +59,11 @@ def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse
if error:
raise ValueError(error)
# Check for duplicate name before insert
existing = db.query(DBEffectPreset).filter_by(name=data.name).first()
if existing:
raise ValueError(f"A preset named '{data.name}' already exists")
preset = DBEffectPreset(
id=str(uuid.uuid4()),
name=data.name,
@@ -66,7 +72,11 @@ def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse
is_builtin=False,
)
db.add(preset)
db.commit()
try:
db.commit()
except IntegrityError:
db.rollback()
raise ValueError(f"A preset named '{data.name}' already exists")
db.refresh(preset)
return _preset_response(preset)
+16 -15
View File
@@ -1297,7 +1297,7 @@ async def transcribe_audio(
try:
# Get audio duration
from .utils.audio import load_audio
audio, sr = load_audio(tmp_path)
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
duration = len(audio) / sr
# Transcribe
@@ -1585,21 +1585,21 @@ async def preview_effects(
if error:
raise HTTPException(status_code=400, detail=error)
# Find clean version
# Find the original unprocessed version (no effects applied)
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next((v for v in all_versions if v.label == "clean"), None)
clean_version = next((v for v in all_versions if v.effects_chain is None), None)
source_path = clean_version.audio_path if clean_version else gen.audio_path
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Process in memory
audio, sample_rate = load_audio(source_path)
processed = apply_effects(audio, sample_rate, chain_dicts)
# Process in memory (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Write to in-memory buffer
import soundfile as sf
buf = io.BytesIO()
sf.write(buf, processed, sample_rate, format="WAV")
await asyncio.to_thread(sf.write, buf, processed, sample_rate, "WAV")
buf.seek(0)
return StreamingResponse(
@@ -1723,10 +1723,10 @@ async def apply_effects_to_generation(
if error:
raise HTTPException(status_code=400, detail=error)
# Find the clean version to apply effects to
# Find the original unprocessed version (no effects applied)
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next(
(v for v in all_versions if v.label == "clean"), None
(v for v in all_versions if v.effects_chain is None), None
)
if not clean_version:
# Fallback: use the generation's audio_path directly
@@ -1737,14 +1737,14 @@ async def apply_effects_to_generation(
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Load, process, save
audio, sample_rate = load_audio(source_path)
processed_audio = apply_effects(audio, sample_rate, chain_dicts)
# Load, process, save (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Generate a unique filename
version_id = str(uuid.uuid4())
processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
save_audio(processed_audio, str(processed_path), sample_rate)
await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate)
# Auto-label
label = data.label or f"version-{len(all_versions) + 1}"
@@ -1857,14 +1857,15 @@ async def update_profile_effects(
def _profile_to_response(profile) -> models.VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse with parsed effects_chain."""
import json as _json
import logging
effects_chain = None
if profile.effects_chain:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [models.EffectConfig(**e) for e in raw]
except Exception:
pass
except Exception as e:
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return models.VoiceProfileResponse(
id=profile.id,
+3 -2
View File
@@ -36,8 +36,9 @@ def _profile_to_response(profile: DBVoiceProfile) -> VoiceProfileResponse:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
except Exception:
pass
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,