Skip to content

Add post-processing audio effects system#271

Merged
jamiepine merged 11 commits intomainfrom
feat/post-processing-effects
Mar 14, 2026
Merged

Add post-processing audio effects system#271
jamiepine merged 11 commits intomainfrom
feat/post-processing-effects

Conversation

@jamiepine
Copy link
Owner

@jamiepine jamiepine commented Mar 14, 2026

Summary

Adds a full audio post-processing effects pipeline powered by Spotify's pedalboard library, enabling users to apply professional DSP effects to generated speech.

Features

Effects Engine (backend/utils/effects.py)

  • 8 effect types: Chorus/Flanger, Reverb, Delay, Compressor, Gain, High-Pass Filter, Low-Pass Filter, Pitch Shift
  • JSON-serializable effects chain format for storage and API transport
  • Validation with parameter range checking
  • 4 built-in presets: Robotic, Radio, Echo Chamber, Deep Voice

Generation Versions (backend/versions.py)

  • Every generation now saves a clean (unprocessed) copy first
  • Processed versions created alongside when effects are configured
  • Multiple versions per generation with switchable default
  • Backward-compatible: existing generations backfilled as clean versions on startup

Effects Presets (backend/effects.py)

  • CRUD for user-created effect presets
  • Built-in presets seeded on startup (non-deletable)
  • Configurable sort order

API Endpoints

  • GET /effects/available — list all effect types with parameter definitions
  • GET /effects/presets — list all presets (built-in + custom)
  • POST /effects/presets — create custom preset
  • DELETE /effects/presets/{id} — delete custom preset
  • POST /effects/preview/{generation_id} — ephemeral preview (process in memory, stream WAV back without saving)
  • POST /generations/{id}/versions/apply-effects — apply effects to existing generation
  • GET /generations/{id}/versions — list all versions
  • PUT /generations/{id}/versions/{vid}/set-default — switch active version
  • GET /audio/version/{version_id} — serve specific version audio
  • PUT /profiles/{id}/effects — set default effects chain on a voice profile

Frontend

  • Effects Chain Editor (app/src/components/Effects/EffectsChainEditor.tsx) — drag-and-drop reorderable effect chain builder with parameter sliders, preset loading, enable/disable per effect
  • Effects Tab (app/src/components/EffectsTab/) — dedicated sidebar tab with left panel (preset list) + right panel (editor with live preview via generation picker)
  • Generation Form — optional effects chain toggle (wand icon) with preset selector
  • History — expandable versions panel on generation cards, "Apply Effects" in context menu
  • Profile Editing — default effects chain configuration per voice profile
  • Generation Picker (app/src/components/Effects/GenerationPicker.tsx) — reusable popover for selecting a generation for preview

Integration Points

  • Generation pipeline saves clean + processed versions automatically
  • Profile default effects applied when no per-generation override specified
  • Version switching updates the generation's active audio path
  • Preview plays through the main WaveSurfer audio player

Dependencies

  • pedalboard>=0.9.0 (Spotify's DSP library, pip-installable, no system deps)
  • @dnd-kit (already in the project, used for effect chain reordering)

Summary by CodeRabbit

  • New Features
    • Full audio effects system: chainable effects editor with drag‑reorder, built‑in and user presets, preview/apply workflows, per‑generation versioning, and versioned playback controls.
    • UI additions: effects editor integrated into generator, profile editor, presets browser, Effects management tab, sidebar entry, and Apply Effects dialog in history.
  • Backend / API
    • New endpoints for effects, presets, previews, generation versioning, and profile default effects; multi‑version export support.
  • UX
    • Improved audio playback end‑of‑track behavior and select focus styling for nested content.

Adds a full effects pipeline powered by Spotify's pedalboard library,
enabling users to apply professional DSP effects (flanger, reverb, delay,
compressor, pitch shift, filters, gain) to generated audio.

Key features:
- Effects chain editor with drag-and-drop reordering (dnd-kit)
- Generation versions: clean copy always saved, processed versions created on top
- Built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) + custom user presets
- Per-profile default effects chain (auto-applied to new generations)
- Per-generation effects override from the generation form
- Apply effects to existing generations from history (creates new version)
- Ephemeral preview endpoint for auditioning effects without persisting
- Dedicated Effects sidebar tab with preset management and live preview
- Version switcher in history cards with expandable panel
- Backward-compatible: existing generations backfilled as clean versions
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a full effects/post-processing system: frontend editors and UI, presets and preset CRUD, preview/apply/versioning of processed audio, a pedalboard-based backend effects engine, DB models for presets and generation versions, API client methods/types, and integration into generation, history, and profile flows.

Changes

Cohort / File(s) Summary
Effects UI
app/src/components/Effects/EffectsChainEditor.tsx, app/src/components/Effects/GenerationPicker.tsx
New draggable EffectsChainEditor with stable item IDs, parameter sliders, add/clear/presets UI and DnD; GenerationPicker popover to choose past completed generations for preview.
Effects Tab
app/src/components/EffectsTab/...
app/src/components/EffectsTab/EffectsTab.tsx, app/src/components/EffectsTab/EffectsList.tsx, app/src/components/EffectsTab/EffectsDetail.tsx
New Effects tab with preset list (builtin/user), PresetCard UI, detail editor (EffectsChainEditor), preset CRUD, generation preview, save/delete flows.
Generation UI Integration
app/src/components/Generation/FloatingGenerateBox.tsx
Wires EffectsChainEditor into generation form (getEffectsChain exposure) and toggles UI to show instruction+effects mode.
History & Versions UI
app/src/components/History/HistoryTable.tsx
Adds versions panel, apply-effects dialog using EffectsChainEditor, per-version play/switch/delete actions, and applying effects workflow.
Profiles UI
app/src/components/VoiceProfiles/ProfileForm.tsx, app/src/components/VoiceProfiles/ProfileCard.tsx
Adds Default Effects editor in profile form (edit mode), tracks effectsDirty and persists via API; ProfileCard shows sparkles when a profile has a default effects_chain and adjusts layout.
Routing & Sidebar
app/src/router.tsx, app/src/components/Sidebar.tsx
New /effects route and Sidebar "Effects" tab (Wand2 icon) linking to EffectsTab.
Client API & Types
app/src/lib/api/client.ts, app/src/lib/api/types.ts
New EffectConfig and related types (presets, available effects, versions, apply requests); ApiClient methods for available effects, presets CRUD, preview/apply effects, versions lifecycle, profile effects update, and getVersionAudioUrl.
Hooks & Store
app/src/lib/hooks/useGenerationForm.ts, app/src/stores/effectsStore.ts
useGenerationForm gains optional getEffectsChain to include effects in generation requests; new Zustand useEffectsStore for selectedPresetId, workingChain, isCreatingNew.
Player & UI tweaks
app/src/components/ui/select.tsx, app/src/components/AudioPlayer/AudioPlayer.tsx
SelectItem focus styling extends to descendants; AudioPlayer handles end-of-track looping/stop more robustly.
Backend models & DB
backend/database.py, backend/models.py
Adds GenerationVersion and EffectPreset models, VoiceProfile.effects_chain column, Generation.is_favorited, migrations/backfill for versions, and seeding of builtin presets.
Backend effects engine
backend/utils/effects.py, backend/requirements.txt
New pedalboard-based effects utilities: registry, param schemas, builtin presets, validation, pedalboard builder and apply pipeline; adds pedalboard dependency.
Backend services & endpoints
backend/main.py, backend/effects.py, backend/versions.py, backend/profiles.py, backend/history.py, backend/export_import.py
New endpoints and logic: effects preview/available, presets CRUD, version lifecycle (list/apply/set-default/delete), apply effects to generations, profile effects update; generation flow updated to create versions, regenerate/retry endpoints, and serve per-version audio; history/export include versions metadata.
Backend utilities
backend/versions.py, backend/effects.py, backend/utils/effects.py
DB↔response conversion with JSON (de)serialization of effect chains, validation, pedalboard construction, audio processing, built-in preset protection, version promotion and cleanup.
Repo config
.gitignore, backend/requirements.txt
data/ directory now ignored except .gitkeep; pedalboard added to backend requirements.
Export/Import
backend/export_import.py
Export now includes multiple versions per generation and corresponding files in manifest.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant FE as Frontend
    participant API as ApiClient
    participant BE as Backend
    participant Engine as EffectsEngine
    participant DB as Database

    User->>FE: Open Effects tab / edit chain
    FE->>API: GET /effects/available
    API->>BE: GET /effects/available
    BE->>Engine: get_available_effects()
    Engine-->>BE: effect types & params
    BE-->>API: AvailableEffectsResponse
    API-->>FE: display registry
Loading
sequenceDiagram
    actor User
    participant FE as Frontend
    participant API as ApiClient
    participant BE as Backend
    participant Engine as EffectsEngine
    participant DB as Database
    User->>FE: Preview effects for generation
    FE->>API: POST /effects/preview/{generationId} (effects_chain)
    API->>BE: preview_effects endpoint
    BE->>DB: fetch generation audio (clean)
    BE->>Engine: apply_effects(clean_audio, effects_chain)
    Engine-->>BE: processed audio blob
    BE-->>API: Blob response
    API-->>FE: play preview
Loading
sequenceDiagram
    actor User
    participant FE as Frontend
    participant API as ApiClient
    participant BE as Backend
    participant Engine as EffectsEngine
    participant DB as Database
    User->>FE: Apply effects to generation
    FE->>API: POST /generations/{id}/versions/apply-effects
    API->>BE: apply_effects_to_generation
    BE->>DB: create_version(generation_id, label="clean", audio_path=clean)
    BE->>Engine: apply_effects(clean_audio, effects_chain)
    Engine-->>BE: processed audio file
    BE->>DB: create_version(generation_id, label="processed", audio_path=processed, effects_chain)
    BE->>DB: set_default (if requested) / update generation.audio_path
    BE-->>API: GenerationVersionResponse
    API-->>FE: refresh history / show new version
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

"I hopped in with a tiny drum,
Twisted knobs until echoes hum.
Presets lined up in tidy rows,
Versions saved where the audio flows.
A rabbit cheers — the sound now grows!" 🐇🎶

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main objective of the changeset: adding a complete audio post-processing effects system with backend, frontend, and integration components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/post-processing-effects
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/history.py (1)

285-300: ⚠️ Potential issue | 🟠 Major

Missing version cleanup in delete_generations_by_profile.

This function deletes generations but doesn't clean up associated GenerationVersion records or their audio files, causing orphaned data. Apply the same cleanup pattern used in delete_generation.

🐛 Proposed fix
 async def delete_generations_by_profile(
     profile_id: str,
     db: Session,
 ) -> int:
     """
     Delete all generations for a profile.
     ...
     """
+    from . import versions as versions_mod
+    
     generations = db.query(DBGeneration).filter_by(profile_id=profile_id).all()
     
     count = 0
     for generation in generations:
+        # Delete all version files and records
+        versions_mod.delete_versions_for_generation(generation.id, db)
+        
-        # Delete audio file
-        audio_path = Path(generation.audio_path)
-        if audio_path.exists():
-            audio_path.unlink()
+        # Delete main audio file (if not already removed by version cleanup)
+        if generation.audio_path:
+            audio_path = Path(generation.audio_path)
+            if audio_path.exists():
+                audio_path.unlink()
         
         # Delete from database
         db.delete(generation)
         count += 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/history.py` around lines 285 - 300, The batch deletion in
delete_generations_by_profile currently removes DBGeneration rows and their
audio files but leaves GenerationVersion records and their audio files orphaned;
update delete_generations_by_profile to mirror the cleanup in delete_generation
by iterating each GenerationVersion for every generation (use the
GenerationVersion model / generation.versions relationship or a query for
GenerationVersion where generation_id == generation.id), delete any
version.audio_path files on disk, delete the GenerationVersion rows from the
session, then proceed to delete the DBGeneration and its audio file before
committing and returning the count.
app/src/lib/hooks/useGenerationForm.ts (1)

108-121: ⚠️ Potential issue | 🟠 Major

Preserve [] as an explicit per-generation override.

Line 121 drops empty chains by converting them to undefined. In the backend that means “use the profile default”, so a user cannot override a profile’s default effects with “none” for a single generation. Send [] when getEffectsChain() returns an empty array, and reserve undefined for “no override provided”.

🛠️ Suggested change
-        effects_chain: effectsChain?.length ? effectsChain : undefined,
+        effects_chain: effectsChain !== undefined ? effectsChain : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/hooks/useGenerationForm.ts` around lines 108 - 121, The code
currently converts an empty effectsChain to undefined when calling
generation.mutateAsync, which causes the backend to apply the profile default;
instead, preserve an explicit empty array as a per-generation override. Change
the value passed for effects_chain in the generation.mutateAsync call (where
effectsChain is sourced from options.getEffectsChain()) so that if
options.getEffectsChain() returns undefined you pass undefined, but if it
returns an array (including []), you pass that array unchanged (e.g., use a
check like Array.isArray(effectsChain) ? effectsChain : undefined or
effectsChain === undefined ? undefined : effectsChain) so empty chains are sent
as [] and undefined remains the “no override provided” sentinel.
🧹 Nitpick comments (11)
backend/database.py (1)

422-446: Unused loop variable key.

The loop variable key is not used. Per static analysis hint, rename to _key to indicate intentional non-use.

♻️ Proposed fix
-        for idx, (key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
+        for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/database.py` around lines 422 - 446, In _seed_builtin_presets(), the
loop unpacks (key, preset_data) from BUILTIN_PRESETS but never uses key; update
the loop to use a clearly intended unused variable name (e.g., rename key to
_key) in the for statement (for idx, (_key, preset_data) in
enumerate(BUILTIN_PRESETS.items()):) so static analysis no longer flags an
unused variable while leaving all logic in _seed_builtin_presets,
BUILTIN_PRESETS handling, and subsequent database operations unchanged.
backend/models.py (2)

361-363: Consider if EffectsChain wrapper is needed.

This model wraps a list of EffectConfig but doesn't appear to be used in the API endpoints (they use List[EffectConfig] directly). Consider removing it if unused, or document its intended use case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/models.py` around lines 361 - 363, The EffectsChain wrapper class
(EffectsChain : BaseModel with effects: List[EffectConfig]) appears unused by
the API which accepts List[EffectConfig] directly; either remove the
EffectsChain model to avoid dead code or update usages and docs to justify it.
Search for the EffectsChain symbol and any references to effects:
List[EffectConfig] (and the EffectConfig model) and either (A) delete the
EffectsChain class and replace any remaining references with plain
List[EffectConfig], or (B) keep EffectsChain and change endpoint signatures,
pydantic validators, and docstrings to accept/return EffectsChain and add a
short docstring explaining its purpose. Ensure tests and imports are updated
accordingly.

428-433: Consider stronger typing for params dict.

The params field could be typed as Dict[str, AvailableEffectParam] for better API documentation and type safety, since you have the AvailableEffectParam model defined.

+from typing import Dict
+
 class AvailableEffect(BaseModel):
     """Description of an available effect type."""
     type: str
     label: str
     description: str
-    params: dict  # param_name -> AvailableEffectParam
+    params: Dict[str, AvailableEffectParam]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/models.py` around lines 428 - 433, The params field on the
AvailableEffect model is currently untyped dict; update it to use a typed
mapping for better docs and type safety by changing params to Dict[str,
AvailableEffectParam] on the AvailableEffect class and ensure typing.Dict (or
from typing import Dict) is imported and that AvailableEffectParam is
referenced/imported where needed.
backend/history.py (1)

23-57: Silent exception swallowing and code duplication.

  1. Lines 43-44 silently swallow exceptions (same issue as profiles.py). Consider logging.

  2. This helper duplicates logic from versions._version_response. Consider importing and reusing that function.

♻️ Proposed refactor using existing helper
 def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
     """Get versions list and active version ID for a generation."""
-    import json
+    from . import versions as versions_mod
+    
     versions_rows = (
         db.query(DBGenerationVersion)
         .filter_by(generation_id=generation_id)
         .order_by(DBGenerationVersion.created_at)
         .all()
     )
     if not versions_rows:
         return None, None

-    versions = []
+    versions = [versions_mod._version_response(v) for v in versions_rows]
     active_version_id = None
-    for v in versions_rows:
-        effects_chain = None
-        if v.effects_chain:
-            try:
-                raw = json.loads(v.effects_chain)
-                effects_chain = [EffectConfig(**e) for e in raw]
-            except Exception:
-                pass
-        versions.append(GenerationVersionResponse(
-            id=v.id,
-            generation_id=v.generation_id,
-            label=v.label,
-            audio_path=v.audio_path,
-            effects_chain=effects_chain,
-            is_default=v.is_default,
-            created_at=v.created_at,
-        ))
-        if v.is_default:
-            active_version_id = v.id
+    for v in versions:
+        if v.is_default:
+            active_version_id = v.id
+            break

     return versions, active_version_id
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/history.py` around lines 23 - 57, The helper
_get_versions_for_generation is silently swallowing JSON parse errors when
building effects_chain and is duplicating the formatting logic present in
versions._version_response; fix it by importing and calling
versions._version_response (or the specific function that builds a
GenerationVersionResponse) to construct each response instead of manually
creating GenerationVersionResponse objects, and replace the bare except with a
narrow except catching json.JSONDecodeError (or Exception if necessary) while
logging the error via the module logger (or processLogger) including context
(generation_id and DBGenerationVersion.id) so failures aren’t silent; ensure you
still set active_version_id when v.is_default.
backend/versions.py (1)

139-181: Consider consolidating commits for atomicity.

The function has multiple db.commit() calls (lines 163, 175, 179) which could leave the database in an inconsistent state if an error occurs between commits. Consider using a single commit at the end.

♻️ Proposed consolidation
     db.delete(version)
-    db.commit()
+    db.flush()  # Make deletion visible within transaction

     # If this was the default, promote the first remaining version
     if was_default:
         first = (
             db.query(DBGenerationVersion)
             .filter_by(generation_id=gen_id)
             .order_by(DBGenerationVersion.created_at)
             .first()
         )
         if first:
             first.is_default = True
-            db.commit()
             gen = db.query(DBGeneration).filter_by(id=gen_id).first()
             if gen:
                 gen.audio_path = first.audio_path
-                db.commit()
+
+    db.commit()  # Single commit for atomicity

     return True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/versions.py` around lines 139 - 181, The delete_version function
currently calls db.commit() multiple times; wrap the DB mutations in a single
transaction so all changes are atomic: determine was_default and gen_id as
before, delete the DBGenerationVersion object (db.delete(version)), if
was_default find the first remaining DBGenerationVersion (query on
DBGenerationVersion with generation_id) and set first.is_default = True and
update the related DBGeneration.audio_path to first.audio_path, then perform one
db.commit() at the end; ensure the file deletion
(Path(version.audio_path).unlink()) is performed either before the DB commit or
handled so failures don't leave the DB partially updated, and keep using the
same Session methods (delete, queries) on DBGenerationVersion and DBGeneration
to locate and update records.
backend/effects.py (1)

17-27: Consider adding error handling for corrupt data.

Unlike _profile_to_response in profiles.py, this helper doesn't catch JSON parse errors. While validation at write time should prevent corrupt data, consider adding defensive error handling for consistency and to guard against manual DB edits or migrations.

♻️ Proposed fix
 def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
     """Convert a DB preset row to a Pydantic response."""
-    effects_chain = [EffectConfig(**e) for e in json.loads(p.effects_chain)]
+    try:
+        effects_chain = [EffectConfig(**e) for e in json.loads(p.effects_chain)]
+    except Exception as e:
+        import logging
+        logging.warning(f"Failed to parse effects_chain for preset {p.id}: {e}")
+        effects_chain = []
     return EffectPresetResponse(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/effects.py` around lines 17 - 27, _wrap the JSON parse and Pydantic
construction in _preset_response so corrupt DB data is handled like
_profile_to_response: try to json.loads(p.effects_chain) and build
EffectConfig(**e) items, and catch json.JSONDecodeError, TypeError and
pydantic.ValidationError; on error log a warning that includes p.id and the raw
p.effects_chain, set effects_chain to an empty list (or a safe default) and
continue returning EffectPresetResponse. Ensure you reference EffectConfig and
EffectPresetResponse in the fix so the fallback shape matches the response
schema.
app/src/components/Effects/EffectsChainEditor.tsx (3)

251-272: Remove unused index prop from SortableEffectItem.

The index prop is declared in SortableEffectItemProps and passed to the component but never used within SortableEffectItem. This is dead code.

🧹 Proposed cleanup
 interface SortableEffectItemProps {
   id: string;
   effect: EffectConfig;
-  index: number;
   effectDef?: AvailableEffect;
   isExpanded: boolean;
   onToggleExpand: () => void;
   onRemove: () => void;
   onToggleEnabled: () => void;
   onUpdateParam: (paramName: string, paramValue: number) => void;
 }

 function SortableEffectItem({
   id,
   effect,
-  index,
   effectDef,
   isExpanded,
   ...

And update the call site at line 215:

-            <SortableEffectItem
-              key={effect._id}
-              id={effect._id}
-              effect={effect}
-              index={index}
+            <SortableEffectItem
+              key={effect._id}
+              id={effect._id}
+              effect={effect}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Effects/EffectsChainEditor.tsx` around lines 251 - 272,
The SortableEffectItem component declares and receives an unused index prop
causing dead code: remove index from the SortableEffectItemProps interface and
from the function parameter destructuring in SortableEffectItem, and then update
its call site(s) (where SortableEffectItem is instantiated) to stop passing the
index prop; keep all other props and handlers (effect, effectDef, isExpanded,
onToggleExpand, onRemove, onToggleEnabled, onUpdateParam) unchanged so behavior
remains identical.

40-43: Module-level mutable counter may cause ID collisions.

The nextId counter persists across component instances and won't reset during HMR. If multiple EffectsChainEditor instances exist or during development hot reloads, this could lead to unexpected behavior when IDs are compared across different editor instances.

♻️ Suggested improvement using crypto.randomUUID()
-let nextId = 0;
-function makeId() {
-  return `fx-${++nextId}`;
+function makeId() {
+  return `fx-${crypto.randomUUID().slice(0, 8)}`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Effects/EffectsChainEditor.tsx` around lines 40 - 43, The
module-level mutable counter nextId used by makeId() can collide across
component instances and HMR; replace it with a non-global unique-ID approach
(for example use crypto.randomUUID() or React's useId) inside the
EffectsChainEditor instance so IDs are generated per instance rather than from a
shared nextId; update all usages that call makeId() to call the new
instance-scoped ID generator (or inline crypto.randomUUID()) and remove the
module-level nextId to prevent cross-instance/HMR collisions.

63-73: ID synchronization may break with external value changes.

The idsRef approach assumes the value array only changes via this component's callbacks. If the parent modifies the array externally (e.g., reordering, inserting in the middle), the IDs won't correspond to the correct effects since they're matched by index position.

This is acceptable if the component contract guarantees value only changes through onChange, but worth documenting or adding a more robust identity mechanism if external mutations are expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Effects/EffectsChainEditor.tsx` around lines 63 - 73, IDs
drift when parent reorders/inserts effects because idsRef.current is aligned by
index; update EffectsChainEditor so IDs follow effect identity not position: in
the useMemo that builds items use a Map/WeakMap keyed by each effect object (or
a stable property like an existing id field) to reuse existing IDs (lookup
existing id via map, otherwise create with makeId and store it), and remove
entries for effects no longer present; reference: useMemo block, idsRef, makeId,
value, and the returned items mapping.
app/src/lib/api/types.ts (1)

87-91: Redundant field declarations in HistoryResponse.

HistoryResponse extends GenerationResponse which already declares versions?: GenerationVersionResponse[] and active_version_id?: string at lines 76-77. The re-declaration at lines 89-90 is redundant.

🧹 Remove redundant fields
 export interface HistoryResponse extends GenerationResponse {
   profile_name: string;
-  versions?: GenerationVersionResponse[];
-  active_version_id?: string;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/api/types.ts` around lines 87 - 91, HistoryResponse currently
re-declares fields that are already present on GenerationResponse (specifically
versions and active_version_id); remove the redundant declarations of versions?:
GenerationVersionResponse[] and active_version_id?: string from the
HistoryResponse interface so it simply extends GenerationResponse and only adds
profile_name (leave HistoryResponse, GenerationResponse, versions, and
active_version_id identifiers intact).
app/src/components/Effects/GenerationPicker.tsx (1)

20-25: Hard-coded limit of 50 may exclude recent generations.

The picker fetches only the first 50 history items. Users with extensive generation history may not see all their completed generations. Consider either increasing the limit or adding pagination/load-more functionality.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Effects/GenerationPicker.tsx` around lines 20 - 25, The
history fetch uses a hard-coded limit of 50 which can omit recent completed
generations; update the useHistory call (where useHistory is invoked) and the
completedGenerations logic to support more results by either increasing/removing
the static limit or implementing paging/load-more: modify the useHistory
invocation to accept a configurable limit or cursor-based pagination, add state
for page/cursor and a "load more" handler in the GenerationPicker component, and
ensure completedGenerations (the useMemo that filters historyData.items)
concatenates new pages rather than replacing them so all completed generations
are visible.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/EffectsTab/EffectsDetail.tsx`:
- Around line 183-223: The UI shows editable fields for existing custom presets
but lacks a Save action; add a Save button in the same control group that
appears when !isBuiltIn && !isCreatingNew and wire it to call the existing
updateEffectPreset(...) function (or create a new handler like
handleUpdatePreset that calls updateEffectPreset), passing the current preset id
and workingChain; ensure the button mirrors disabled logic used elsewhere
(disabled when saving || workingChain.length === 0), shows saving state (e.g.,
'Saving...' when saving), and avoids affecting built-ins (keep current Delete
button for non-built-in). This provides a persistent update path for edits to
custom presets.

In `@app/src/components/Generation/FloatingGenerateBox.tsx`:
- Around line 395-398: The click handler uses the pre-toggle value of
isEffectsMode causing the inverted behavior; compute the new state first and
base the side-effect on that (e.g., derive newEffects = !isEffectsMode, call
setIsEffectsMode(newEffects), and if newEffects then call
setIsInstructMode(false)), or use the functional setter form
setIsEffectsMode(prev => { const next = !prev; if (next)
setIsInstructMode(false); return next; }) to ensure instruct mode is disabled
only when effects mode is being enabled; update the onClick in
FloatingGenerateBox to reference isEffectsMode, setIsEffectsMode, and
setIsInstructMode accordingly.

In `@app/src/components/History/HistoryTable.tsx`:
- Around line 348-381: The interactive container uses conditional role/tabIndex
but always wires onMouseDown/onKeyDown, so when isPlayable is false it still has
interactive handlers without proper semantics; change the markup to render a
semantic <button> (or a focusable element with role="button" permanently) only
when isPlayable is true, and render a non-interactive <div> otherwise, or
alternately keep a single element but always set role="button" and tabIndex and
only attach handlePlay in onMouseDown/onKeyDown when isPlayable; update the code
around isPlayable, onMouseDown/onKeyDown, and handlePlay usage and ensure
aria-label remains accurate for both states.

In `@app/src/components/VoiceProfiles/ProfileForm.tsx`:
- Around line 445-460: The effects save catch in ProfileForm.tsx currently only
shows a toast then allows execution to continue into the success/close path;
modify the handler around updateProfileEffects (the block using
apiClient.updateProfileEffects and the effectsDirty flag) to prevent falling
through on error by either re-throwing fxError or returning early from the
enclosing save/submit function so the modal remains open and the effects chain
is not lost; ensure any success toast/close logic that follows (the code that
closes the dialog and shows the "voice updated" toast) only runs when
updateProfileEffects completes without throwing.

In `@app/src/stores/effectsStore.ts`:
- Around line 24-25: The setter setIsCreatingNew currently assigns
selectedPresetId using the no-op ternary v ? null : null; update
setIsCreatingNew so it clears selectedPresetId only when entering create mode (v
=== true) and preserves the existing selectedPresetId when v is false by using
the state updater form (referencing setIsCreatingNew, isCreatingNew,
selectedPresetId and set) so the new state is set({ isCreatingNew: v,
selectedPresetId: v ? null : state.selectedPresetId }).

In `@backend/effects.py`:
- Around line 52-71: The create_preset function can raise an unhandled
IntegrityError when inserting a DBEffectPreset with a duplicate name; update
create_preset to check for existing presets by name before adding (e.g., query
DBEffectPreset where name == data.name) and raise a ValueError or a custom
exception when a duplicate exists, or alternatively catch
sqlalchemy.exc.IntegrityError around db.commit() in create_preset (or ensure
create_effect_preset handles IntegrityError) and translate it into a clear error
for the endpoint; reference DBEffectPreset, create_preset, db.commit, and the
create_effect_preset endpoint when implementing the fix.

In `@backend/main.py`:
- Around line 1588-1591: The current lookup uses the mutable display label
(v.label == "clean") to choose the source version; change that to pick the
immutable original/unprocessed version instead by using a stable field such as
v.effects_chain is None or a dedicated v.kind == "original" (where available)
when calling versions_mod.list_versions(generation_id, db) and computing
clean_version; update the same logic locations that reference v.label == "clean"
(e.g., the clean_version selection and any subsequent source-path resolution
using clean_version.audio_path or gen.audio_path) to use the immutable property
(effects_chain or kind) so processed versions named "clean" cannot be mistakenly
treated as the unprocessed baseline.
- Around line 828-857: The code currently calls versions_mod.create_version(...)
for the "clean" version before post-processing; if apply_effects, save_audio, or
the second create_version fails you leave inconsistent rows/files. Change the
flow so you do not call versions_mod.create_version (the create_version function
in backend/versions.py) until after validate_effects_chain, apply_effects,
save_audio and any processed-version creation have completed successfully (i.e.,
once final_audio_path and all files exist), or alternatively wrap the entire
post-processing block in a try/except and roll back any created versions/files
on error by deleting the created audio files and removing the earlier
create_version entry via the versions_mod API; ensure all references to
apply_effects, validate_effects_chain, save_audio and
versions_mod.create_version are updated accordingly.
- Around line 1595-1602: Synchronous audio I/O and CPU-bound processing (calls
to load_audio, apply_effects, and sf.write) are being executed directly inside
async endpoints (preview_effects, apply_effects_to_generation, transcribe_audio)
which will block the event loop; wrap those blocking calls in
asyncio.to_thread() (or run them in a dedicated threadpool) so the
decode/process/encode/write work runs off the event loop and the async handlers
remain non-blocking, replacing direct calls to load_audio, apply_effects, and
sf.write with await asyncio.to_thread(...) invocations for each blocking helper.

In `@backend/profiles.py`:
- Around line 32-50: In _profile_to_response, avoid silently swallowing errors
when deserializing profile.effects_chain: catch the exception around
_json.loads(...) / EffectConfig(...) in the try block and log a warning
including the exception details and identifying info (e.g., profile.id or
profile.name) so malformed JSON or validation errors are visible; then continue
returning VoiceProfileResponse with effects_chain set to None (as current) to
preserve behavior while surfacing the root cause for debugging.

In `@backend/utils/effects.py`:
- Around line 25-35: The hard import of pedalboard in effects.py causes startup
crashes when pedalboard is missing; modify the module so pedalboard is imported
lazily inside functions that actually need it (e.g., build_pedalboard()) and add
a guarded fallback: try importing pedalboard inside build_pedalboard() (or an
internal resolver), on ImportError log a warning and return a no-op/disabled
effects state so the app can start; change EFFECT_REGISTRY to store effect class
names/identifiers instead of direct class objects and resolve those names to
actual pedalboard classes at runtime in build_pedalboard() (used by
_seed_builtin_presets()/BUILTIN_PRESETS) so database initialization no longer
triggers a hard import.

---

Outside diff comments:
In `@app/src/lib/hooks/useGenerationForm.ts`:
- Around line 108-121: The code currently converts an empty effectsChain to
undefined when calling generation.mutateAsync, which causes the backend to apply
the profile default; instead, preserve an explicit empty array as a
per-generation override. Change the value passed for effects_chain in the
generation.mutateAsync call (where effectsChain is sourced from
options.getEffectsChain()) so that if options.getEffectsChain() returns
undefined you pass undefined, but if it returns an array (including []), you
pass that array unchanged (e.g., use a check like Array.isArray(effectsChain) ?
effectsChain : undefined or effectsChain === undefined ? undefined :
effectsChain) so empty chains are sent as [] and undefined remains the “no
override provided” sentinel.

In `@backend/history.py`:
- Around line 285-300: The batch deletion in delete_generations_by_profile
currently removes DBGeneration rows and their audio files but leaves
GenerationVersion records and their audio files orphaned; update
delete_generations_by_profile to mirror the cleanup in delete_generation by
iterating each GenerationVersion for every generation (use the GenerationVersion
model / generation.versions relationship or a query for GenerationVersion where
generation_id == generation.id), delete any version.audio_path files on disk,
delete the GenerationVersion rows from the session, then proceed to delete the
DBGeneration and its audio file before committing and returning the count.

---

Nitpick comments:
In `@app/src/components/Effects/EffectsChainEditor.tsx`:
- Around line 251-272: The SortableEffectItem component declares and receives an
unused index prop causing dead code: remove index from the
SortableEffectItemProps interface and from the function parameter destructuring
in SortableEffectItem, and then update its call site(s) (where
SortableEffectItem is instantiated) to stop passing the index prop; keep all
other props and handlers (effect, effectDef, isExpanded, onToggleExpand,
onRemove, onToggleEnabled, onUpdateParam) unchanged so behavior remains
identical.
- Around line 40-43: The module-level mutable counter nextId used by makeId()
can collide across component instances and HMR; replace it with a non-global
unique-ID approach (for example use crypto.randomUUID() or React's useId) inside
the EffectsChainEditor instance so IDs are generated per instance rather than
from a shared nextId; update all usages that call makeId() to call the new
instance-scoped ID generator (or inline crypto.randomUUID()) and remove the
module-level nextId to prevent cross-instance/HMR collisions.
- Around line 63-73: IDs drift when parent reorders/inserts effects because
idsRef.current is aligned by index; update EffectsChainEditor so IDs follow
effect identity not position: in the useMemo that builds items use a Map/WeakMap
keyed by each effect object (or a stable property like an existing id field) to
reuse existing IDs (lookup existing id via map, otherwise create with makeId and
store it), and remove entries for effects no longer present; reference: useMemo
block, idsRef, makeId, value, and the returned items mapping.

In `@app/src/components/Effects/GenerationPicker.tsx`:
- Around line 20-25: The history fetch uses a hard-coded limit of 50 which can
omit recent completed generations; update the useHistory call (where useHistory
is invoked) and the completedGenerations logic to support more results by either
increasing/removing the static limit or implementing paging/load-more: modify
the useHistory invocation to accept a configurable limit or cursor-based
pagination, add state for page/cursor and a "load more" handler in the
GenerationPicker component, and ensure completedGenerations (the useMemo that
filters historyData.items) concatenates new pages rather than replacing them so
all completed generations are visible.

In `@app/src/lib/api/types.ts`:
- Around line 87-91: HistoryResponse currently re-declares fields that are
already present on GenerationResponse (specifically versions and
active_version_id); remove the redundant declarations of versions?:
GenerationVersionResponse[] and active_version_id?: string from the
HistoryResponse interface so it simply extends GenerationResponse and only adds
profile_name (leave HistoryResponse, GenerationResponse, versions, and
active_version_id identifiers intact).

In `@backend/database.py`:
- Around line 422-446: In _seed_builtin_presets(), the loop unpacks (key,
preset_data) from BUILTIN_PRESETS but never uses key; update the loop to use a
clearly intended unused variable name (e.g., rename key to _key) in the for
statement (for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):)
so static analysis no longer flags an unused variable while leaving all logic in
_seed_builtin_presets, BUILTIN_PRESETS handling, and subsequent database
operations unchanged.

In `@backend/effects.py`:
- Around line 17-27: _wrap the JSON parse and Pydantic construction in
_preset_response so corrupt DB data is handled like _profile_to_response: try to
json.loads(p.effects_chain) and build EffectConfig(**e) items, and catch
json.JSONDecodeError, TypeError and pydantic.ValidationError; on error log a
warning that includes p.id and the raw p.effects_chain, set effects_chain to an
empty list (or a safe default) and continue returning EffectPresetResponse.
Ensure you reference EffectConfig and EffectPresetResponse in the fix so the
fallback shape matches the response schema.

In `@backend/history.py`:
- Around line 23-57: The helper _get_versions_for_generation is silently
swallowing JSON parse errors when building effects_chain and is duplicating the
formatting logic present in versions._version_response; fix it by importing and
calling versions._version_response (or the specific function that builds a
GenerationVersionResponse) to construct each response instead of manually
creating GenerationVersionResponse objects, and replace the bare except with a
narrow except catching json.JSONDecodeError (or Exception if necessary) while
logging the error via the module logger (or processLogger) including context
(generation_id and DBGenerationVersion.id) so failures aren’t silent; ensure you
still set active_version_id when v.is_default.

In `@backend/models.py`:
- Around line 361-363: The EffectsChain wrapper class (EffectsChain : BaseModel
with effects: List[EffectConfig]) appears unused by the API which accepts
List[EffectConfig] directly; either remove the EffectsChain model to avoid dead
code or update usages and docs to justify it. Search for the EffectsChain symbol
and any references to effects: List[EffectConfig] (and the EffectConfig model)
and either (A) delete the EffectsChain class and replace any remaining
references with plain List[EffectConfig], or (B) keep EffectsChain and change
endpoint signatures, pydantic validators, and docstrings to accept/return
EffectsChain and add a short docstring explaining its purpose. Ensure tests and
imports are updated accordingly.
- Around line 428-433: The params field on the AvailableEffect model is
currently untyped dict; update it to use a typed mapping for better docs and
type safety by changing params to Dict[str, AvailableEffectParam] on the
AvailableEffect class and ensure typing.Dict (or from typing import Dict) is
imported and that AvailableEffectParam is referenced/imported where needed.

In `@backend/versions.py`:
- Around line 139-181: The delete_version function currently calls db.commit()
multiple times; wrap the DB mutations in a single transaction so all changes are
atomic: determine was_default and gen_id as before, delete the
DBGenerationVersion object (db.delete(version)), if was_default find the first
remaining DBGenerationVersion (query on DBGenerationVersion with generation_id)
and set first.is_default = True and update the related DBGeneration.audio_path
to first.audio_path, then perform one db.commit() at the end; ensure the file
deletion (Path(version.audio_path).unlink()) is performed either before the DB
commit or handled so failures don't leave the DB partially updated, and keep
using the same Session methods (delete, queries) on DBGenerationVersion and
DBGeneration to locate and update records.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 251cf9b1-349c-4126-b1ea-790aee6cea09

📥 Commits

Reviewing files that changed from the base of the PR and between 7cbf5a1 and 638820c.

📒 Files selected for processing (24)
  • app/src/components/Effects/EffectsChainEditor.tsx
  • app/src/components/Effects/GenerationPicker.tsx
  • app/src/components/EffectsTab/EffectsDetail.tsx
  • app/src/components/EffectsTab/EffectsList.tsx
  • app/src/components/EffectsTab/EffectsTab.tsx
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/History/HistoryTable.tsx
  • app/src/components/Sidebar.tsx
  • app/src/components/VoiceProfiles/ProfileForm.tsx
  • app/src/components/ui/select.tsx
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • app/src/lib/hooks/useGenerationForm.ts
  • app/src/router.tsx
  • app/src/stores/effectsStore.ts
  • backend/database.py
  • backend/effects.py
  • backend/history.py
  • backend/main.py
  • backend/models.py
  • backend/profiles.py
  • backend/requirements.txt
  • backend/utils/effects.py
  • backend/versions.py

Comment on lines +828 to +857
# Create clean version entry
versions_mod.create_version(
generation_id=generation_id,
label="clean",
audio_path=str(clean_audio_path),
db=bg_db,
effects_chain=None,
is_default=not has_effects,
)

# Apply effects and create processed version if configured
final_audio_path = str(clean_audio_path)
if has_effects:
from .utils.effects import apply_effects, validate_effects_chain
error_msg = validate_effects_chain(effects_chain_config)
if error_msg:
print(f"Warning: invalid effects chain, skipping: {error_msg}")
else:
processed_audio = apply_effects(audio, sample_rate, effects_chain_config)
processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
save_audio(processed_audio, str(processed_path), sample_rate)
final_audio_path = str(processed_path)
versions_mod.create_version(
generation_id=generation_id,
label="processed",
audio_path=str(processed_path),
db=bg_db,
effects_chain=effects_chain_config,
is_default=True,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defer version persistence until the whole post-processing run succeeds.

backend/versions.py:create_version() commits immediately. Line 829 writes the clean version before the rest of the pipeline is known to be successful, so any later exception from processing, saving, or the second version insert leaves version rows/files behind while the generation is marked failed. Create the version records only after all output files are ready, or explicitly clean them up in the failure path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 828 - 857, The code currently calls
versions_mod.create_version(...) for the "clean" version before post-processing;
if apply_effects, save_audio, or the second create_version fails you leave
inconsistent rows/files. Change the flow so you do not call
versions_mod.create_version (the create_version function in backend/versions.py)
until after validate_effects_chain, apply_effects, save_audio and any
processed-version creation have completed successfully (i.e., once
final_audio_path and all files exist), or alternatively wrap the entire
post-processing block in a try/except and roll back any created versions/files
on error by deleting the created audio files and removing the earlier
create_version entry via the versions_mod API; ensure all references to
apply_effects, validate_effects_chain, save_audio and
versions_mod.create_version are updated accordingly.

…nc 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
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/components/Generation/FloatingGenerateBox.tsx (1)

56-65: ⚠️ Potential issue | 🟠 Major

Effects toggle does not control whether effects are applied.

On Line 64, payload inclusion ignores isEffectsMode, so a non-empty chain is still submitted after toggling effects off.

🛠️ Suggested fix
   const { form, handleSubmit, isPending } = useGenerationForm({
@@
-    getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
+    getEffectsChain: () =>
+      isEffectsMode && effectsChain.length > 0 ? effectsChain : undefined,
   });

Also applies to: 395-399

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Generation/FloatingGenerateBox.tsx` around lines 56 - 65,
The getEffectsChain supplied to useGenerationForm currently returns effectsChain
regardless of isEffectsMode, so effects still get applied when toggled off;
update the getEffectsChain callback (used in useGenerationForm and the other
occurrence around the block handling payload creation at lines ~395-399) to
return effectsChain only when isEffectsMode is true and effectsChain.length > 0
(otherwise return undefined) so toggling off prevents submitting the chain;
reference the symbols isEffectsMode, getEffectsChain, effectsChain, and
useGenerationForm to locate and change both places.
♻️ Duplicate comments (1)
backend/main.py (1)

828-857: ⚠️ Potential issue | 🟠 Major

Version persistence can still be left in a partial state on post-processing failure.

Line 829 persists the clean version before the full effects pipeline is known to succeed. If later processing/save/version creation fails, rows/files can remain while the generation is marked failed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 828 - 857, The clean version is being persisted
via versions_mod.create_version before the effects pipeline runs, so if
apply_effects/save_audio or the processed versions_mod.create_version fails the
DB/files are left in a partial state; fix by either (A) postponing the initial
versions_mod.create_version(...) call for the "clean" label until after
validate_effects_chain(...) and apply_effects(...) complete successfully (so
both "clean" and "processed" are created together), or (B) wrap the effects
processing and subsequent versions_mod.create_version/save_audio calls in a
try/except and on any exception delete the previously created clean version and
clean file (use versions_mod.delete_version or equivalent and remove the saved
audio path) so the generation is not left partially persisted; update code
around versions_mod.create_version, validate_effects_chain, apply_effects,
save_audio to implement one of these approaches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/effects.py`:
- Around line 92-107: In update_preset(), handle unique-name collisions the same
way create_preset() does: wrap the db.commit()/db.refresh(preset) block in a
try/except that catches sqlalchemy.exc.IntegrityError, calls db.rollback(), and
raises a ValueError with a clear "duplicate preset name" message so the endpoint
maps it to a 400; import IntegrityError where needed and keep raising ValueError
(not raw IntegrityError) to preserve existing error mapping for
_preset_response/update path.

In `@backend/main.py`:
- Around line 769-773: The try/except around
_json.loads(profile_obj.effects_chain) currently swallows all errors; change the
except to capture the exception (e.g., except Exception as e) and log a clear
error including the exception/traceback and profile identity (use profile_obj.id
or profile_obj.name) before applying a safe fallback for effects_chain_config
(e.g., None or an empty dict) so malformed stored defaults are visible in logs
and the application remains stable; update the block around
effects_chain_config/_json.loads(profile_obj.effects_chain) accordingly.
- Around line 1600-1602: The sf.write call is passing "WAV" positionally (going
to subtype) which is wrong for a BytesIO target; update the call in
backend/main.py (the call that uses sf.write with buf, processed, sample_rate)
to pass keyword args and set format explicitly (e.g., await
asyncio.to_thread(sf.write, buf, processed, sample_rate, format="WAV") or use
parameter names like sf.write(file=buf, data=processed, samplerate=sample_rate,
format="WAV")); if you also need a subtype, provide it via the subtype=...
keyword rather than a positional argument.

---

Outside diff comments:
In `@app/src/components/Generation/FloatingGenerateBox.tsx`:
- Around line 56-65: The getEffectsChain supplied to useGenerationForm currently
returns effectsChain regardless of isEffectsMode, so effects still get applied
when toggled off; update the getEffectsChain callback (used in useGenerationForm
and the other occurrence around the block handling payload creation at lines
~395-399) to return effectsChain only when isEffectsMode is true and
effectsChain.length > 0 (otherwise return undefined) so toggling off prevents
submitting the chain; reference the symbols isEffectsMode, getEffectsChain,
effectsChain, and useGenerationForm to locate and change both places.

---

Duplicate comments:
In `@backend/main.py`:
- Around line 828-857: The clean version is being persisted via
versions_mod.create_version before the effects pipeline runs, so if
apply_effects/save_audio or the processed versions_mod.create_version fails the
DB/files are left in a partial state; fix by either (A) postponing the initial
versions_mod.create_version(...) call for the "clean" label until after
validate_effects_chain(...) and apply_effects(...) complete successfully (so
both "clean" and "processed" are created together), or (B) wrap the effects
processing and subsequent versions_mod.create_version/save_audio calls in a
try/except and on any exception delete the previously created clean version and
clean file (use versions_mod.delete_version or equivalent and remove the saved
audio path) so the generation is not left partially persisted; update code
around versions_mod.create_version, validate_effects_chain, apply_effects,
save_audio to implement one of these approaches.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66695b2b-a93c-4907-a1e2-45064cec3a30

📥 Commits

Reviewing files that changed from the base of the PR and between 638820c and 3d922ec.

📒 Files selected for processing (9)
  • app/src/components/EffectsTab/EffectsDetail.tsx
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/VoiceProfiles/ProfileForm.tsx
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • app/src/stores/effectsStore.ts
  • backend/effects.py
  • backend/main.py
  • backend/profiles.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/src/components/EffectsTab/EffectsDetail.tsx
  • app/src/stores/effectsStore.ts
  • app/src/lib/api/client.ts

Comment on lines +92 to +107
if data.name is not None:
preset.name = data.name
if data.description is not None:
preset.description = data.description
if data.effects_chain is not None:
from .utils.effects import validate_effects_chain

chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise ValueError(error)
preset.effects_chain = json.dumps(chain_dicts)

db.commit()
db.refresh(preset)
return _preset_response(preset)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify update path lacks duplicate handling and endpoint only catches ValueError
rg -n -C4 "def update_preset|preset.name = data.name|db.commit|except IntegrityError" backend/effects.py
rg -n -C6 "async def update_effect_preset|except ValueError" backend/main.py
rg -n -C8 "class EffectPreset|name = Column\\(String.*unique=True" backend/database.py

Repository: jamiepine/voicebox

Length of output: 11912


Duplicate-name rename path can return 500.

update_preset() commits name changes without handling unique-name collisions, while the endpoint only maps ValueError to 400. Renaming a preset to an existing name will raise an unhandled IntegrityError, surfacing as a 500 error instead of 400.

The asymmetry with create_preset(), which guards against duplicates and converts IntegrityError to ValueError, creates a vulnerability in the update path.

🛠️ Suggested fix
 def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
@@
-    if data.name is not None:
+    if data.name is not None:
+        existing = db.query(DBEffectPreset).filter(
+            DBEffectPreset.name == data.name,
+            DBEffectPreset.id != preset_id,
+        ).first()
+        if existing:
+            raise ValueError(f"A preset named '{data.name}' already exists")
         preset.name = data.name
@@
-    db.commit()
+    try:
+        db.commit()
+    except IntegrityError as e:
+        db.rollback()
+        raise ValueError("Preset name already exists") from e
     db.refresh(preset)
     return _preset_response(preset)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/effects.py` around lines 92 - 107, In update_preset(), handle
unique-name collisions the same way create_preset() does: wrap the
db.commit()/db.refresh(preset) block in a try/except that catches
sqlalchemy.exc.IntegrityError, calls db.rollback(), and raises a ValueError with
a clear "duplicate preset name" message so the endpoint maps it to a 400; import
IntegrityError where needed and keep raising ValueError (not raw IntegrityError)
to preserve existing error mapping for _preset_response/update path.

Comment on lines +769 to +773
try:
effects_chain_config = _json.loads(profile_obj.effects_chain)
except Exception:
pass

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t silently swallow profile default effects parse failures.

Lines 771-772 currently suppress deserialization errors; this hides malformed stored defaults and makes operational debugging harder.

🛠️ Suggested fix
-        if profile_obj and profile_obj.effects_chain:
-            try:
-                effects_chain_config = _json.loads(profile_obj.effects_chain)
-            except Exception:
-                pass
+        if profile_obj and profile_obj.effects_chain:
+            try:
+                effects_chain_config = _json.loads(profile_obj.effects_chain)
+            except Exception as e:
+                import logging
+                logging.warning(
+                    "Failed to parse profile effects_chain for profile %s: %s",
+                    data.profile_id,
+                    e,
+                )
🧰 Tools
🪛 Ruff (0.15.5)

[error] 771-772: try-except-pass detected, consider logging the exception

(S110)


[warning] 771-771: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 769 - 773, The try/except around
_json.loads(profile_obj.effects_chain) currently swallows all errors; change the
except to capture the exception (e.g., except Exception as e) and log a clear
error including the exception/traceback and profile identity (use profile_obj.id
or profile_obj.name) before applying a safe fallback for effects_chain_config
(e.g., None or an empty dict) so malformed stored defaults are visible in logs
and the application remains stable; update the block around
effects_chain_config/_json.loads(profile_obj.effects_chain) accordingly.

Reload the player with the version-specific audio URL when effects are
applied to the currently playing generation. Also consolidate the
instruct/effects buttons into a single button with the effects editor
shown inline when instruct mode is open.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/components/Generation/FloatingGenerateBox.tsx (2)

366-374: ⚠️ Potential issue | 🟡 Minor

ARIA label no longer matches the control’s combined behavior.

The tooltip now says “instructions & effects” (Line 373), but the button aria-label still says instructions only. This leaves screen-reader users without the updated intent.

Suggested fix
-                          aria-label={
-                            isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
-                          }
+                          aria-label={
+                            isInstructMode
+                              ? 'Fine tune instructions and effects, on'
+                              : 'Fine tune instructions and effects'
+                          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Generation/FloatingGenerateBox.tsx` around lines 366 -
374, Update the Button's aria-label to reflect the control's combined purpose
(instructions and effects) so screen readers match the visible tooltip; modify
the aria-label expression on the Button (where isInstructMode is used) to
include both "instructions & effects" in both states (e.g., toggling between
"Fine tune instructions & effects, on" and "Fine tune instructions & effects")
so the Button component and the tooltip span remain consistent.

344-365: ⚠️ Potential issue | 🟠 Major

Effects can remain active but become hidden/non-editable after engine switch.

At Line 344, the toggle/editor UI is only rendered for qwen, but Line 63 still submits effectsChain whenever it is non-empty. If a user configures effects on Qwen and then switches engines, effects may still apply with no visible control to edit/disable.

Suggested minimal fix
-                  {isExpanded && form.watch('engine') === 'qwen' && (
+                  {isExpanded &&
+                    (form.watch('engine') === 'qwen' || effectsChain.length > 0) && (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Generation/FloatingGenerateBox.tsx` around lines 344 -
365, The effectsChain can remain active but become uneditable when the user
switches off 'qwen'; fix by clearing or disabling effects when the engine
changes: add logic that watches form.watch('engine') and if it becomes !==
'qwen' and effectsChain is non-empty, call setEffectsChain([]) (or otherwise
prevent effects from being submitted) so effects are not applied invisibly;
alternatively, render the effects toggle/UI for non-'qwen' engines by moving the
Button block out of the form.watch('engine') === 'qwen' condition so the user
can edit/disable effects — reference effectsChain, setEffectsChain,
form.watch('engine'), isExpanded and the Button/isInstructMode toggle.
🧹 Nitpick comments (2)
app/src/components/History/HistoryTable.tsx (2)

543-548: UX: Clicking a version both plays and switches default simultaneously.

When the user clicks a non-default version, it plays the audio and sets it as default in a single action. Consider whether users might want to preview a version without committing to it as the new default—if so, a separate "Set as default" action might be clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 543 - 548, The
current onClick handler both calls handlePlayVersion(...) and, for non-default
versions, handleSwitchVersion(...), causing a click to both preview and commit;
change the onClick for the version row to only call handlePlayVersion(gen.id,
v.id, gen.text, gen.profile_id) and remove the conditional handleSwitchVersion
call, and instead add a separate UI control (e.g., a "Set as default" button or
icon rendered when v.is_default is false) that calls handleSwitchVersion(gen.id,
v.id) when explicitly activated; update any accessible labels and tests that
reference handlePlayVersion and handleSwitchVersion to reflect the split
actions.

445-449: Add role="presentation" to clarify non-interactive container intent.

Static analysis flags this div for having onClick without keyboard handlers. The div exists solely to stop event propagation—child buttons handle actual interactions and are keyboard-accessible. Adding role="presentation" clarifies this is a layout container.

♻️ Suggested fix
                     {/* Far right - Actions */}
                     <div
+                      role="presentation"
                       className="shrink-0 flex flex-col justify-center items-center gap-1"
                       onMouseDown={(e) => e.stopPropagation()}
                       onClick={(e) => e.stopPropagation()}
                     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 445 - 449, The div
in HistoryTable.tsx with className "shrink-0 flex flex-col justify-center
items-center gap-1" that uses onMouseDown and onClick solely to stop propagation
should be marked as non-interactive by adding role="presentation" to the
element; update the JSX for that container (the element using onMouseDown={(e)
=> e.stopPropagation()} and onClick={(e) => e.stopPropagation()}) to include
role="presentation" so static analysis knows it's a layout-only container and
not missing keyboard handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/src/components/Generation/FloatingGenerateBox.tsx`:
- Around line 366-374: Update the Button's aria-label to reflect the control's
combined purpose (instructions and effects) so screen readers match the visible
tooltip; modify the aria-label expression on the Button (where isInstructMode is
used) to include both "instructions & effects" in both states (e.g., toggling
between "Fine tune instructions & effects, on" and "Fine tune instructions &
effects") so the Button component and the tooltip span remain consistent.
- Around line 344-365: The effectsChain can remain active but become uneditable
when the user switches off 'qwen'; fix by clearing or disabling effects when the
engine changes: add logic that watches form.watch('engine') and if it becomes
!== 'qwen' and effectsChain is non-empty, call setEffectsChain([]) (or otherwise
prevent effects from being submitted) so effects are not applied invisibly;
alternatively, render the effects toggle/UI for non-'qwen' engines by moving the
Button block out of the form.watch('engine') === 'qwen' condition so the user
can edit/disable effects — reference effectsChain, setEffectsChain,
form.watch('engine'), isExpanded and the Button/isInstructMode toggle.

---

Nitpick comments:
In `@app/src/components/History/HistoryTable.tsx`:
- Around line 543-548: The current onClick handler both calls
handlePlayVersion(...) and, for non-default versions, handleSwitchVersion(...),
causing a click to both preview and commit; change the onClick for the version
row to only call handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id) and
remove the conditional handleSwitchVersion call, and instead add a separate UI
control (e.g., a "Set as default" button or icon rendered when v.is_default is
false) that calls handleSwitchVersion(gen.id, v.id) when explicitly activated;
update any accessible labels and tests that reference handlePlayVersion and
handleSwitchVersion to reflect the split actions.
- Around line 445-449: The div in HistoryTable.tsx with className "shrink-0 flex
flex-col justify-center items-center gap-1" that uses onMouseDown and onClick
solely to stop propagation should be marked as non-interactive by adding
role="presentation" to the element; update the JSX for that container (the
element using onMouseDown={(e) => e.stopPropagation()} and onClick={(e) =>
e.stopPropagation()}) to include role="presentation" so static analysis knows
it's a layout-only container and not missing keyboard handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2db99732-33f6-4c02-8a49-b01ab99571c5

📥 Commits

Reviewing files that changed from the base of the PR and between 3d922ec and 25134b4.

📒 Files selected for processing (2)
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/History/HistoryTable.tsx

- Add /generate/{id}/regenerate endpoint that creates a new take version
- Wire regenerate into history dropdown with SSE progress + autoplay
- Add normalize_audio to regenerate path
- Remove duplicate Regenerate menu item
- Show disabled ellipsis menu during generation instead of hiding it
- Fix sf.write format kwarg broken by asyncio.to_thread migration
- Rename clean version label to 'original', effects to 'version-N'
- Show effects chain names in version list instead of 'N fx'
- Include all versions in export package
- Clean up version panel padding
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (3)
backend/main.py (3)

824-857: ⚠️ Potential issue | 🟠 Major

Persist version rows only after effects processing outcome is known.

Line 829 commits the clean version before validation/processing. If downstream work fails (or validation fails), you can end with inconsistent version metadata (including no default version when has_effects=True but processing is skipped) and orphan artifacts.

🔧 Suggested restructuring
-            has_effects = effects_chain_config and any(
+            has_effects = bool(effects_chain_config and any(
                 e.get("enabled", True) for e in effects_chain_config
-            )
+            ))
 
-            # Create clean version entry
-            versions_mod.create_version(
-                generation_id=generation_id,
-                label="original",
-                audio_path=str(clean_audio_path),
-                db=bg_db,
-                effects_chain=None,
-                is_default=not has_effects,
-            )
-
-            # Apply effects and create processed version if configured
             final_audio_path = str(clean_audio_path)
+            processed_path = None
             if has_effects:
                 from .utils.effects import apply_effects, validate_effects_chain
                 error_msg = validate_effects_chain(effects_chain_config)
                 if error_msg:
                     print(f"Warning: invalid effects chain, skipping: {error_msg}")
                 else:
                     processed_audio = apply_effects(audio, sample_rate, effects_chain_config)
                     processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
                     save_audio(processed_audio, str(processed_path), sample_rate)
                     final_audio_path = str(processed_path)
+
+            # Persist versions after processing branch settles.
+            versions_mod.create_version(
+                generation_id=generation_id,
+                label="original",
+                audio_path=str(clean_audio_path),
+                db=bg_db,
+                effects_chain=None,
+                is_default=processed_path is None,
+            )
+            if processed_path is not None:
+                versions_mod.create_version(
+                    generation_id=generation_id,
+                    label="version-2",
+                    audio_path=str(processed_path),
+                    db=bg_db,
+                    effects_chain=effects_chain_config,
+                    is_default=True,
+                )
-                    versions_mod.create_version(
-                        generation_id=generation_id,
-                        label="version-2",
-                        audio_path=str(processed_path),
-                        db=bg_db,
-                        effects_chain=effects_chain_config,
-                        is_default=True,
-                    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 824 - 857, The current flow calls
versions_mod.create_version for the "original" before validating/applying
effects, which can leave inconsistent metadata if validate_effects_chain or
apply_effects fails; change the logic so effects are validated first (use
validate_effects_chain) and only after a successful validation and processing
(apply_effects, save_audio) create the appropriate version rows: if effects are
invalid or skipped create a single "original" version with is_default=True,
otherwise create the "original" version with is_default=False and then create
the processed version (label "version-2") with is_default=True; ensure you also
avoid writing the processed file unless apply_effects succeeds to prevent orphan
artifacts and update final_audio_path accordingly.

769-772: ⚠️ Potential issue | 🟡 Minor

Log malformed profile default effects instead of silently ignoring parse failures.

Line 771 currently swallows bad stored JSON and hides data corruption/ops signals.

🔧 Suggested fix
         if profile_obj and profile_obj.effects_chain:
             try:
                 effects_chain_config = _json.loads(profile_obj.effects_chain)
-            except Exception:
-                pass
+            except Exception as e:
+                import logging
+                logging.getLogger(__name__).warning(
+                    "Failed to parse profile effects_chain for profile %s: %s",
+                    profile_obj.id,
+                    e,
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 769 - 772, Replace the silent except-pass
around _json.loads(profile_obj.effects_chain) so malformed stored JSON is
logged; catch the exception as e (e.g., except Exception as e:) and call the
project logger (or logging.exception) to record the error, include identifying
info such as profile_obj.id or profile_obj.name and the raw
profile_obj.effects_chain value plus the exception details, then allow
effects_chain_config to remain None or a safe default.

1857-1871: ⚠️ Potential issue | 🟠 Major

Remove processed audio file if version insert fails.

The file is written first (Line 1859), then DB insert is attempted (Line 1864). On DB failure, the endpoint leaks an orphaned file.

🔧 Suggested fix
     version_id = str(uuid.uuid4())
     processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
     await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate)
@@
-    version = versions_mod.create_version(
-        generation_id=generation_id,
-        label=label,
-        audio_path=str(processed_path),
-        db=db,
-        effects_chain=chain_dicts,
-        is_default=data.set_as_default,
-    )
+    try:
+        version = versions_mod.create_version(
+            generation_id=generation_id,
+            label=label,
+            audio_path=str(processed_path),
+            db=db,
+            effects_chain=chain_dicts,
+            is_default=data.set_as_default,
+        )
+    except Exception:
+        Path(processed_path).unlink(missing_ok=True)
+        raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 1857 - 1871, The code saves the processed audio
to processed_path before calling versions_mod.create_version, which can fail and
leave an orphaned file; wrap the create_version call in a try/except (or
try/finally) so that on any exception you remove the saved file (use Path.unlink
or os.remove on processed_path) and then re-raise the exception so the error
propagates; reference the processed_path variable, the save_audio call that
created it, and the versions_mod.create_version call when implementing the
cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/AudioPlayer/AudioPlayer.tsx`:
- Around line 160-174: The timeupdate handler in wavesurfer.on('timeupdate') is
pausing playback when time >= duration which prevents the media 'ended' event
and thus WaveSurfer's 'finish' handler (and your onFinish callback) from
running; remove the terminal pause/loop logic from the timeupdate handler (the
block that calls wavesurfer.pause(), wavesurfer.seekTo(0)/play when isLooping,
setIsPlaying(false), and the early return) and instead let the media naturally
reach 'ended' so the existing wavesurfer.on('finish') handler can run and invoke
onFinish; keep only non-terminal updates (e.g., setCurrentTime) in the
timeupdate handler and rely on the isLooping/finish logic inside the finish
event handler to handle looping and finalization.

In `@app/src/components/History/HistoryTable.tsx`:
- Around line 562-567: The current onClick handler calls both
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id) and, if the version is
non-default, handleSwitchVersion(gen.id, v.id), causing an unexpected default
switch when users only intend to preview; change the click behavior so the
onClick only invokes handlePlayVersion with the existing arguments (gen.id,
v.id, gen.text, gen.profile_id) and remove the conditional handleSwitchVersion
call, then add a separate explicit control (e.g., a "Set as default" button or
menu item tied to handleSwitchVersion(gen.id, v.id)) adjacent to the version row
so switching defaults is an opt-in action.

In `@backend/export_import.py`:
- Around line 284-297: The export currently serializes multiple versions (see
version_entries in export_import.py) but import_generation_from_zip() only
creates a single DBGeneration and assigns audio_files[0], losing
DBGenerationVersion rows, is_default flags, effects_chain and original default
selection; update import_generation_from_zip() to recreate DBGenerationVersion
records for each exported version entry (use the exported "filename",
"is_default", "effects_chain", "label", and original version id if needed) and
attach each saved audio file to its own DBGenerationVersion record rather than
stuffing the first file into DBGeneration; ensure the code restores the
is_default flag by setting the DBGeneration.default_version (or equivalent) to
the exported default and preserve effects_chain by deserializing and saving it
on each DBGenerationVersion so re-export/import is round-trippable.
- Around line 286-297: The export currently adds every DB row into
manifest["versions"] even if the referenced audio file (v.audio_path) is missing
and the later zip write silently skips missing files; modify the loop that
builds version_entries (and the corresponding zip-writing block that iterates
versions) to verify existence of Path(v.audio_path) before appending to
version_entries or attempting to write it to the ZIP, and if a file is missing
raise/return a clear exception (fail the export) instead of continuing;
reference the loop over versions, v.audio_path, version_entries and the ZIP
write block that handles versions to locate where to add the Path(...).exists()
check and the early failure.

In `@backend/main.py`:
- Around line 1000-1003: The current code unconditionally sets gen.status =
"generating" and later may set gen.status = "failed" even if a prior completed
generation/audio exists; modify the regenerate flow in backend/main.py to first
check the existing generation state and assets (e.g., gen.status and whatever
field stores the completed audio/asset) and only set gen.status = "generating"
if there is no already-completed result to preserve, and when a regenerate
attempt fails avoid overwriting a prior completed state—only set gen.error (and
set status to "failed") when there is no previously completed asset to fall back
to; update the logic around the gen.status/gen.error mutations and db.commit
calls so they guard on gen.status == "completed" or presence of the completed
asset before downgrading.

---

Duplicate comments:
In `@backend/main.py`:
- Around line 824-857: The current flow calls versions_mod.create_version for
the "original" before validating/applying effects, which can leave inconsistent
metadata if validate_effects_chain or apply_effects fails; change the logic so
effects are validated first (use validate_effects_chain) and only after a
successful validation and processing (apply_effects, save_audio) create the
appropriate version rows: if effects are invalid or skipped create a single
"original" version with is_default=True, otherwise create the "original" version
with is_default=False and then create the processed version (label "version-2")
with is_default=True; ensure you also avoid writing the processed file unless
apply_effects succeeds to prevent orphan artifacts and update final_audio_path
accordingly.
- Around line 769-772: Replace the silent except-pass around
_json.loads(profile_obj.effects_chain) so malformed stored JSON is logged; catch
the exception as e (e.g., except Exception as e:) and call the project logger
(or logging.exception) to record the error, include identifying info such as
profile_obj.id or profile_obj.name and the raw profile_obj.effects_chain value
plus the exception details, then allow effects_chain_config to remain None or a
safe default.
- Around line 1857-1871: The code saves the processed audio to processed_path
before calling versions_mod.create_version, which can fail and leave an orphaned
file; wrap the create_version call in a try/except (or try/finally) so that on
any exception you remove the saved file (use Path.unlink or os.remove on
processed_path) and then re-raise the exception so the error propagates;
reference the processed_path variable, the save_audio call that created it, and
the versions_mod.create_version call when implementing the cleanup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71b40d9a-d1ba-4f38-99fb-f1d98e77069d

📥 Commits

Reviewing files that changed from the base of the PR and between 25134b4 and 00c5b75.

📒 Files selected for processing (5)
  • app/src/components/AudioPlayer/AudioPlayer.tsx
  • app/src/components/History/HistoryTable.tsx
  • app/src/lib/api/client.ts
  • backend/export_import.py
  • backend/main.py

Comment on lines +160 to +174
// Update store when time changes, stop if past duration
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) {
setCurrentTime(dur);
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In WaveSurfer.js, if pause() is called from a timeupdatecallback when currentTime >= duration, is thefinish event still guaranteed to fire? Please cite docs/issues for the current major version.

💡 Result:

No. In the current major version (WaveSurfer.js v7), the finish event is emitted only in the handler for the media element’s ended event—not when you call pause() yourself. So if you call pause() from a timeupdate callback as soon as currentTime >= duration, you can prevent the underlying ended event from occurring, and therefore finish is not guaranteed to fire. [2]

Docs describe finish as “When the audio finishes playing,” but do not state it will fire if you programmatically pause at (or near) the end. [1]

Sources: WaveSurfer events docs [1], WaveSurfer v7 source (ended ⇒ emit finish) [2].


🏁 Script executed:

# Inspect the AudioPlayer file to understand the full context
# First, check file size to ensure safe reading
wc -l app/src/components/AudioPlayer/AudioPlayer.tsx

Repository: jamiepine/voicebox

Length of output: 112


🏁 Script executed:

# Read the timeupdate handler section (lines 160-174) with context
sed -n '155,180p' app/src/components/AudioPlayer/AudioPlayer.tsx

Repository: jamiepine/voicebox

Length of output: 831


🏁 Script executed:

# Read the finish handler section (lines 410-440) with context
sed -n '410,440p' app/src/components/AudioPlayer/AudioPlayer.tsx

Repository: jamiepine/voicebox

Length of output: 969


Move onFinish callback invocation to timeupdate handler or consolidate all terminal logic in the finish handler.

The timeupdate handler pauses at the end of track (lines 169), which prevents the media element's ended event from firing. Since WaveSurfer.js v7 emits the finish event only when ended fires, the finish handler (lines 416-430) is never reached, making the onFinish callback unreachable.

Either:

  1. Invoke onFinish from the timeupdate handler alongside the pause, or
  2. Remove pause/loop logic from timeupdate and let the media naturally fire endedfinishonFinish.

Option 2 is cleaner and keeps terminal behavior in one place.

Proposed fix (Option 2)
-      // Update store when time changes, stop if past duration
+      // Update store when time changes; clamp to duration.
+      // End-of-track loop/stop/callback behavior is handled in `finish`.
       wavesurfer.on('timeupdate', (time) => {
         const dur = usePlayerStore.getState().duration;
-        if (dur > 0 && time >= dur) {
-          setCurrentTime(dur);
-          const loop = usePlayerStore.getState().isLooping;
-          if (loop) {
-            wavesurfer.seekTo(0);
-            wavesurfer.play();
-          } else {
-            wavesurfer.pause();
-            setIsPlaying(false);
-          }
-          return;
-        }
-        setCurrentTime(time);
+        setCurrentTime(dur > 0 ? Math.min(time, dur) : time);
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Update store when time changes, stop if past duration
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) {
setCurrentTime(dur);
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
// Update store when time changes; clamp to duration.
// End-of-track loop/stop/callback behavior is handled in `finish`.
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
setCurrentTime(dur > 0 ? Math.min(time, dur) : time);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/AudioPlayer/AudioPlayer.tsx` around lines 160 - 174, The
timeupdate handler in wavesurfer.on('timeupdate') is pausing playback when time
>= duration which prevents the media 'ended' event and thus WaveSurfer's
'finish' handler (and your onFinish callback) from running; remove the terminal
pause/loop logic from the timeupdate handler (the block that calls
wavesurfer.pause(), wavesurfer.seekTo(0)/play when isLooping,
setIsPlaying(false), and the early return) and instead let the media naturally
reach 'ended' so the existing wavesurfer.on('finish') handler can run and invoke
onFinish; keep only non-terminal updates (e.g., setCurrentTime) in the
timeupdate handler and rely on the isLooping/finish logic inside the finish
event handler to handle looping and finalization.

Comment on lines +284 to +297
# Build version manifest entries
version_entries = []
for v in versions:
v_path = Path(v.audio_path)
effects_chain = None
if v.effects_chain:
effects_chain = json.loads(v.effects_chain)
version_entries.append({
"id": v.id,
"label": v.label,
"is_default": v.is_default,
"effects_chain": effects_chain,
"filename": v_path.name,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the new archive format round-trippable.

import_generation_from_zip() at Lines 384-445 still imports audio_files[0] into a single DBGeneration row and never recreates DBGenerationVersion records. A ZIP exported here will therefore lose extra versions, is_default, and effects_chain on re-import, and the imported audio will follow write order rather than the default version.

Also applies to: 316-330

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/export_import.py` around lines 284 - 297, The export currently
serializes multiple versions (see version_entries in export_import.py) but
import_generation_from_zip() only creates a single DBGeneration and assigns
audio_files[0], losing DBGenerationVersion rows, is_default flags, effects_chain
and original default selection; update import_generation_from_zip() to recreate
DBGenerationVersion records for each exported version entry (use the exported
"filename", "is_default", "effects_chain", "label", and original version id if
needed) and attach each saved audio file to its own DBGenerationVersion record
rather than stuffing the first file into DBGeneration; ensure the code restores
the is_default flag by setting the DBGeneration.default_version (or equivalent)
to the exported default and preserve effects_chain by deserializing and saving
it on each DBGenerationVersion so re-export/import is round-trippable.

Comment on lines +286 to +297
for v in versions:
v_path = Path(v.audio_path)
effects_chain = None
if v.effects_chain:
effects_chain = json.loads(v.effects_chain)
version_entries.append({
"id": v.id,
"label": v.label,
"is_default": v.is_default,
"effects_chain": effects_chain,
"filename": v_path.name,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail the export when a version file is missing.

Every DB row is added to manifest["versions"], but the ZIP write silently skips missing audio_paths. That can produce an archive whose manifest references files that were never bundled, and the fallback at Lines 326-330 does not help once versions is non-empty.

💡 Minimal fix
         version_entries = []
         for v in versions:
             v_path = Path(v.audio_path)
+            if not v_path.exists():
+                raise ValueError(f"Generation version audio file not found: {v.audio_path}")
             effects_chain = None
             if v.effects_chain:
                 effects_chain = json.loads(v.effects_chain)
             version_entries.append({
                 "id": v.id,
@@
         # Add all version audio files
         for v in versions:
             v_path = Path(v.audio_path)
-            if v_path.exists():
-                zip_file.write(v_path, f"audio/{v_path.name}")
+            zip_file.write(v_path, f"audio/{v_path.name}")

Also applies to: 320-330

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/export_import.py` around lines 286 - 297, The export currently adds
every DB row into manifest["versions"] even if the referenced audio file
(v.audio_path) is missing and the later zip write silently skips missing files;
modify the loop that builds version_entries (and the corresponding zip-writing
block that iterates versions) to verify existence of Path(v.audio_path) before
appending to version_entries or attempting to write it to the ZIP, and if a file
is missing raise/return a clear exception (fail the export) instead of
continuing; reference the loop over versions, v.audio_path, version_entries and
the ZIP write block that handles versions to locate where to add the
Path(...).exists() check and the early failure.

Comment on lines +1000 to +1003
# Set to generating so the UI shows the loader and SSE picks it up
gen.status = "generating"
gen.error = None
db.commit()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not downgrade an already completed generation to failed when regenerate fails.

This endpoint mutates status to generating (Line 1001) and then to failed on regenerate errors (Line 1078), even though a valid prior version/audio already exists. That can incorrectly block completed-only operations for the original item.

🔧 Suggested fix
 async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)):
@@
-    # Set to generating so the UI shows the loader and SSE picks it up
+    previous_status = gen.status or "completed"
+    previous_audio_path = gen.audio_path
+    previous_duration = gen.duration
+
+    # Set to generating so the UI shows the loader and SSE picks it up
     gen.status = "generating"
@@
         except Exception as e:
             import traceback
             traceback.print_exc()
-            await history.update_generation_status(
-                generation_id=generation_id,
-                status="failed",
-                db=bg_db,
-                error=str(e),
-            )
+            if previous_audio_path:
+                await history.update_generation_status(
+                    generation_id=generation_id,
+                    status=previous_status,
+                    db=bg_db,
+                    audio_path=previous_audio_path,
+                    duration=previous_duration,
+                )
+            else:
+                await history.update_generation_status(
+                    generation_id=generation_id,
+                    status="failed",
+                    db=bg_db,
+                    error=str(e),
+                )

Also applies to: 1078-1083

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 1000 - 1003, The current code unconditionally
sets gen.status = "generating" and later may set gen.status = "failed" even if a
prior completed generation/audio exists; modify the regenerate flow in
backend/main.py to first check the existing generation state and assets (e.g.,
gen.status and whatever field stores the completed audio/asset) and only set
gen.status = "generating" if there is no already-completed result to preserve,
and when a regenerate attempt fails avoid overwriting a prior completed
state—only set gen.error (and set status to "failed") when there is no
previously completed asset to fall back to; update the logic around the
gen.status/gen.error mutations and db.commit calls so they guard on gen.status
== "completed" or presence of the completed asset before downgrading.

- Add is_favorited column with toggle endpoint and star button on history
- Show sparkles icon on profile cards that have effects configured
- Gold ring on selected profile cards
- Smaller, gray action buttons with brighter hover
- Clamp player time to duration to prevent runaway playback
- Align profile card icon to top for wrapped names
- Flush bottom corners on history card when versions expanded
- Simplify .gitignore data/ rule
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
app/src/components/VoiceProfiles/ProfileCard.tsx (1)

115-121: Consider adding a tooltip for accessibility.

The Sparkles icon correctly renders when effects_chain exists and is non-empty, and the type guard properly handles the optional field. However, the icon is purely visual with no accessible context—screen reader users and sighted users unfamiliar with the UI won't know what it indicates.

💡 Suggested improvement to add a title attribute
             {profile.effects_chain && profile.effects_chain.length > 0 && (
-              <Sparkles className="h-3.5 w-3.5 text-accent" />
+              <Sparkles className="h-3.5 w-3.5 text-accent" aria-hidden="true" title="Has audio effects" />
             )}

Alternatively, wrap in a <Tooltip> component if one is available in the UI library for a more polished experience.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/VoiceProfiles/ProfileCard.tsx` around lines 115 - 121, Add
accessible context for the Sparkles icon in ProfileCard.tsx by providing a
tooltip or title so screen readers and sighted users understand its meaning;
locate the conditional that renders <Sparkles /> when profile.effects_chain &&
profile.effects_chain.length > 0 and either add a title attribute (e.g.,
title="Has audio effects") on the Sparkles element or wrap it with the project's
Tooltip component (if available) so the icon conveys "has effects_chain" to
assistive tech and hover users.
backend/main.py (1)

1722-1728: Consider simplifying the lambda wrapper.

The lambda wrapping sf.write is functional but slightly awkward. A direct call would be cleaner since asyncio.to_thread accepts positional args.

♻️ Suggested simplification
-    await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
+    def _write_wav():
+        sf.write(buf, processed, sample_rate, format="WAV")
+    await asyncio.to_thread(_write_wav)

Or use functools.partial:

from functools import partial
await asyncio.to_thread(partial(sf.write, buf, processed, sample_rate, format="WAV"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 1722 - 1728, Replace the lambda wrapper around
sf.write when calling asyncio.to_thread with a direct call that passes sf.write
and its args to asyncio.to_thread (or use functools.partial); specifically
update the call using asyncio.to_thread(lambda: sf.write(buf, processed,
sample_rate, format="WAV")) to call asyncio.to_thread(sf.write, buf, processed,
sample_rate, format="WAV") (or asyncio.to_thread(partial(sf.write, buf,
processed, sample_rate, format="WAV"))) so sf.write, buf, processed and
sample_rate are passed directly without the lambda.
backend/database.py (1)

439-454: Rename unused loop variable key to _key.

The loop variable key is not used within the loop body. Prefix with underscore to indicate intentionally unused.

♻️ Suggested fix
-        for idx, (key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
+        for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/database.py` around lines 439 - 454, Rename the unused loop variable
key to _key in the for loop that iterates over BUILTIN_PRESETS (the header
currently reads for idx, (key, preset_data) in
enumerate(BUILTIN_PRESETS.items()):); update it to for idx, (_key, preset_data)
in enumerate(BUILTIN_PRESETS.items()): so that the unused identifier is clearly
marked, leaving the rest of the logic that creates or updates EffectPreset, uses
sort_order, existing, db.add, and db.commit unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/history.py`:
- Around line 40-44: The current try/except around json.loads(v.effects_chain)
and the subsequent EffectConfig(**e) list comprehension swallows JSON parse or
validation errors (effects_chain) silently; change the except block to catch
Exception as e and emit a warning that includes the offending v.effects_chain
and the exception (e) so corrupted data is visible in logs—use the module logger
(e.g., logger.warning or logging.getLogger(__name__).warning) and keep the rest
of the logic (raw = json.loads(...), effects_chain = [EffectConfig(**e) ...])
intact.

---

Nitpick comments:
In `@app/src/components/VoiceProfiles/ProfileCard.tsx`:
- Around line 115-121: Add accessible context for the Sparkles icon in
ProfileCard.tsx by providing a tooltip or title so screen readers and sighted
users understand its meaning; locate the conditional that renders <Sparkles />
when profile.effects_chain && profile.effects_chain.length > 0 and either add a
title attribute (e.g., title="Has audio effects") on the Sparkles element or
wrap it with the project's Tooltip component (if available) so the icon conveys
"has effects_chain" to assistive tech and hover users.

In `@backend/database.py`:
- Around line 439-454: Rename the unused loop variable key to _key in the for
loop that iterates over BUILTIN_PRESETS (the header currently reads for idx,
(key, preset_data) in enumerate(BUILTIN_PRESETS.items()):); update it to for
idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()): so that the
unused identifier is clearly marked, leaving the rest of the logic that creates
or updates EffectPreset, uses sort_order, existing, db.add, and db.commit
unchanged.

In `@backend/main.py`:
- Around line 1722-1728: Replace the lambda wrapper around sf.write when calling
asyncio.to_thread with a direct call that passes sf.write and its args to
asyncio.to_thread (or use functools.partial); specifically update the call using
asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
to call asyncio.to_thread(sf.write, buf, processed, sample_rate, format="WAV")
(or asyncio.to_thread(partial(sf.write, buf, processed, sample_rate,
format="WAV"))) so sf.write, buf, processed and sample_rate are passed directly
without the lambda.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef139095-ba36-40c1-b59f-944aaa95b1ce

📥 Commits

Reviewing files that changed from the base of the PR and between 00c5b75 and e8d54d5.

📒 Files selected for processing (9)
  • .gitignore
  • app/src/components/History/HistoryTable.tsx
  • app/src/components/VoiceProfiles/ProfileCard.tsx
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • backend/database.py
  • backend/history.py
  • backend/main.py
  • backend/models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/lib/api/types.ts

Comment on lines +40 to +44
try:
raw = json.loads(v.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
except Exception:
pass
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Log JSON parse failures instead of silently swallowing them.

If the stored effects_chain is malformed, this silent pass makes debugging very difficult. A warning log would surface corrupted data without breaking the response.

🛠️ Suggested fix
         if v.effects_chain:
             try:
                 raw = json.loads(v.effects_chain)
                 effects_chain = [EffectConfig(**e) for e in raw]
-            except Exception:
-                pass
+            except Exception as e:
+                import logging
+                logging.warning(
+                    "Failed to parse effects_chain for version %s: %s", v.id, e
+                )
🧰 Tools
🪛 Ruff (0.15.5)

[error] 43-44: try-except-pass detected, consider logging the exception

(S110)


[warning] 43-43: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/history.py` around lines 40 - 44, The current try/except around
json.loads(v.effects_chain) and the subsequent EffectConfig(**e) list
comprehension swallows JSON parse or validation errors (effects_chain) silently;
change the except block to catch Exception as e and emit a warning that includes
the offending v.effects_chain and the exception (e) so corrupted data is visible
in logs—use the module logger (e.g., logger.warning or
logging.getLogger(__name__).warning) and keep the rest of the logic (raw =
json.loads(...), effects_chain = [EffectConfig(**e) ...]) intact.

jamiepine and others added 6 commits March 14, 2026 09:56
- Story items can be pinned to a specific generation version via
  toolbar dropdown (shows when clip is selected and has >1 version)
- version_id column on story_items with migration, validated against
  the generation's versions before saving
- Split/duplicate preserve the source clip's pinned version
- Export and playback resolve version-specific audio paths
- Extracted _build_item_detail helper in stories.py (DRY cleanup)
- Story list restyled from rounded cards to flat rows with rounded
  hover/active states, gradient header fade, and dynamic bottom
  padding that accounts for track editor + generate box
@jamiepine jamiepine merged commit 7a511e3 into main Mar 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant