Compare commits

...
Author SHA1 Message Date
Jamie PineandGitHub 6d261c44a1 Merge branch 'main' into feat/post-processing-effects 2026-03-14 12:14:26 -07:00
Jamie Pine 103e98b38f github runners suck 2026-03-14 12:13:45 -07:00
Jamie Pine 1c61b47a64 Glassmorphic active state for sidebar buttons with accent border shine 2026-03-14 12:11:07 -07:00
Jamie Pine 626e3740e1 Auto-select first story when navigating to Stories tab 2026-03-14 11:14:46 -07:00
Jamie Pine 310a4acb02 Add source version selection when applying effects, voices tab overhaul with inline inspector 2026-03-14 11:07:32 -07:00
Jamie Pine 899b90202b Add version control to track editor, restyle story list
- 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
2026-03-14 09:56:27 -07:00
Jamie Pine e8d54d52d3 Add favorites, effects badge on profiles, UI polish
- 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
2026-03-14 09:10:56 -07:00
Jamie Pine 00c5b75ffb Regenerate as new version, UI polish, and bugfixes
- 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
2026-03-14 08:34:58 -07:00
Jamie Pine 25134b4ba9 Fix player not loading new version after applying effects
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.
2026-03-14 08:01:45 -07:00
Jamie Pine 3d922ec846 Fix review findings: toggle logic, preset saving, version lookup, async audio ops
- Fix inverted effects toggle in FloatingGenerateBox
- Add Save button + API method for editing custom effect presets
- Return early on effects save failure in ProfileForm
- Fix no-op ternary in effectsStore
- Handle duplicate preset names with proper 400 response
- Use effects_chain is None instead of label for clean version lookup
- Move blocking audio ops to asyncio.to_thread in async endpoints
- Log warnings instead of silently swallowing parse errors
2026-03-14 07:47:06 -07:00
James Pine 638820c839 Add post-processing audio effects system
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
2026-03-14 07:11:56 -07:00
Jamie Pine 7cbf5a1ded Use Namespace runner for Linux release build 2026-03-14 05:38:25 -07:00
Jamie Pine e89a7eb7e7 Windows dev experience: CUDA detection, GPU display cleanup, hide drag region, dev-mode update status 2026-03-14 03:24:39 -07:00
Jamie Pine e18757bab3 Skip AppImage on Linux, build deb/rpm only 2026-03-14 03:04:59 -07:00
Jamie Pine 0e6c678fc3 Add Windows support to justfile 2026-03-14 02:41:59 -07:00
Jamie Pine 942dabbcac Install CPU-only PyTorch before requirements.txt on Linux
The previous ordering let requirements.txt pull CUDA-enabled torch
with all nvidia deps first, then the CPU override only swapped the
torch wheel while leaving ~3GB of nvidia packages installed.
2026-03-14 00:55:43 -07:00
Jamie Pine b915825165 Add libasound2-dev to Linux release deps 2026-03-13 21:36:46 -07:00
Jamie Pine f3fc63942f Fix Linux release build exceeding 4GB PyInstaller limit
Install CPU-only PyTorch on Linux and exclude nvidia modules from
non-CUDA PyInstaller builds.
2026-03-13 21:19:24 -07:00
Jamie Pine 9044b986f3 Move tauri imports behind platform abstraction, merge CUDA build into release workflow
- Add openPath and pickDirectory to PlatformFilesystem interface
- Remove direct @tauri-apps/plugin-shell and plugin-dialog imports from app
- Merge build-cuda.yml into release.yml as a parallel job
2026-03-13 11:33:32 -07:00
Jamie Pine b01076b6b3 Bump version: 0.1.13 → 0.2.0 2026-03-13 11:06:40 -07:00
Jamie PineandGitHub 5121c76e39 Merge pull request #269 from jamiepine/feat/async-generation-queue
feat: async generation queue
2026-03-13 10:58:18 -07:00
63 changed files with 4721 additions and 859 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.13 current_version = 0.2.0
commit = True commit = True
tag = True tag = True
tag_name = v{new_version} tag_name = v{new_version}
-73
View File
@@ -1,73 +0,0 @@
name: Build CUDA Backend
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact (for testing)
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
# Linux CUDA build can be added later with:
# build-cuda-linux:
# runs-on: ubuntu-22.04
# ...
+68 -13
View File
@@ -22,10 +22,6 @@ jobs:
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
python-version: "3.12" python-version: "3.12"
backend: "pytorch" backend: "pytorch"
- platform: "ubuntu-22.04"
args: ""
python-version: "3.12"
backend: "pytorch"
- platform: "windows-latest" - platform: "windows-latest"
args: "" args: ""
python-version: "3.12" python-version: "3.12"
@@ -37,10 +33,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
- name: Install LLVM (macOS) - name: Install LLVM (macOS)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel' if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
@@ -55,6 +51,11 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: "pip" cache: "pip"
- name: Install CPU-only PyTorch (Linux)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
@@ -66,12 +67,6 @@ jobs:
run: | run: |
pip install -r backend/requirements-mlx.txt pip install -r backend/requirements-mlx.txt
# - name: Install PyTorch with CUDA (Windows only)
# if: matrix.platform == 'windows-latest'
# run: |
# pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
# pip install torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Build Python server (Linux/macOS) - name: Build Python server (Linux/macOS)
if: matrix.platform != 'windows-latest' if: matrix.platform != 'windows-latest'
run: | run: |
@@ -151,10 +146,70 @@ jobs:
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference - **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch - **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
- **Windows**: Download the `.msi` installer - **Windows**: Download the `.msi` installer
- **Linux**: Download the `.AppImage` or `.deb` package - **Linux**: Compile from source (see README)
The app includes automatic updates - future updates will be installed automatically. The app includes automatic updates - future updates will be installed automatically.
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: ${{ matrix.args }} args: ${{ matrix.args }}
includeUpdaterJson: true includeUpdaterJson: true
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
+1 -4
View File
@@ -35,10 +35,7 @@ target/
Thumbs.db Thumbs.db
# Data (user-generated) # Data (user-generated)
data/profiles/* data/
data/generations/*
data/projects/*
data/voicebox.db
!data/.gitkeep !data/.gitkeep
# Logs # Logs
+1 -1
View File
@@ -85,7 +85,7 @@ Voicebox is available now for macOS and Windows.
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) | | Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
| Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) | | Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) |
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations. > **Linux** — Pre-built binaries are not yet available. Linux users can compile from source, see [Development](#development) below.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.13", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+14 -1
View File
@@ -157,8 +157,21 @@ export function AudioPlayer() {
const wavesurfer = wavesurferRef.current; const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return; if (!wavesurfer) return;
// Update store when time changes // Update store when time changes, stop if past duration
wavesurfer.on('timeupdate', (time) => { 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(time);
}); });
@@ -0,0 +1,377 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
// Each effect in the chain gets a stable ID for dnd-kit
interface EffectWithId extends EffectConfig {
_id: string;
}
let nextId = 0;
function makeId() {
return `fx-${++nextId}`;
}
interface EffectsChainEditorProps {
value: EffectConfig[];
onChange: (chain: EffectConfig[]) => void;
compact?: boolean;
showPresets?: boolean;
}
export function EffectsChainEditor({
value,
onChange,
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
// We use a ref to map value items to IDs, rebuilding when length changes.
const idsRef = useRef<string[]>([]);
const items: EffectWithId[] = useMemo(() => {
// Grow ID array if effects were added
while (idsRef.current.length < value.length) {
idsRef.current.push(makeId());
}
// Shrink if effects were removed
if (idsRef.current.length > value.length) {
idsRef.current = idsRef.current.slice(0, value.length);
}
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
}, [value]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const { data: availableEffects } = useQuery({
queryKey: ['available-effects'],
queryFn: () => apiClient.getAvailableEffects(),
staleTime: Infinity,
});
const { data: presets } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const effectsMap = useMemo(() => {
const m = new Map<string, AvailableEffect>();
if (availableEffects) {
for (const e of availableEffects.effects) {
m.set(e.type, e);
}
}
return m;
}, [availableEffects]);
function addEffect(type: string) {
const def = effectsMap.get(type);
if (!def) return;
const params: Record<string, number> = {};
for (const [key, p] of Object.entries(def.params)) {
params[key] = p.default;
}
const newEffect: EffectConfig = { type, enabled: true, params };
const newId = makeId();
idsRef.current = [...idsRef.current, newId];
onChange([...value, newEffect]);
setExpandedId(newId);
}
const removeEffect = useCallback(
(index: number) => {
const removedId = idsRef.current[index];
idsRef.current = idsRef.current.filter((_, i) => i !== index);
onChange(value.filter((_, i) => i !== index));
if (expandedId === removedId) setExpandedId(null);
},
[value, onChange, expandedId],
);
const toggleEnabled = useCallback(
(index: number) => {
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
},
[value, onChange],
);
const updateParam = useCallback(
(index: number, paramName: string, paramValue: number) => {
onChange(
value.map((e, i) =>
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
),
);
},
[value, onChange],
);
function loadPreset(preset: EffectPresetResponse) {
idsRef.current = preset.effects_chain.map(() => makeId());
onChange(preset.effects_chain);
setExpandedId(null);
}
function clearAll() {
idsRef.current = [];
onChange([]);
setExpandedId(null);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = idsRef.current.indexOf(active.id as string);
const newIndex = idsRef.current.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
onChange(arrayMove([...value], oldIndex, newIndex));
}
return (
<div className={cn('space-y-2', compact && 'text-sm')}>
{/* Preset selector row */}
{showPresets && (
<div className="flex items-center gap-2">
<Select
onValueChange={(id) => {
const preset = presets?.find((p) => p.id === id);
if (preset) loadPreset(preset);
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Load preset..." />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.description && (
<span className="ml-1 text-muted-foreground">- {p.description}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{value.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
Clear
</Button>
)}
</div>
)}
{/* Sortable effects chain */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
{items.map((effect, index) => (
<SortableEffectItem
key={effect._id}
id={effect._id}
effect={effect}
index={index}
effectDef={effectsMap.get(effect.type)}
isExpanded={expandedId === effect._id}
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
onRemove={() => removeEffect(index)}
onToggleEnabled={() => toggleEnabled(index)}
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
/>
))}
</SortableContext>
</DndContext>
{/* Add effect */}
{availableEffects && (
<Select onValueChange={addEffect}>
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
<Plus className="mr-1 h-3.5 w-3.5" />
<SelectValue placeholder="Add effect..." />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sortable effect item
// ---------------------------------------------------------------------------
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,
effectDef,
isExpanded,
onToggleExpand,
onRemove,
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
};
const label = effectDef?.label ?? effect.type;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-md border',
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
isDragging && 'opacity-80 shadow-lg',
)}
>
{/* Header */}
<div className="flex items-center gap-1 px-2 py-1.5">
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggleExpand}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<span
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
>
{label}
</span>
<button
type="button"
className={cn(
'p-0.5 transition-colors',
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? 'Disable' : 'Enable'}
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={onRemove}
title="Remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Params */}
{isExpanded && effectDef && (
<div className="space-y-3 border-t px-3 py-2.5">
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
const currentValue = effect.params[paramName] ?? paramDef.default;
return (
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{paramDef.description}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
)}
</span>
</div>
<Slider
min={paramDef.min}
max={paramDef.max}
step={paramDef.step}
value={[currentValue]}
onValueChange={([v]) => onUpdateParam(paramName, v)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,103 @@
import { ChevronDown, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
interface GenerationPickerProps {
selectedId: string | null;
onSelect: (generation: HistoryResponse) => void;
className?: string;
}
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { data: historyData } = useHistory({ limit: 50 });
const completedGenerations = useMemo(() => {
if (!historyData?.items) return [];
return historyData.items.filter((gen) => gen.status === 'completed');
}, [historyData]);
const filtered = useMemo(() => {
if (!searchQuery) return completedGenerations;
const q = searchQuery.toLowerCase();
return completedGenerations.filter(
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
);
}, [completedGenerations, searchQuery]);
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
>
{selectedGeneration ? (
<span className="truncate">
<span className="font-medium">{selectedGeneration.profile_name}</span>
<span className="text-muted-foreground ml-1.5">
{selectedGeneration.text.length > 30
? `${selectedGeneration.text.substring(0, 30)}...`
: selectedGeneration.text}
</span>
</span>
) : (
<span className="text-muted-foreground">Select a generation...</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by voice or text..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No generations found
</div>
) : (
filtered.map((gen) => (
<button
key={gen.id}
type="button"
className={cn(
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
gen.id === selectedId && 'bg-accent/10',
)}
onClick={() => {
onSelect(gen);
setOpen(false);
setSearchQuery('');
}}
>
<div className="font-medium text-sm">{gen.profile_name}</div>
<div className="text-xs text-muted-foreground truncate">
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
</div>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,332 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { useEffectsStore } from '@/stores/effectsStore';
import { usePlayerStore } from '@/stores/playerStore';
export function EffectsDetail() {
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const workingChain = useEffectsStore((s) => s.workingChain);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// Preview state
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const blobUrlRef = useRef<string | null>(null);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const { toast } = useToast();
const queryClient = useQueryClient();
// Auto-select the most recent generation as preview source
const { data: historyData } = useHistory({ limit: 1 });
useEffect(() => {
if (!previewGenId && historyData?.items?.length) {
const first = historyData.items.find((g) => g.status === 'completed');
if (first) setPreviewGenId(first.id);
}
}, [historyData, previewGenId]);
const { data: preset } = useQuery({
queryKey: ['effect-preset', selectedPresetId],
queryFn: () =>
selectedPresetId
? apiClient
.listEffectPresets()
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
: null,
enabled: !!selectedPresetId,
staleTime: 30_000,
});
// Sync name/description when selecting a preset
useEffect(() => {
if (preset) {
setName(preset.name);
setDescription(preset.description ?? '');
} else if (isCreatingNew) {
setName('');
setDescription('');
}
}, [preset, isCreatingNew]);
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isEditing = !!selectedPresetId || isCreatingNew;
const isBuiltIn = preset?.is_builtin ?? false;
async function handlePreview() {
if (!previewGenId || workingChain.length === 0) return;
setPreviewLoading(true);
try {
const blob = await apiClient.previewEffects(previewGenId, workingChain);
// Revoke old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
// Play through the main audio player
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: 'Preview failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setPreviewLoading(false);
}
}
function handleSelectGeneration(gen: HistoryResponse) {
setPreviewGenId(gen.id);
}
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleSaveExisting() {
if (!selectedPresetId || !name.trim()) return;
setSaving(true);
try {
await apiClient.updateEffectPreset(selectedPresetId, {
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: 'Preset updated' });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleSaveAsNew() {
await handleSaveNew();
}
async function handleDelete() {
if (!selectedPresetId) return;
setDeleting(true);
try {
await apiClient.deleteEffectPreset(selectedPresetId);
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: 'Preset deleted' });
} catch (error) {
toast({
title: 'Failed to delete',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setDeleting(false);
}
}
if (!isEditing) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
<p className="text-sm">Select a preset or create a new one</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveExisting}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save'}
</Button>
</>
)}
{isCreatingNew && (
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveNew}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save Preset'}
</Button>
)}
{isBuiltIn && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={handleSaveAsNew}
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save as Custom'}
</Button>
)}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
{/* Name & description */}
{(isCreatingNew || !isBuiltIn) && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My preset..."
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{/* Built-in description (read-only) */}
{isBuiltIn && preset?.description && (
<p className="text-sm text-muted-foreground">{preset.description}</p>
)}
{/* Effects chain editor */}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
<Separator />
{/* Preview section */}
<div className="space-y-3">
<Label className="text-xs">Preview</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
onSelect={handleSelectGeneration}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handlePreview}
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
>
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing...
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
Preview
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Preview applies effects to the clean version without saving.
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,165 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useEffectsStore } from '@/stores/effectsStore';
export function EffectsList() {
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const { data: presets, isLoading } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
function handleSelect(preset: EffectPresetResponse) {
setSelectedPresetId(preset.id);
setWorkingChain(preset.effects_chain);
}
function handleCreateNew() {
setIsCreatingNew(true);
setWorkingChain([]);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Effects</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
New Preset
</Button>
</div>
{/* Scrollable list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* Built-in presets */}
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Built-in
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* User presets */}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Custom
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* New preset placeholder */}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
New
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Unsaved Preset</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Configure effects in the panel on the right.
</p>
</div>
</div>
)}
</div>
</div>
);
}
function PresetCard({
preset,
isSelected,
onSelect,
}: {
preset: EffectPresetResponse;
isSelected: boolean;
onSelect: () => void;
}) {
const effectCount = preset.effects_chain.length;
return (
<button
type="button"
className={cn(
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
isSelected
? 'border-accent/50 bg-accent/10'
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{preset.name}</span>
{preset.is_builtin && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
built-in
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{preset.description || 'No description'}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{effectCount} effect{effectCount !== 1 ? 's' : ''}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
.filter((e) => e.enabled)
.map((e) => e.type)
.join(' → ')}
</span>
</div>
</button>
);
}
@@ -0,0 +1,20 @@
import {EffectsDetail} from "./EffectsDetail";
import {EffectsList} from "./EffectsList";
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
<EffectsList />
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<EffectsDetail />
</div>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react'; import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { import {
@@ -12,6 +13,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { EffectConfig } from '@/lib/api/types';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
@@ -37,6 +39,7 @@ export function FloatingGenerateBox({
const { data: profiles } = useProfiles(); const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isInstructMode, setIsInstructMode] = useState(false); const [isInstructMode, setIsInstructMode] = useState(false);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
@@ -57,6 +60,7 @@ export function FloatingGenerateBox({
addPendingStoryAdd(generationId, selectedStoryId); addPendingStoryAdd(generationId, selectedStoryId);
} }
}, },
getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
}); });
// Click away handler to collapse the box // Click away handler to collapse the box
@@ -355,6 +359,8 @@ export function FloatingGenerateBox({
'h-10 w-10 rounded-full transition-all duration-200', 'h-10 w-10 rounded-full transition-all duration-200',
isInstructMode isInstructMode
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90' ? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: effectsChain.length > 0
? 'bg-accent/50 text-accent-foreground border border-accent/50 hover:bg-accent/70'
: 'bg-card border border-border hover:bg-background/50', : 'bg-card border border-border hover:bg-background/50',
)} )}
aria-label={ aria-label={
@@ -364,7 +370,7 @@ export function FloatingGenerateBox({
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
</Button> </Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]"> <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
Fine tune instructions Fine tune instructions & effects
</span> </span>
</div> </div>
</motion.div> </motion.div>
@@ -373,6 +379,23 @@ export function FloatingGenerateBox({
</div> </div>
</div> </div>
{/* Effects chain editor panel - shown alongside instruct */}
<AnimatePresence>
{isExpanded && isInstructMode && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden mt-2"
>
<div className="border-t border-border/50 pt-2 pb-1">
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} compact />
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
+298 -12
View File
@@ -1,15 +1,22 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { import {
AlignCenter,
AudioLines,
AudioWaveform,
Download, Download,
FileArchive, FileArchive,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Play, Play,
RotateCcw, RotateCcw,
Star,
Trash2, Trash2,
Wand2,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import Loader from 'react-loaders'; import Loader from 'react-loaders';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -25,10 +32,17 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { HistoryResponse } from '@/lib/api/types'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { import {
useDeleteGeneration, useDeleteGeneration,
@@ -60,6 +74,15 @@ export function HistoryTable() {
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>( const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
null, null,
); );
const [effectsDialogOpen, setEffectsDialogOpen] = useState(false);
const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null);
const [effectsTargetVersions, setEffectsTargetVersions] = useState<GenerationVersionResponse[]>(
[],
);
const [effectsSourceVersionId, setEffectsSourceVersionId] = useState<string | null>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [applyingEffects, setApplyingEffects] = useState(false);
const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null);
const limit = 20; const limit = 20;
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -215,6 +238,106 @@ export function HistoryTable() {
} }
}; };
const handleRegenerate = async (generationId: string) => {
try {
await apiClient.regenerateGeneration(generationId);
addPendingGeneration(generationId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Regenerate failed',
description: error instanceof Error ? error.message : 'Could not regenerate',
variant: 'destructive',
});
}
};
const handleToggleFavorite = async (generationId: string) => {
try {
await apiClient.toggleFavorite(generationId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Failed to update favorite',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handleApplyEffects = (generationId: string) => {
const gen = allHistory.find((g) => g.id === generationId);
const versions = gen?.versions ?? [];
setEffectsTargetId(generationId);
setEffectsTargetVersions(versions);
// Default to clean/original version (no effects chain)
const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0);
setEffectsSourceVersionId(cleanVersion?.id ?? null);
setEffectsChain([]);
setEffectsDialogOpen(true);
};
const handleApplyEffectsConfirm = async () => {
if (!effectsTargetId || effectsChain.length === 0) return;
setApplyingEffects(true);
try {
const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, {
effects_chain: effectsChain,
source_version_id: effectsSourceVersionId ?? undefined,
set_as_default: true,
});
queryClient.invalidateQueries({ queryKey: ['history'] });
// If the player is currently on this generation, reload with the new version audio
if (currentAudioId === effectsTargetId) {
const gen = allHistory.find((g) => g.id === effectsTargetId);
if (gen) {
const versionUrl = apiClient.getVersionAudioUrl(newVersion.id);
setAudioWithAutoPlay(
versionUrl,
effectsTargetId,
gen.profile_id,
gen.text.substring(0, 50),
);
}
}
setEffectsDialogOpen(false);
toast({ title: 'Effects applied', description: 'A new version has been created.' });
} catch (error) {
toast({
title: 'Failed to apply effects',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setApplyingEffects(false);
}
};
const handleSwitchVersion = async (generationId: string, versionId: string) => {
try {
await apiClient.setDefaultVersion(generationId, versionId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Failed to switch version',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handlePlayVersion = (
generationId: string,
versionId: string,
text: string,
profileId: string,
) => {
const audioUrl = apiClient.getVersionAudioUrl(versionId);
setAudioWithAutoPlay(audioUrl, generationId, profileId, text.substring(0, 50));
};
const handleImportConfirm = () => { const handleImportConfirm = () => {
if (selectedFile) { if (selectedFile) {
importGeneration.mutate(selectedFile, { importGeneration.mutate(selectedFile, {
@@ -274,15 +397,24 @@ export function HistoryTable() {
const isGenerating = gen.status === 'generating'; const isGenerating = gen.status === 'generating';
const isFailed = gen.status === 'failed'; const isFailed = gen.status === 'failed';
const isPlayable = !isGenerating && !isFailed; const isPlayable = !isGenerating && !isFailed;
const hasVersions = gen.versions && gen.versions.length > 1;
const isVersionsExpanded = expandedVersionsId === gen.id;
return ( return (
<div <div
key={gen.id} key={gen.id}
className={cn(
'border rounded-md bg-card transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70',
)}
>
{/* Main row */}
<div
role={isPlayable ? 'button' : undefined} role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined} tabIndex={isPlayable ? 0 : undefined}
className={cn( className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full', 'flex items-stretch gap-4 h-26 p-3',
isPlayable && 'hover:bg-muted/70 cursor-pointer', isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
isCurrentlyPlaying && 'bg-muted/70', isVersionsExpanded && 'rounded-b-none',
)} )}
aria-label={ aria-label={
isGenerating isGenerating
@@ -360,30 +492,62 @@ export function HistoryTable() {
{/* Far right - Actions */} {/* Far right - Actions */}
<div <div
className="w-10 shrink-0 flex justify-end items-center" className="shrink-0 flex flex-col justify-center items-center gap-0.5"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
gen.is_favorited && 'text-accent hover:text-accent',
)}
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
onClick={() => handleToggleFavorite(gen.id)}
>
<Star
className="h-2 w-2"
fill={gen.is_favorited ? 'currentColor' : 'none'}
/>
</Button>
{hasVersions && (
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
isVersionsExpanded && 'text-accent hover:text-accent',
)}
aria-label="Toggle versions"
onClick={() => setExpandedVersionsId(isVersionsExpanded ? null : gen.id)}
>
<AudioLines className="h-2 w-2" />
</Button>
)}
{isFailed ? ( {isFailed ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation" aria-label="Retry generation"
onClick={() => handleRetry(gen.id)} onClick={() => handleRetry(gen.id)}
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-2 w-2" />
</Button> </Button>
) : isPlayable ? ( ) : (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Actions" aria-label="Actions"
disabled={isGenerating}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-2 w-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -407,19 +571,90 @@ export function HistoryTable() {
<FileArchive className="mr-2 h-4 w-4" /> <FileArchive className="mr-2 h-4 w-4" />
Export Package Export Package
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Effects
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Regenerate
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)} onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending} disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive" // className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} </>
)}
</div> </div>
</div> </div>
{/* Expandable versions panel */}
<AnimatePresence>
{isVersionsExpanded && gen.versions && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="border-t border-border/50">
<div className="divide-y divide-border/40">
{gen.versions.map((v) => {
// Show source provenance when effects were applied to a non-clean version
const sourceVersion = v.source_version_id
? gen.versions?.find((sv) => sv.id === v.source_version_id)
: null;
const showSource =
sourceVersion &&
sourceVersion.effects_chain &&
sourceVersion.effects_chain.length > 0;
return (
<button
key={v.id}
type="button"
className="flex items-center gap-2 w-full h-9 px-3 text-left hover:bg-muted/50 transition-colors"
onClick={() => {
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
if (!v.is_default) {
handleSwitchVersion(gen.id, v.id);
}
}}
>
<AudioLines className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">{v.label}</span>
{v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-[10px] text-muted-foreground truncate">
{v.effects_chain.map((e) => e.type).join(' → ')}
</span>
)}
{showSource && (
<span className="text-[10px] text-muted-foreground/60 truncate">
from {sourceVersion.label}
</span>
)}
<span className="flex-1" />
{v.is_default && (
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
active
</span>
)}
</button>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
); );
})} })}
@@ -500,6 +735,57 @@ export function HistoryTable() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Apply Effects</DialogTitle>
<DialogDescription>
Configure post-processing effects to apply to this generation. A new version will be
created.
</DialogDescription>
</DialogHeader>
{effectsTargetVersions.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Source</label>
<Select
value={effectsSourceVersionId ?? ''}
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select source version" />
</SelectTrigger>
<SelectContent>
{effectsTargetVersions.map((v) => (
<SelectItem key={v.id} value={v.id} className="text-xs">
{v.label}
{v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-muted-foreground ml-1.5">
({v.effects_chain.map((e) => e.type).join(' + ')})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="py-2 max-h-80 overflow-y-auto">
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleApplyEffectsConfirm}
disabled={applyingEffects || effectsChain.length === 0}
>
{applyingEffects ? 'Applying...' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -113,7 +113,7 @@ export function ConnectionForm() {
<Badge variant={health.gpu_available ? 'default' : 'secondary'}> <Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'} GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</Badge> </Badge>
{health.vram_used_mb && ( {health.vram_used_mb != null && health.vram_used_mb > 0 && (
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge> <Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
)} )}
</div> </div>
@@ -26,7 +26,7 @@ export function GpuAcceleration() {
// Query CUDA backend status // Query CUDA backend status
const { const {
data: cudaStatus, data: cudaStatus,
isLoading: cudaStatusLoading, isLoading: _cudaStatusLoading,
refetch: refetchCudaStatus, refetch: refetchCudaStatus,
} = useQuery({ } = useQuery({
queryKey: ['cuda-status', serverUrl], queryKey: ['cuda-status', serverUrl],
@@ -218,35 +218,33 @@ export function GpuAcceleration() {
<CardTitle>GPU Acceleration</CardTitle> <CardTitle>GPU Acceleration</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Current status */} {/* GPU status */}
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">Backend</div> {health.gpu_available && health.gpu_type ? (
<>
<div className="text-sm font-medium">
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type}
</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{isCurrentlyCuda {health.gpu_type.replace(/\s*\(.+\)$/, '')}
? 'CUDA (GPU accelerated)' {health.vram_used_mb != null && health.vram_used_mb > 0
: hasNativeGpu ? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
? `${health.backend_type === 'mlx' ? 'MLX' : 'PyTorch'} (GPU accelerated)` : ''}
: 'CPU'}
</div>
</div>
{/* GPU info from health */}
{health.gpu_type && (
<div className="space-y-1">
<div className="text-sm font-medium">GPU</div>
<div className="text-sm text-muted-foreground">{health.gpu_type}</div>
{health.vram_used_mb != null && (
<div className="text-xs text-muted-foreground">
VRAM: {health.vram_used_mb.toFixed(0)} MB used
</div> </div>
</>
) : (
<>
<div className="text-sm font-medium">CPU</div>
<div className="text-sm text-muted-foreground">No GPU acceleration available</div>
</>
)} )}
</div> </div>
)}
{/* Native GPU detected - no CUDA download needed */} {/* Native GPU detected - no CUDA download needed */}
{/* CUDA download section - only show when native GPU is NOT detected (i.e., Windows/Linux NVIDIA users) */} {/* CUDA download section - only show when no GPU is active (native or CUDA) */}
{!hasNativeGpu && ( {!hasNativeGpu && !isCurrentlyCuda && (
<> <>
{/* Download progress */} {/* Download progress */}
{cudaDownloading && downloadProgress && ( {cudaDownloading && downloadProgress && (
@@ -446,8 +446,7 @@ export function ModelManagement() {
className="text-xs text-muted-foreground h-7 px-2" className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => { onClick={async () => {
try { try {
const { open } = await import('@tauri-apps/plugin-shell'); await platform.filesystem.openPath(cacheDir.path);
await open(cacheDir.path);
} catch { } catch {
toast({ title: 'Failed to open model folder', variant: 'destructive' }); toast({ title: 'Failed to open model folder', variant: 'destructive' });
} }
@@ -462,14 +461,9 @@ export function ModelManagement() {
className="text-xs text-muted-foreground h-7 px-2" className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => { onClick={async () => {
try { try {
const { open: openDialog } = await import('@tauri-apps/plugin-dialog'); const newDir = await platform.filesystem.pickDirectory(
const selected = await openDialog({ 'Choose model storage folder',
directory: true, );
title: 'Choose model storage folder',
});
if (!selected) return;
const newDir =
typeof selected === 'string' ? selected : (selected as { path: string }).path;
if (!newDir) return; if (!newDir) return;
setPendingMigrateDir(newDir); setPendingMigrateDir(newDir);
} catch { } catch {
@@ -11,6 +11,7 @@ export function UpdateStatus() {
const platform = usePlatform(); const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false); const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>(''); const [currentVersion, setCurrentVersion] = useState<string>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => { useEffect(() => {
platform.metadata platform.metadata
@@ -20,11 +21,7 @@ export function UpdateStatus() {
}, [platform]); }, [platform]);
return ( return (
<Card <Card role="region" aria-label="App Updates" tabIndex={0}>
role="region"
aria-label="App Updates"
tabIndex={0}
>
<CardHeader> <CardHeader>
<CardTitle>App Updates</CardTitle> <CardTitle>App Updates</CardTitle>
</CardHeader> </CardHeader>
@@ -32,8 +29,12 @@ export function UpdateStatus() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">Current Version</div> <div className="text-sm font-medium">Current Version</div>
<div className="text-sm text-muted-foreground">v{currentVersion}</div> <div className="text-sm text-muted-foreground">
v{currentVersion}
{isDev ? ' (dev)' : ''}
</div> </div>
</div>
{!isDev && (
<Button <Button
onClick={checkForUpdates} onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.readyToInstall} disabled={status.checking || status.downloading || status.readyToInstall}
@@ -43,8 +44,15 @@ export function UpdateStatus() {
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates Check for Updates
</Button> </Button>
)}
</div> </div>
{isDev ? (
<div className="text-sm text-muted-foreground">
Auto-updates are disabled in development mode.
</div>
) : (
<>
{status.checking && ( {status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
@@ -109,8 +117,8 @@ export function UpdateStatus() {
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
The app needs to restart to complete the installation. You can do this now or later at The app needs to restart to complete the installation. You can do this now or
your convenience. later at your convenience.
</div> </div>
<Button onClick={restartAndInstall} className="w-full" size="sm"> <Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
@@ -119,11 +127,13 @@ export function UpdateStatus() {
</div> </div>
)} )}
{!status.available && !status.checking && !status.error && status.checking === false && ( {!status.available && !status.checking && !status.error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
You're up to date You're up to date
</div> </div>
)} )}
</>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
+27 -7
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router'; import { Link, useMatchRoute } from '@tanstack/react-router';
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } from 'lucide-react'; import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -11,8 +11,9 @@ interface SidebarProps {
const tabs = [ const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' }, { id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' }, { id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' }, { id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' }, { id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'server', path: '/server', icon: Server, label: 'Server' }, { id: 'server', path: '/server', icon: Server, label: 'Server' },
@@ -31,7 +32,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
> >
{/* Logo */} {/* Logo */}
<div className="mb-2"> <div className="mb-2">
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" /> <img
src={voiceboxLogo}
alt="Voicebox"
className="w-12 h-12 object-contain"
style={{
filter:
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
}}
/>
</div> </div>
{/* Navigation Buttons */} {/* Navigation Buttons */}
@@ -47,14 +56,25 @@ export function Sidebar({ isMacOS }: SidebarProps) {
key={tab.id} key={tab.id}
to={tab.path} to={tab.path}
className={cn( className={cn(
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200', 'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
'hover:bg-muted/50', isActive
isActive ? 'bg-muted/50 text-foreground shadow-lg' : 'text-muted-foreground', ? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground hover:bg-muted/50',
)} )}
title={tab.label} title={tab.label}
aria-label={tab.label} aria-label={tab.label}
> >
<Icon className="h-5 w-5" /> {isActive && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
border: '1px solid hsl(var(--accent) / 0.5)',
}}
/>
)}
<Icon className="h-5 w-5 relative z-10" />
</Link> </Link>
); );
})} })}
+43 -26
View File
@@ -1,5 +1,5 @@
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories'; import {
useCreateStory,
useDeleteStory,
useStories,
useStory,
useUpdateStory,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatDate } from '@/lib/utils/format'; import { formatDate } from '@/lib/utils/format';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
@@ -38,6 +44,8 @@ export function StoryList() {
const { data: stories, isLoading } = useStories(); const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId); const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: selectedStory } = useStory(selectedStoryId);
const createStory = useCreateStory(); const createStory = useCreateStory();
const updateStory = useUpdateStory(); const updateStory = useUpdateStory();
const deleteStory = useDeleteStory(); const deleteStory = useDeleteStory();
@@ -54,6 +62,13 @@ export function StoryList() {
const [newStoryDescription, setNewStoryDescription] = useState(''); const [newStoryDescription, setNewStoryDescription] = useState('');
const { toast } = useToast(); const { toast } = useToast();
// Auto-select the first story when the list loads with no selection
useEffect(() => {
if (!selectedStoryId && stories && stories.length > 0) {
setSelectedStoryId(stories[0].id);
}
}, [selectedStoryId, stories, setSelectedStoryId]);
const handleCreateStory = () => { const handleCreateStory = () => {
if (!newStoryName.trim()) { if (!newStoryName.trim()) {
toast({ toast({
@@ -170,10 +185,15 @@ export function StoryList() {
} }
const storyList = stories || []; const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="h-full flex flex-col relative overflow-hidden">
{/* Header */} {/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20">
<div className="flex items-center justify-between mb-4 px-1"> <div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Stories</h2> <h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm"> <Button onClick={() => setCreateDialogOpen(true)} size="sm">
@@ -181,9 +201,13 @@ export function StoryList() {
New Story New Story
</Button> </Button>
</div> </div>
</div>
{/* Story List */} {/* Scrollable Story List */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2"> <div
className="flex-1 overflow-y-auto pt-14 relative z-0"
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? ( {storyList.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground"> <div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" /> <BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
@@ -191,20 +215,17 @@ export function StoryList() {
<p className="text-xs mt-2">Create your first story to get started</p> <p className="text-xs mt-2">Create your first story to get started</p>
</div> </div>
) : ( ) : (
storyList.map((story) => ( <div className="space-y-0.5">
{storyList.map((story) => (
<div <div
key={story.id} key={story.id}
role="button" role="button"
tabIndex={0} tabIndex={0}
className={cn( className={cn(
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer', 'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
selectedStoryId === story.id && 'bg-muted border-primary', selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
)} )}
aria-label={ aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
selectedStoryId === story.id
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
}
aria-pressed={selectedStoryId === story.id} aria-pressed={selectedStoryId === story.id}
onClick={() => setSelectedStoryId(story.id)} onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -217,17 +238,12 @@ export function StoryList() {
> >
<div className="flex items-start justify-between gap-2 w-full min-w-0"> <div className="flex items-start justify-between gap-2 w-full min-w-0">
<div className="flex-1 min-w-0 text-left overflow-hidden"> <div className="flex-1 min-w-0 text-left overflow-hidden">
<h3 className="font-medium truncate">{story.name}</h3> <h3 className="text-sm font-medium truncate">{story.name}</h3>
{story.description && ( <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground mt-1 truncate">
{story.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span> <span>
{story.item_count} {story.item_count === 1 ? 'item' : 'items'} {story.item_count} {story.item_count === 1 ? 'item' : 'items'}
</span> </span>
<span>•</span> <span>·</span>
<span>{formatDate(story.updated_at)}</span> <span>{formatDate(story.updated_at)}</span>
</div> </div>
</div> </div>
@@ -236,11 +252,11 @@ export function StoryList() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`} aria-label={`Actions for ${story.name}`}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -259,7 +275,8 @@ export function StoryList() {
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
)) ))}
</div>
)} )}
</div> </div>
@@ -1,5 +1,7 @@
import { import {
Check,
Copy, Copy,
GalleryVerticalEnd,
GripHorizontal, GripHorizontal,
Minus, Minus,
Pause, Pause,
@@ -12,6 +14,12 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types'; import type { StoryItemDetail } from '@/lib/api/types';
@@ -19,6 +27,7 @@ import {
useDuplicateStoryItem, useDuplicateStoryItem,
useMoveStoryItem, useMoveStoryItem,
useRemoveStoryItem, useRemoveStoryItem,
useSetStoryItemVersion,
useSplitStoryItem, useSplitStoryItem,
useTrimStoryItem, useTrimStoryItem,
} from '@/lib/hooks/useStories'; } from '@/lib/hooks/useStories';
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support // Clip waveform component with trim support
function ClipWaveform({ function ClipWaveform({
generationId, generationId,
versionId,
width, width,
trimStartMs, trimStartMs,
trimEndMs, trimEndMs,
duration, duration,
}: { }: {
generationId: string; generationId: string;
versionId?: string;
width: number; width: number;
trimStartMs: number; trimStartMs: number;
trimEndMs: number; trimEndMs: number;
@@ -79,7 +90,9 @@ function ClipWaveform({
wavesurferRef.current = wavesurfer; wavesurferRef.current = wavesurfer;
const audioUrl = apiClient.getAudioUrl(generationId); const audioUrl = versionId
? apiClient.getVersionAudioUrl(versionId)
: apiClient.getAudioUrl(generationId);
wavesurfer.load(audioUrl).catch(() => { wavesurfer.load(audioUrl).catch(() => {
// Ignore load errors // Ignore load errors
}); });
@@ -88,7 +101,7 @@ function ClipWaveform({
wavesurfer.destroy(); wavesurfer.destroy();
wavesurferRef.current = null; wavesurferRef.current = null;
}; };
}, [generationId, fullWaveformWidth]); }, [generationId, versionId, fullWaveformWidth]);
return ( return (
<div className="w-full h-full opacity-60 overflow-hidden"> <div className="w-full h-full opacity-60 overflow-hidden">
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const splitItem = useSplitStoryItem(); const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem(); const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem(); const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const { toast } = useToast(); const { toast } = useToast();
// Selection state // Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId); const selectedClipId = useStoryStore((state) => state.selectedClipId);
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId); const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
// Selected clip item (for version picker)
const selectedItem = useMemo(
() => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
[selectedClipId, items],
);
const selectedItemVersions = selectedItem?.versions;
const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
// Determine which version label is active for the selected clip
const activeVersionLabel = useMemo(() => {
if (!selectedItem || !selectedItemVersions) return null;
// If the item has a pinned version_id, find its label
if (selectedItem.version_id) {
const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
return pinned?.label ?? null;
}
// Otherwise use the generation's default version
const defaultVersion = selectedItemVersions.find((v) => v.is_default);
return defaultVersion?.label ?? null;
}, [selectedItem, selectedItemVersions]);
const handleSetVersion = useCallback(
(versionId: string | null) => {
if (!selectedClipId) return;
setItemVersion.mutate(
{
storyId,
itemId: selectedClipId,
data: { version_id: versionId },
},
{
onError: (error) => {
toast({
title: 'Failed to set version',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
},
);
},
[selectedClipId, storyId, setItemVersion, toast],
);
// Trim state // Trim state
const [trimmingItem, setTrimmingItem] = useState<string | null>(null); const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null); const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
@@ -788,6 +846,49 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
{hasMultipleVersions && (
<>
<div className="w-px h-4 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-7 gap-1.5 px-2 text-xs"
title="Change version/take"
>
<GalleryVerticalEnd className="h-3.5 w-3.5" />
<span className="max-w-[80px] truncate">
{activeVersionLabel ?? 'default'}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[160px]">
{selectedItemVersions.map((version) => {
const isActive = selectedItem?.version_id
? version.id === selectedItem.version_id
: version.is_default;
return (
<DropdownMenuItem
key={version.id}
onClick={() => handleSetVersion(version.id)}
className="gap-2 text-xs"
>
<Check
className={cn('h-3 w-3', isActive ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{version.label}</span>
{version.effects_chain && version.effects_chain.length > 0 && (
<span className="text-muted-foreground ml-auto text-[10px]">
{version.effects_chain.length} fx
</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div> </div>
)} )}
@@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
<div className="absolute inset-0 top-3"> <div className="absolute inset-0 top-3">
<ClipWaveform <ClipWaveform
generationId={item.generation_id} generationId={item.generation_id}
versionId={item.version_id}
width={clipWidth} width={clipWidth}
trimStartMs={displayTrimStart} trimStartMs={displayTrimStart}
trimEndMs={displayTrimEnd} trimEndMs={displayTrimEnd}
+5 -6
View File
@@ -1,8 +1,7 @@
const isWindows = navigator.userAgent.includes('Windows');
export function TitleBarDragRegion() { export function TitleBarDragRegion() {
return ( if (isWindows) return null;
<div
data-tauri-drag-region return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
/>
);
} }
@@ -1,4 +1,4 @@
import { Download, Edit, Mic, Trash2 } from 'lucide-react'; import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles'; import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
interface ProfileCardProps { interface ProfileCardProps {
@@ -24,19 +23,16 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) { export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile(); const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id; const isSelected = selectedProfileId === profile.id;
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const handleSelect = () => { const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id); setSelectedProfileId(isSelected ? null : profile.id);
}; };
@@ -79,7 +75,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<Card <Card
className={cn( className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]', 'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
isSelected && 'ring-2 ring-primary shadow-md', isSelected && 'ring-2 ring-accent shadow-md',
)} )}
onClick={handleSelect} onClick={handleSelect}
tabIndex={0} tabIndex={0}
@@ -89,22 +85,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<CardHeader className="p-3 pb-2"> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium"> <CardTitle className="text-base font-medium">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isSelected && 'grayscale',
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<span className="break-words">{profile.name}</span> <span className="break-words">{profile.name}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -112,10 +93,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed"> <p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || 'No description'} {profile.description || 'No description'}
</p> </p>
<div className="mb-2"> <div className="mb-2 flex items-center gap-1.5">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground"> <Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
{profile.language} {profile.language}
</Badge> </Badge>
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
</div> </div>
<div className="flex gap-0.5 justify-end items-end mt-auto"> <div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton <CircleButton
@@ -3,6 +3,7 @@ import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -30,6 +31,8 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages'; import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
@@ -125,6 +128,8 @@ export function ProfileForm() {
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId; const isCreating = !editingProfileId;
const serverUrl = useServerStore((state) => state.serverUrl); const serverUrl = useServerStore((state) => state.serverUrl);
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -280,6 +285,8 @@ export function ProfileForm() {
referenceText: undefined, referenceText: undefined,
avatarFile: undefined, avatarFile: undefined,
}); });
setProfileEffectsChain(editingProfile.effects_chain ?? []);
setEffectsDirty(false);
} else if (profileFormDraft && open) { } else if (profileFormDraft && open) {
// Restore from draft when opening in create mode // Restore from draft when opening in create mode
form.reset({ form.reset({
@@ -435,6 +442,24 @@ export function ProfileForm() {
} }
} }
// Save effects chain if changed
if (effectsDirty) {
try {
await apiClient.updateProfileEffects(
editingProfileId,
profileEffectsChain.length > 0 ? profileEffectsChain : null,
);
} catch (fxError) {
toast({
title: 'Effects update failed',
description:
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
variant: 'destructive',
});
return;
}
}
toast({ toast({
title: 'Voice updated', title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`, description: `"${data.name}" has been updated successfully.`,
@@ -898,6 +923,23 @@ export function ProfileForm() {
</FormItem> </FormItem>
)} )}
/> />
{editingProfileId && (
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
Effects applied automatically to all new generations with this voice.
</p>
<EffectsChainEditor
value={profileEffectsChain}
onChange={(chain) => {
setProfileEffectsChain(chain);
setEffectsDirty(true);
}}
compact
/>
</div>
)}
</div> </div>
</div> </div>
@@ -0,0 +1,340 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { SampleList } from '@/components/VoiceProfiles/SampleList';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useDeleteAvatar,
useProfile,
useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const updateProfile = useUpdateProfile();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const serverUrl = useServerStore((state) => state.serverUrl);
const { toast } = useToast();
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
},
});
// Populate form when profile loads
useEffect(() => {
if (profile) {
form.reset({
name: profile.name,
description: profile.description || '',
language: profile.language as LanguageCode,
});
setEffectsChain(profile.effects_chain ?? []);
setEffectsDirty(false);
}
}, [profile, form]);
// Avatar preview
useEffect(() => {
if (profile?.avatar_path) {
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
} else {
setAvatarPreview(null);
}
setAvatarError(false);
}, [profile, serverUrl]);
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
variant: 'destructive',
});
return;
}
// Upload immediately
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: 'Avatar updated' });
},
onError: (err) => {
toast({
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
},
},
);
}
async function handleRemoveAvatar() {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: 'Avatar removed' });
} catch (err) {
toast({
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
}
}
setAvatarPreview(null);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
async function onSubmit(data: ProfileFormValues) {
try {
await updateProfile.mutateAsync({
profileId,
data: {
name: data.name,
description: data.description,
language: data.language,
},
});
if (effectsDirty) {
try {
await apiClient.updateProfileEffects(
profileId,
effectsChain.length > 0 ? effectsChain : null,
);
setEffectsDirty(false);
} catch (fxError) {
toast({
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
variant: 'destructive',
});
return;
}
}
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
}
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
);
}
const isDirty = form.formState.isDirty || effectsDirty;
return (
<div className="h-full flex flex-col overflow-hidden">
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
{/* Avatar */}
<div className="flex justify-center pt-5 pb-3">
<div className="relative group">
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview && !avatarError ? (
<img
src={avatarPreview}
alt={profile.name}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-8 w-8 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-5 w-5 text-accent-foreground" />
</button>
{avatarPreview && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
{/* Fields */}
<div className="space-y-3 px-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Effects */}
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
Applied automatically to new generations with this voice.
</p>
<EffectsChainEditor
value={effectsChain}
onChange={(chain) => {
setEffectsChain(chain);
setEffectsDirty(true);
}}
compact
/>
</div>
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</Button>
)}
</div>
{/* Samples */}
<div className="px-5 pb-5">
<SampleList profileId={profileId} />
</div>
</form>
</Form>
</div>
</div>
);
}
+110 -93
View File
@@ -1,13 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react'; import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Input } from '@/components/ui/input';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MultiSelect } from '@/components/ui/multi-select'; import { MultiSelect } from '@/components/ui/multi-select';
import { import {
Table, Table,
@@ -21,33 +17,46 @@ import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useHistory } from '@/lib/hooks/useHistory'; import { useProfiles } from '@/lib/hooks/useProfiles';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() { export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles(); const { data: profiles, isLoading } = useProfiles();
const { data: historyData } = useHistory({ limit: 1000 });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
const deleteProfile = useDeleteProfile(); const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl; const isPlayerVisible = !!audioUrl;
const [search, setSearch] = useState('');
// Get generation counts per profile const filteredProfiles = useMemo(() => {
const generationCounts = useMemo(() => { if (!profiles) return [];
const counts: Record<string, number> = {}; if (!search.trim()) return profiles;
if (historyData?.items) { const q = search.toLowerCase();
historyData.items.forEach((item) => { return profiles.filter(
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1; (p) =>
}); p.name.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.language.toLowerCase().includes(q),
);
}, [profiles, search]);
// Auto-select first profile if none selected
useEffect(() => {
if (!selectedVoiceId && profiles && profiles.length > 0) {
setSelectedVoiceId(profiles[0].id);
} }
return counts; // Clear selection if selected profile was deleted
}, [historyData]); if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
}
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
// Get channel assignments for each profile // Get channel assignments for each profile
const { data: channelAssignments } = useQuery({ const { data: channelAssignments } = useQuery({
@@ -74,17 +83,6 @@ export function VoicesTab() {
queryFn: () => apiClient.listChannels(), queryFn: () => apiClient.listChannels(),
}); });
const handleEdit = (profileId: string) => {
setEditingProfileId(profileId);
setDialogOpen(true);
};
const handleProfileDelete = async (profileId: string) => {
if (await confirm('Are you sure you want to delete this profile?')) {
deleteProfile.mutate(profileId);
}
};
const handleChannelChange = async (profileId: string, channelIds: string[]) => { const handleChannelChange = async (profileId: string, channelIds: string[]) => {
try { try {
await apiClient.setProfileChannels(profileId, channelIds); await apiClient.setProfileChannels(profileId, channelIds);
@@ -103,14 +101,26 @@ export function VoicesTab() {
} }
return ( return (
<div className="h-full flex flex-col relative overflow-hidden"> <div className="h-full flex gap-0 overflow-hidden -mx-8">
{/* Scroll Mask - Always visible, behind content */} {/* Left: Table */}
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" /> <div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */} {/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20"> <div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">Voices</h1> <h1 className="text-2xl font-bold">Voices</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search voices..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Voice New Voice
@@ -122,37 +132,45 @@ export function VoicesTab() {
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
'flex-1 overflow-y-auto pt-16 relative z-0', 'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING, isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)} )}
> >
<Table> <Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead className="w-[30%]">Name</TableHead>
<TableHead>Language</TableHead> <TableHead className="w-[10%]">Language</TableHead>
<TableHead>Generations</TableHead> <TableHead className="w-[10%]">Generations</TableHead>
<TableHead>Samples</TableHead> <TableHead className="w-[8%]">Samples</TableHead>
<TableHead>Channels</TableHead> <TableHead className="w-[8%]">Effects</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[24%]">Channels</TableHead>
<TableHead className="w-6"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{profiles?.map((profile) => ( {filteredProfiles.map((profile) => (
<VoiceRow <VoiceRow
key={profile.id} key={profile.id}
profile={profile} profile={profile}
generationCount={generationCounts[profile.id] || 0} isSelected={selectedVoiceId === profile.id}
onSelect={() => setSelectedVoiceId(profile.id)}
channelIds={channelAssignments?.[profile.id] || []} channelIds={channelAssignments?.[profile.id] || []}
channels={channels || []} channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)} onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
onEdit={() => handleEdit(profile.id)}
onDelete={() => handleProfileDelete(profile.id)}
/> />
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{/* Right: Inspector */}
{selectedVoiceId && (
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
</div>
)}
<ProfileForm /> <ProfileForm />
</div> </div>
@@ -161,42 +179,46 @@ export function VoicesTab() {
interface VoiceRowProps { interface VoiceRowProps {
profile: VoiceProfileResponse; profile: VoiceProfileResponse;
generationCount: number; isSelected: boolean;
onSelect: () => void;
channelIds: string[]; channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>; channels: Array<{ id: string; name: string; is_default: boolean }>;
onChannelChange: (channelIds: string[]) => void; onChannelChange: (channelIds: string[]) => void;
onEdit: () => void;
onDelete: () => void;
} }
function VoiceRow({ function VoiceRow({
profile, profile,
generationCount, isSelected,
onSelect,
channelIds, channelIds,
channels, channels,
onChannelChange, onChannelChange,
onEdit,
onDelete,
}: VoiceRowProps) { }: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id); const serverUrl = useServerStore((state) => state.serverUrl);
const sampleCount = samples?.length || 0; const [avatarError, setAvatarError] = useState(false);
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`; const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
return ( return (
<TableRow className="cursor-pointer" onClick={onEdit}> <TableRow
<TableCell> className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
<button onClick={onSelect}
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
aria-label={rowLabel}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
> >
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> <TableCell>
<div className="flex w-full min-w-0 items-center gap-2">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-4 w-4 text-muted-foreground" /> <Mic className="h-4 w-4 text-muted-foreground" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium truncate">{profile.name}</div> <div className="font-medium truncate">{profile.name}</div>
@@ -204,11 +226,24 @@ function VoiceRow({
<div className="text-sm text-muted-foreground truncate">{profile.description}</div> <div className="text-sm text-muted-foreground truncate">{profile.description}</div>
)} )}
</div> </div>
</button> </div>
</TableCell>
<TableCell>{profile.language}</TableCell>
<TableCell>{profile.generation_count}</TableCell>
<TableCell>{profile.sample_count}</TableCell>
<TableCell>
{enabledEffects.length > 0 ? (
<span
className="inline-flex items-center gap-1 text-xs text-accent"
title={effectsSummary}
>
<Sparkles className="h-3 w-3 fill-accent" />
{enabledEffects.length}
</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect <MultiSelect
options={channels.map((ch) => ({ options={channels.map((ch) => ({
@@ -218,28 +253,10 @@ function VoiceRow({
value={channelIds} value={channelIds}
onChange={onChannelChange} onChange={onChannelChange}
placeholder="Select channels..." placeholder="Select channels..."
className="min-w-[200px]" className="w-full"
/> />
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={onEdit}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow> </TableRow>
); );
} }
+5 -3
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -73,7 +73,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8', inset && 'pl-8',
className, className,
)} )}
@@ -154,7 +154,9 @@ const DropdownMenuSeparator = React.forwardRef<
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />; return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
}; };
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
+1 -1
View File
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground focus:[&_*]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
{...props} {...props}
+6 -5
View File
@@ -14,7 +14,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} /> <thead
ref={ref}
className={cn('[&_tr]:border-b [&_tr]:hover:bg-transparent', className)}
{...props}
/>
)); ));
TableHeader.displayName = 'TableHeader'; TableHeader.displayName = 'TableHeader';
@@ -42,10 +46,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn('border-b hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props} {...props}
/> />
), ),
+127
View File
@@ -2,9 +2,15 @@ import type { LanguageCode } from '@/lib/constants/languages';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import type { import type {
ActiveTasksResponse, ActiveTasksResponse,
ApplyEffectsRequest,
AvailableEffectsResponse,
CudaStatus, CudaStatus,
EffectConfig,
EffectPresetCreate,
EffectPresetResponse,
GenerationRequest, GenerationRequest,
GenerationResponse, GenerationResponse,
GenerationVersionResponse,
HealthResponse, HealthResponse,
HistoryListResponse, HistoryListResponse,
HistoryQuery, HistoryQuery,
@@ -21,6 +27,7 @@ import type {
StoryItemReorder, StoryItemReorder,
StoryItemSplit, StoryItemSplit,
StoryItemTrim, StoryItemTrim,
StoryItemVersionUpdate,
StoryResponse, StoryResponse,
TranscriptionResponse, TranscriptionResponse,
VoiceProfileCreate, VoiceProfileCreate,
@@ -206,6 +213,18 @@ class ApiClient {
}); });
} }
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
method: 'POST',
});
}
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
method: 'POST',
});
}
// History // History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> { async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -570,6 +589,17 @@ class ApiClient {
}); });
} }
async setStoryItemVersion(
storyId: string,
itemId: string,
data: StoryItemVersionUpdate,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async exportStoryAudio(storyId: string): Promise<Blob> { async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url); const response = await fetch(url);
@@ -583,6 +613,103 @@ class ApiClient {
return response.blob(); return response.blob();
} }
// Effects & Versions
async getAvailableEffects(): Promise<AvailableEffectsResponse> {
return this.request<AvailableEffectsResponse>('/effects/available');
}
async listEffectPresets(): Promise<EffectPresetResponse[]> {
return this.request<EffectPresetResponse[]>('/effects/presets');
}
async createEffectPreset(data: EffectPresetCreate): Promise<EffectPresetResponse> {
return this.request<EffectPresetResponse>('/effects/presets', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateEffectPreset(
presetId: string,
data: { name?: string; description?: string; effects_chain?: EffectConfig[] },
): Promise<EffectPresetResponse> {
return this.request<EffectPresetResponse>(`/effects/presets/${presetId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteEffectPreset(presetId: string): Promise<void> {
await this.request<void>(`/effects/presets/${presetId}`, {
method: 'DELETE',
});
}
async listGenerationVersions(generationId: string): Promise<GenerationVersionResponse[]> {
return this.request<GenerationVersionResponse[]>(`/generations/${generationId}/versions`);
}
async applyEffectsToGeneration(
generationId: string,
data: ApplyEffectsRequest,
): Promise<GenerationVersionResponse> {
return this.request<GenerationVersionResponse>(
`/generations/${generationId}/versions/apply-effects`,
{
method: 'POST',
body: JSON.stringify(data),
},
);
}
async setDefaultVersion(
generationId: string,
versionId: string,
): Promise<GenerationVersionResponse> {
return this.request<GenerationVersionResponse>(
`/generations/${generationId}/versions/${versionId}/set-default`,
{ method: 'PUT' },
);
}
async deleteGenerationVersion(generationId: string, versionId: string): Promise<void> {
await this.request<void>(`/generations/${generationId}/versions/${versionId}`, {
method: 'DELETE',
});
}
getVersionAudioUrl(versionId: string): string {
return `${this.getBaseUrl()}/audio/version/${versionId}`;
}
async updateProfileEffects(
profileId: string,
effectsChain: EffectConfig[] | null,
): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}/effects`, {
method: 'PUT',
body: JSON.stringify({ effects_chain: effectsChain }),
});
}
async previewEffects(generationId: string, effectsChain: EffectConfig[]): Promise<Blob> {
const url = `${this.getBaseUrl()}/effects/preview/${generationId}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ effects_chain: effectsChain }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();
+82
View File
@@ -13,6 +13,9 @@ export interface VoiceProfileResponse {
description?: string; description?: string;
language: string; language: string;
avatar_path?: string; avatar_path?: string;
effects_chain?: EffectConfig[];
generation_count: number;
sample_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -28,6 +31,12 @@ export interface ProfileSampleResponse {
reference_text: string; reference_text: string;
} }
export interface EffectConfig {
type: string;
enabled: boolean;
params: Record<string, number>;
}
export interface GenerationRequest { export interface GenerationRequest {
profile_id: string; profile_id: string;
text: string; text: string;
@@ -39,6 +48,18 @@ export interface GenerationRequest {
max_chunk_chars?: number; max_chunk_chars?: number;
crossfade_ms?: number; crossfade_ms?: number;
normalize?: boolean; normalize?: boolean;
effects_chain?: EffectConfig[];
}
export interface GenerationVersionResponse {
id: string;
generation_id: string;
label: string;
audio_path: string;
effects_chain?: EffectConfig[];
source_version_id?: string;
is_default: boolean;
created_at: string;
} }
export interface GenerationResponse { export interface GenerationResponse {
@@ -54,7 +75,10 @@ export interface GenerationResponse {
model_size?: string; model_size?: string;
status: 'generating' | 'completed' | 'failed'; status: 'generating' | 'completed' | 'failed';
error?: string; error?: string;
is_favorited?: boolean;
created_at: string; created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
} }
export interface HistoryQuery { export interface HistoryQuery {
@@ -66,6 +90,8 @@ export interface HistoryQuery {
export interface HistoryResponse extends GenerationResponse { export interface HistoryResponse extends GenerationResponse {
profile_name: string; profile_name: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
} }
export interface HistoryListResponse { export interface HistoryListResponse {
@@ -199,6 +225,7 @@ export interface StoryItemDetail {
id: string; id: string;
story_id: string; story_id: string;
generation_id: string; generation_id: string;
version_id?: string;
start_time_ms: number; start_time_ms: number;
track: number; track: number;
trim_start_ms: number; trim_start_ms: number;
@@ -213,6 +240,12 @@ export interface StoryItemDetail {
seed?: number; seed?: number;
instruct?: string; instruct?: string;
generation_created_at: string; generation_created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface StoryItemVersionUpdate {
version_id: string | null;
} }
export interface StoryDetailResponse { export interface StoryDetailResponse {
@@ -256,3 +289,52 @@ export interface StoryItemTrim {
export interface StoryItemSplit { export interface StoryItemSplit {
split_time_ms: number; split_time_ms: number;
} }
// Effects
export interface EffectPresetResponse {
id: string;
name: string;
description?: string;
effects_chain: EffectConfig[];
is_builtin: boolean;
created_at: string;
}
export interface EffectPresetCreate {
name: string;
description?: string;
effects_chain: EffectConfig[];
}
export interface EffectPresetUpdate {
name?: string;
description?: string;
effects_chain?: EffectConfig[];
}
export interface AvailableEffectParam {
default: number;
min: number;
max: number;
step: number;
description: string;
}
export interface AvailableEffect {
type: string;
label: string;
description: string;
params: Record<string, AvailableEffectParam>;
}
export interface AvailableEffectsResponse {
effects: AvailableEffect[];
}
export interface ApplyEffectsRequest {
effects_chain: EffectConfig[];
source_version_id?: string;
label?: string;
set_as_default?: boolean;
}
+5 -2
View File
@@ -2,11 +2,14 @@
* UI layout constants for safe area padding * UI layout constants for safe area padding
*/ */
const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
/** /**
* Top safe area padding - height of the drag region bar * Top safe area padding - height of the drag region bar
* Corresponds to Tailwind's pt-12 (3rem / 48px) * On macOS this accounts for the overlay titlebar (48px).
* On Windows the native title bar is outside the webview, so no padding is needed.
*/ */
export const TOP_SAFE_AREA_PADDING = 'pt-12'; export const TOP_SAFE_AREA_PADDING = isWindows ? 'pt-8' : 'pt-12';
/** /**
* Bottom safe area padding - height of the audio player * Bottom safe area padding - height of the audio player
+5 -1
View File
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages'; import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration'; import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
@@ -11,7 +12,7 @@ import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
const generationSchema = z.object({ const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(50000), text: z.string().min(1, '').max(50000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(), seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(), modelSize: z.enum(['1.7B', '0.6B']).optional(),
@@ -24,6 +25,7 @@ export type GenerationFormValues = z.infer<typeof generationSchema>;
interface UseGenerationFormOptions { interface UseGenerationFormOptions {
onSuccess?: (generationId: string) => void; onSuccess?: (generationId: string) => void;
defaultValues?: Partial<GenerationFormValues>; defaultValues?: Partial<GenerationFormValues>;
getEffectsChain?: () => EffectConfig[] | undefined;
} }
export function useGenerationForm(options: UseGenerationFormOptions = {}) { export function useGenerationForm(options: UseGenerationFormOptions = {}) {
@@ -103,6 +105,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
} }
const isQwen = engine === 'qwen'; const isQwen = engine === 'qwen';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating" // This now returns immediately with status="generating"
const result = await generation.mutateAsync({ const result = await generation.mutateAsync({
profile_id: selectedProfileId, profile_id: selectedProfileId,
@@ -115,6 +118,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
max_chunk_chars: maxChunkChars, max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs, crossfade_ms: crossfadeMs,
normalize: normalizeAudio, normalize: normalizeAudio,
effects_chain: effectsChain?.length ? effectsChain : undefined,
}); });
// Track this generation for SSE status updates // Track this generation for SSE status updates
+61 -8
View File
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types'; import type {
StoryCreate,
StoryItemBatchUpdate,
StoryItemCreate,
StoryItemMove,
StoryItemReorder,
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
} from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
export function useStories() { export function useStories() {
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) => mutationFn: ({
apiClient.moveStoryItem(storyId, itemId, data), storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemMove;
}) => apiClient.moveStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) => mutationFn: ({
apiClient.trimStoryItem(storyId, itemId, data), storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemTrim;
}) => apiClient.trimStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) => mutationFn: ({
apiClient.splitStoryItem(storyId, itemId, data), storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemSplit;
}) => apiClient.splitStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -157,6 +187,26 @@ export function useDuplicateStoryItem() {
}); });
} }
export function useSetStoryItemVersion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemVersionUpdate;
}) => apiClient.setStoryItemVersion(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useExportStoryAudio() { export function useExportStoryAudio() {
const platform = usePlatform(); const platform = usePlatform();
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
const blob = await apiClient.exportStoryAudio(storyId); const blob = await apiClient.exportStoryAudio(storyId);
// Create safe filename // Create safe filename
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase(); const safeName = storyName
.substring(0, 50)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `${safeName || 'story'}.wav`; const filename = `${safeName || 'story'}.wav`;
await platform.filesystem.saveFile(filename, blob, [ await platform.filesystem.saveFile(filename, blob, [
+22 -10
View File
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
} }
}, []); }, []);
// Resolve the audio buffer key and URL for an item.
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
const getAudioKey = (item: StoryItemDetail) =>
item.version_id ? `v:${item.version_id}` : item.generation_id;
const getAudioUrlForItem = (item: StoryItemDetail) =>
item.version_id
? apiClient.getVersionAudioUrl(item.version_id)
: apiClient.getAudioUrl(item.generation_id);
// Preload audio files as AudioBuffers // Preload audio files as AudioBuffers
useEffect(() => { useEffect(() => {
if (!items || items.length === 0) { if (!items || items.length === 0) {
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return; return;
} }
const currentIds = new Set(items.map((item) => item.generation_id)); const currentKeys = new Set(items.map(getAudioKey));
const audioContext = getAudioContext(); const audioContext = getAudioContext();
// Remove buffers for items that no longer exist // Remove buffers for items that no longer exist
for (const [id] of audioBuffersRef.current) { for (const [id] of audioBuffersRef.current) {
if (!currentIds.has(id)) { if (!currentKeys.has(id)) {
audioBuffersRef.current.delete(id); audioBuffersRef.current.delete(id);
} }
} }
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Preload audio for new items // Preload audio for new items
const preloadPromises: Promise<void>[] = []; const preloadPromises: Promise<void>[] = [];
for (const item of items) { for (const item of items) {
if (!audioBuffersRef.current.has(item.generation_id)) { const key = getAudioKey(item);
const audioUrl = apiClient.getAudioUrl(item.generation_id); if (!audioBuffersRef.current.has(key)) {
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id); const audioUrl = getAudioUrlForItem(item);
console.log('[StoryPlayback] Preloading audio buffer:', key);
const preloadPromise = fetch(audioUrl) const preloadPromise = fetch(audioUrl)
.then((response) => response.arrayBuffer()) .then((response) => response.arrayBuffer())
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)) .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
.then((audioBuffer) => { .then((audioBuffer) => {
audioBuffersRef.current.set(item.generation_id, audioBuffer); audioBuffersRef.current.set(key, audioBuffer);
console.log( console.log(
'[StoryPlayback] Preloaded buffer:', '[StoryPlayback] Preloaded buffer:',
item.generation_id, key,
'duration:', 'duration:',
audioBuffer.duration, audioBuffer.duration,
); );
}) })
.catch((err) => { .catch((err) => {
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err); console.error('[StoryPlayback] Failed to preload audio:', key, err);
}); });
preloadPromises.push(preloadPromise); preloadPromises.push(preloadPromise);
@@ -216,9 +227,10 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Schedule new sources for items that should be playing // Schedule new sources for items that should be playing
for (const item of shouldBePlaying) { for (const item of shouldBePlaying) {
if (!activeSourcesRef.current.has(item.id)) { if (!activeSourcesRef.current.has(item.id)) {
const buffer = audioBuffersRef.current.get(item.generation_id); const bufferKey = getAudioKey(item);
const buffer = audioBuffersRef.current.get(bufferKey);
if (!buffer) { if (!buffer) {
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id); console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
continue; continue;
} }
+2
View File
@@ -10,6 +10,8 @@ export interface FileFilter {
export interface PlatformFilesystem { export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>; saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
openPath(path: string): Promise<void>;
pickDirectory(title: string): Promise<string | null>;
} }
export interface UpdateStatus { export interface UpdateStatus {
+9
View File
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'; import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame'; import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab'; import { AudioTab } from '@/components/AudioTab/AudioTab';
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
import { MainEditor } from '@/components/MainEditor/MainEditor'; import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab'; import { ServerTab } from '@/components/ServerTab/ServerTab';
@@ -105,6 +106,13 @@ const audioRoute = createRoute({
component: AudioTab, component: AudioTab,
}); });
// Effects route
const effectsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/effects',
component: EffectsTab,
});
// Models route // Models route
const modelsRoute = createRoute({ const modelsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -125,6 +133,7 @@ const routeTree = rootRoute.addChildren([
storiesRoute, storiesRoute,
voicesRoute, voicesRoute,
audioRoute, audioRoute,
effectsRoute,
modelsRoute, modelsRoute,
serverRoute, serverRoute,
]); ]);
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { EffectConfig } from '@/lib/api/types';
interface EffectsStore {
selectedPresetId: string | null;
setSelectedPresetId: (id: string | null) => void;
// Working chain for the detail panel (editing a preset or building a new one)
workingChain: EffectConfig[];
setWorkingChain: (chain: EffectConfig[]) => void;
// Track if editing an existing preset vs creating new
isCreatingNew: boolean;
setIsCreatingNew: (v: boolean) => void;
}
export const useEffectsStore = create<EffectsStore>((set) => ({
selectedPresetId: null,
setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
workingChain: [],
setWorkingChain: (chain) => set({ workingChain: chain }),
isCreatingNew: false,
setIsCreatingNew: (v) => set({ isCreatingNew: v, ...(v && { selectedPresetId: null }) }),
}));
+7
View File
@@ -31,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null; selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void; setSelectedProfileId: (id: string | null) => void;
// Selected voice in Voices tab inspector
selectedVoiceId: string | null;
setSelectedVoiceId: (id: string | null) => void;
// Profile form draft (for persisting create voice modal state) // Profile form draft (for persisting create voice modal state)
profileFormDraft: ProfileFormDraft | null; profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void; setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
@@ -55,6 +59,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null, selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }), setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
profileFormDraft: null, profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package # Backend package
__version__ = "0.1.13" __version__ = "0.2.0"
+12
View File
@@ -90,6 +90,18 @@ def build_server(cuda=False):
'--hidden-import', 'torch.cuda', '--hidden-import', 'torch.cuda',
'--hidden-import', 'torch.backends.cudnn', '--hidden-import', 'torch.backends.cudnn',
]) ])
else:
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary under 4GB.
# On Linux, pip may pull CUDA-enabled PyTorch by default which includes ~3GB
# of NVIDIA shared libraries that PyInstaller would bundle.
nvidia_packages = [
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink',
'nvidia.nvtx',
]
for pkg in nvidia_packages:
args.extend(['--exclude-module', pkg])
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds) # Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
if is_apple_silicon() and not cuda: if is_apple_silicon() and not cuda:
+155
View File
@@ -23,6 +23,7 @@ class VoiceProfile(Base):
description = Column(Text) description = Column(Text)
language = Column(String, default="en") language = Column(String, default="en")
avatar_path = Column(String, nullable=True) avatar_path = Column(String, nullable=True)
effects_chain = Column(Text, nullable=True) # JSON-serialized default effects chain
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -53,6 +54,7 @@ class Generation(Base):
model_size = Column(String, nullable=True) model_size = Column(String, nullable=True)
status = Column(String, default="completed") # generating, completed, failed status = Column(String, default="completed") # generating, completed, failed
error = Column(Text, nullable=True) error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -74,6 +76,7 @@ class StoryItem(Base):
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
story_id = Column(String, ForeignKey("stories.id"), nullable=False) story_id = Column(String, ForeignKey("stories.id"), nullable=False)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False) generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Pin to specific version, null = use generation default
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track) track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
@@ -92,6 +95,33 @@ class Project(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class GenerationVersion(Base):
"""A version of a generation's audio (clean, processed, alternate takes)."""
__tablename__ = "generation_versions"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
label = Column(String, nullable=False) # "clean", "processed", or user-defined
audio_path = Column(String, nullable=False)
effects_chain = Column(Text, nullable=True) # JSON-serialized effects config, null for clean
source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Which version was used as input
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class EffectPreset(Base):
"""Saved effect chain preset."""
__tablename__ = "effect_presets"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False)
description = Column(Text, nullable=True)
effects_chain = Column(Text, nullable=False) # JSON-serialized effects config
is_builtin = Column(Boolean, default=False)
sort_order = Column(Integer, default=100)
created_at = Column(DateTime, default=datetime.utcnow)
class AudioChannel(Base): class AudioChannel(Base):
"""Audio channel (bus) database model.""" """Audio channel (bus) database model."""
__tablename__ = "audio_channels" __tablename__ = "audio_channels"
@@ -169,6 +199,12 @@ def init_db():
finally: finally:
db.close() db.close()
# Backfill: create "clean" GenerationVersion entries for existing generations
_backfill_generation_versions()
# Seed built-in effect presets
_seed_builtin_presets()
def _run_migrations(engine): def _run_migrations(engine):
"""Run database migrations.""" """Run database migrations."""
@@ -322,6 +358,125 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added model_size column to generations") print("Added model_size column to generations")
# Migration: Add effects_chain to profiles table
if 'profiles' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('profiles')}
if 'effects_chain' not in columns:
print("Migrating profiles: adding effects_chain column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE profiles ADD COLUMN effects_chain TEXT"))
conn.commit()
print("Added effects_chain column to profiles")
# Migration: Add sort_order to effect_presets table
if 'effect_presets' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('effect_presets')}
if 'sort_order' not in columns:
print("Migrating effect_presets: adding sort_order column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE effect_presets ADD COLUMN sort_order INTEGER DEFAULT 100"))
conn.commit()
print("Added sort_order column to effect_presets")
# Migration: Add version_id column to story_items table
if 'story_items' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('story_items')}
if 'version_id' not in columns:
print("Migrating story_items: adding version_id column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE story_items ADD COLUMN version_id VARCHAR"))
conn.commit()
print("Added version_id column to story_items")
# Migration: Add source_version_id to generation_versions table
if 'generation_versions' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generation_versions')}
if 'source_version_id' not in columns:
print("Migrating generation_versions: adding source_version_id column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generation_versions ADD COLUMN source_version_id VARCHAR"))
conn.commit()
print("Added source_version_id column to generation_versions")
if 'generations' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'is_favorited' not in columns:
print("Migrating generations: adding is_favorited column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN is_favorited BOOLEAN DEFAULT 0"))
conn.commit()
print("Added is_favorited column to generations")
# Migration: Create generation_versions for existing generations
# (populate after tables are created, handled in init_db)
def _backfill_generation_versions():
"""Create 'clean' version entries for existing generations that don't have any."""
db = SessionLocal()
try:
from pathlib import Path as _Path
# Find generations that have no version entries
existing_version_gen_ids = {
row[0] for row in db.query(GenerationVersion.generation_id).all()
}
generations = db.query(Generation).filter(
Generation.status == "completed",
Generation.audio_path.isnot(None),
Generation.audio_path != "",
).all()
count = 0
for gen in generations:
if gen.id in existing_version_gen_ids:
continue
if not _Path(gen.audio_path).exists():
continue
version = GenerationVersion(
id=str(uuid.uuid4()),
generation_id=gen.id,
label="clean",
audio_path=gen.audio_path,
effects_chain=None,
is_default=True,
)
db.add(version)
count += 1
if count > 0:
db.commit()
print(f"Backfilled {count} generation version entries")
finally:
db.close()
def _seed_builtin_presets():
"""Ensure built-in effect presets exist in the database."""
import json
from .utils.effects import BUILTIN_PRESETS
db = SessionLocal()
try:
for idx, (key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
sort_order = preset_data.get("sort_order", idx)
existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first()
if not existing:
preset = EffectPreset(
id=str(uuid.uuid4()),
name=preset_data["name"],
description=preset_data.get("description"),
effects_chain=json.dumps(preset_data["effects_chain"]),
is_builtin=True,
sort_order=sort_order,
)
db.add(preset)
elif existing.sort_order != sort_order:
existing.sort_order = sort_order
db.commit()
finally:
db.close()
def get_db(): def get_db():
"""Get database session (generator for dependency injection).""" """Get database session (generator for dependency injection)."""
+120
View File
@@ -0,0 +1,120 @@
"""
Effect presets CRUD operations.
"""
from __future__ import annotations
import json
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .database import EffectPreset as DBEffectPreset
from .models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
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)]
return EffectPresetResponse(
id=p.id,
name=p.name,
description=p.description,
effects_chain=effects_chain,
is_builtin=p.is_builtin or False,
created_at=p.created_at,
)
def list_presets(db: Session) -> List[EffectPresetResponse]:
"""List all effect presets (built-in + user-created)."""
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
return [_preset_response(p) for p in presets]
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
"""Get a preset by ID."""
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not p:
return None
return _preset_response(p)
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
"""Get a preset by name."""
p = db.query(DBEffectPreset).filter_by(name=name).first()
if not p:
return None
return _preset_response(p)
def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
"""Create a new user effect preset."""
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)
# Check for duplicate name before insert
existing = db.query(DBEffectPreset).filter_by(name=data.name).first()
if existing:
raise ValueError(f"A preset named '{data.name}' already exists")
preset = DBEffectPreset(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
effects_chain=json.dumps(chain_dicts),
is_builtin=False,
)
db.add(preset)
try:
db.commit()
except IntegrityError:
db.rollback()
raise ValueError(f"A preset named '{data.name}' already exists")
db.refresh(preset)
return _preset_response(preset)
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
"""Update a user effect preset. Cannot modify built-in presets."""
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not preset:
return None
if preset.is_builtin:
raise ValueError("Cannot modify built-in presets")
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)
def delete_preset(preset_id: str, db: Session) -> bool:
"""Delete a user effect preset. Cannot delete built-in presets."""
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not preset:
return False
if preset.is_builtin:
raise ValueError("Cannot delete built-in presets")
db.delete(preset)
db.commit()
return True
+36 -10
View File
@@ -13,7 +13,7 @@ from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import VoiceProfileResponse from .models import VoiceProfileResponse
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion
from .profiles import create_profile, add_profile_sample from .profiles import create_profile, add_profile_sample
from .models import VoiceProfileCreate from .models import VoiceProfileCreate
from . import config from . import config
@@ -269,16 +269,33 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
if not profile: if not profile:
raise ValueError(f"Profile {generation.profile_id} not found") raise ValueError(f"Profile {generation.profile_id} not found")
# Get audio file # Get all versions for this generation
audio_path = Path(generation.audio_path) versions = (
if not audio_path.exists(): db.query(DBGenerationVersion)
raise ValueError(f"Audio file not found: {audio_path}") .filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.all()
)
# Create ZIP in memory # Create ZIP in memory
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Create manifest.json # 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,
})
manifest = { manifest = {
"version": "1.0", "version": "1.0",
"generation": { "generation": {
@@ -295,13 +312,22 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"name": profile.name, "name": profile.name,
"description": profile.description, "description": profile.description,
"language": profile.language, "language": profile.language,
} },
"versions": version_entries,
} }
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2)) zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
# Add audio file # Add all version audio files
filename = audio_path.name for v in versions:
zip_file.write(audio_path, f"audio/{filename}") v_path = Path(v.audio_path)
if v_path.exists():
zip_file.write(v_path, f"audio/{v_path.name}")
# Fallback: if no versions exist, include the generation's main audio
if not versions:
audio_path = Path(generation.audio_path)
if audio_path.exists():
zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0) zip_buffer.seek(0)
return zip_buffer.read() return zip_buffer.read()
+49 -3
View File
@@ -10,8 +10,8 @@ from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
from . import config from . import config
@@ -20,6 +20,43 @@ def _get_generations_dir() -> Path:
return config.get_generations_dir() return config.get_generations_dir()
def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
"""Get versions list and active version ID for a generation."""
import json
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 = []
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
return versions, active_version_id
async def create_generation( async def create_generation(
profile_id: str, profile_id: str,
text: str, text: str,
@@ -170,6 +207,7 @@ async def list_generations(
# Convert to HistoryResponse with profile_name # Convert to HistoryResponse with profile_name
items = [] items = []
for generation, profile_name in results: for generation, profile_name in results:
versions, active_version_id = _get_versions_for_generation(generation.id, db)
items.append(HistoryResponse( items.append(HistoryResponse(
id=generation.id, id=generation.id,
profile_id=generation.profile_id, profile_id=generation.profile_id,
@@ -184,7 +222,10 @@ async def list_generations(
model_size=generation.model_size, model_size=generation.model_size,
status=generation.status or "completed", status=generation.status or "completed",
error=generation.error, error=generation.error,
is_favorited=bool(generation.is_favorited),
created_at=generation.created_at, created_at=generation.created_at,
versions=versions,
active_version_id=active_version_id,
)) ))
return HistoryListResponse( return HistoryListResponse(
@@ -211,7 +252,12 @@ async def delete_generation(
if not generation: if not generation:
return False return False
# Delete audio file # Delete all version files and records
from . import versions as versions_mod
versions_mod.delete_versions_for_generation(generation_id, db)
# Delete main audio file (if not already removed by version cleanup)
if generation.audio_path:
audio_path = Path(generation.audio_path) audio_path = Path(generation.audio_path)
if audio_path.exists(): if audio_path.exists():
audio_path.unlink() audio_path.unlink()
+532 -6
View File
@@ -263,7 +263,7 @@ async def health():
gpu_type=gpu_type, gpu_type=gpu_type,
vram_used_mb=vram_used, vram_used_mb=vram_used,
backend_type=backend_type, backend_type=backend_type,
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cpu"), backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
) )
@@ -757,6 +757,20 @@ async def generate_speech(
text=data.text, text=data.text,
) )
# Resolve effects chain: explicit request > profile default > none
effects_chain_config = None
if data.effects_chain is not None:
effects_chain_config = [e.model_dump() for e in data.effects_chain]
else:
# Check profile default
import json as _json
profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first()
if profile_obj and profile_obj.effects_chain:
try:
effects_chain_config = _json.loads(profile_obj.effects_chain)
except Exception:
pass
# Kick off TTS in background # Kick off TTS in background
async def _run_generation(): async def _run_generation():
bg_db = next(get_db()) bg_db = next(get_db())
@@ -799,17 +813,55 @@ async def generate_speech(
audio = normalize_audio(audio) audio = normalize_audio(audio)
duration = len(audio) / sample_rate duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
# Always save clean version first
clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate) save_audio(audio, str(clean_audio_path), sample_rate)
from . import versions as versions_mod
has_effects = 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)
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="version-2",
audio_path=str(processed_path),
db=bg_db,
effects_chain=effects_chain_config,
is_default=True,
)
# Update the record to completed # Update the record to completed
await history.update_generation_status( await history.update_generation_status(
generation_id=generation_id, generation_id=generation_id,
status="completed", status="completed",
db=bg_db, db=bg_db,
audio_path=str(audio_path), audio_path=final_audio_path,
duration=duration, duration=duration,
) )
@@ -926,6 +978,118 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
return models.GenerationResponse.model_validate(gen) return models.GenerationResponse.model_validate(gen)
@app.post(
"/generate/{generation_id}/regenerate",
response_model=models.GenerationResponse,
)
async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)):
"""Re-run TTS with the same parameters and save the result as a new version."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation must be completed to regenerate")
from .backends import get_tts_backend_for_engine
from . import versions as versions_mod
regen_engine = gen.engine or "qwen"
regen_model_size = gen.model_size or "1.7B"
tts_model = get_tts_backend_for_engine(regen_engine)
# Set to generating so the UI shows the loader and SSE picks it up
gen.status = "generating"
gen.error = None
db.commit()
db.refresh(gen)
task_manager = get_task_manager()
task_manager.start_generation(
task_id=generation_id,
profile_id=gen.profile_id,
text=gen.text,
)
version_id = str(uuid.uuid4())
async def _run_regenerate():
bg_db = next(get_db())
try:
if regen_engine == "qwen":
await tts_model.load_model_async(regen_model_size)
else:
await tts_model.load_model()
voice_prompt = await profiles.create_voice_prompt_for_profile(
gen.profile_id,
bg_db,
use_cache=True,
engine=regen_engine,
)
from .utils.chunked_tts import generate_chunked
trim_fn = None
if regen_engine in ("chatterbox", "chatterbox_turbo"):
from .utils.audio import trim_tts_output
trim_fn = trim_tts_output
audio, sample_rate = await generate_chunked(
tts_model,
gen.text,
voice_prompt,
language=gen.language,
seed=None, # New seed for variation
instruct=gen.instruct,
trim_fn=trim_fn,
)
from .utils.audio import normalize_audio, save_audio
audio = normalize_audio(audio)
duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
save_audio(audio, str(audio_path), sample_rate)
# Count existing versions to auto-label
existing = versions_mod.list_versions(generation_id, bg_db)
label = f"take-{len(existing) + 1}"
versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(audio_path),
db=bg_db,
effects_chain=None,
is_default=True,
)
await history.update_generation_status(
generation_id=generation_id,
status="completed",
db=bg_db,
audio_path=str(audio_path),
duration=duration,
)
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),
)
finally:
task_manager.complete_generation(generation_id)
bg_db.close()
_enqueue_generation(_run_regenerate())
return models.GenerationResponse.model_validate(gen)
@app.get("/generate/{generation_id}/status") @app.get("/generate/{generation_id}/status")
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)): async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
"""SSE endpoint that streams generation status updates. """SSE endpoint that streams generation status updates.
@@ -1150,6 +1314,20 @@ async def get_generation(
) )
@app.post("/history/{generation_id}/favorite")
async def toggle_favorite(
generation_id: str,
db: Session = Depends(get_db),
):
"""Toggle the favorite status of a generation."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
gen.is_favorited = not gen.is_favorited
db.commit()
return {"is_favorited": gen.is_favorited}
@app.delete("/history/{generation_id}") @app.delete("/history/{generation_id}")
async def delete_generation( async def delete_generation(
generation_id: str, generation_id: str,
@@ -1245,7 +1423,7 @@ async def transcribe_audio(
try: try:
# Get audio duration # Get audio duration
from .utils.audio import load_audio from .utils.audio import load_audio
audio, sr = load_audio(tmp_path) audio, sr = await asyncio.to_thread(load_audio, tmp_path)
duration = len(audio) / sr duration = len(audio) / sr
# Transcribe # Transcribe
@@ -1466,6 +1644,20 @@ async def duplicate_story_item(
return item return item
@app.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail)
async def set_story_item_version(
story_id: str,
item_id: str,
data: models.StoryItemVersionUpdate,
db: Session = Depends(get_db),
):
"""Pin a story item to a specific generation version."""
item = await stories.set_story_item_version(story_id, item_id, data, db)
if item is None:
raise HTTPException(status_code=404, detail="Story item or version not found")
return item
@app.get("/stories/{story_id}/export-audio") @app.get("/stories/{story_id}/export-audio")
async def export_story_audio( async def export_story_audio(
story_id: str, story_id: str,
@@ -1503,13 +1695,347 @@ async def export_story_audio(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ============================================
# EFFECTS & VERSIONS
# ============================================
@app.post("/effects/preview/{generation_id}")
async def preview_effects(
generation_id: str,
data: models.ApplyEffectsRequest,
db: Session = Depends(get_db),
):
"""Apply effects to a generation's clean audio and stream back the result without saving.
Used for ephemeral preview/auditioning of effects chains.
"""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation is not completed")
from . import versions as versions_mod
from .utils.effects import apply_effects, validate_effects_chain
from .utils.audio import load_audio
# Validate chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise HTTPException(status_code=400, detail=error)
# Find the original unprocessed version (no effects applied)
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next((v for v in all_versions if v.effects_chain is None), None)
source_path = clean_version.audio_path if clean_version else gen.audio_path
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Process in memory (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Write to in-memory buffer
import soundfile as sf
buf = io.BytesIO()
await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
buf.seek(0)
return StreamingResponse(
buf,
media_type="audio/wav",
headers={
"Content-Disposition": f'inline; filename="preview_{generation_id}.wav"',
"Cache-Control": "no-cache, no-store",
},
)
@app.get("/effects/available", response_model=models.AvailableEffectsResponse)
async def get_available_effects():
"""List all available effect types with parameter definitions."""
from .utils.effects import get_available_effects as _get_effects
return models.AvailableEffectsResponse(effects=[
models.AvailableEffect(**e) for e in _get_effects()
])
@app.get("/effects/presets", response_model=List[models.EffectPresetResponse])
async def list_effect_presets(db: Session = Depends(get_db)):
"""List all effect presets (built-in + user-created)."""
from . import effects as effects_mod
return effects_mod.list_presets(db)
@app.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)):
"""Get a specific effect preset."""
from . import effects as effects_mod
preset = effects_mod.get_preset(preset_id, db)
if not preset:
raise HTTPException(status_code=404, detail="Preset not found")
return preset
@app.post("/effects/presets", response_model=models.EffectPresetResponse)
async def create_effect_preset(
data: models.EffectPresetCreate,
db: Session = Depends(get_db),
):
"""Create a new effect preset."""
from . import effects as effects_mod
try:
return effects_mod.create_preset(data, db)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
async def update_effect_preset(
preset_id: str,
data: models.EffectPresetUpdate,
db: Session = Depends(get_db),
):
"""Update an effect preset."""
from . import effects as effects_mod
try:
result = effects_mod.update_preset(preset_id, data, db)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/effects/presets/{preset_id}")
async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)):
"""Delete a user effect preset."""
from . import effects as effects_mod
try:
if not effects_mod.delete_preset(preset_id, db):
raise HTTPException(status_code=404, detail="Preset not found")
return {"status": "deleted"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get(
"/generations/{generation_id}/versions",
response_model=List[models.GenerationVersionResponse],
)
async def list_generation_versions(
generation_id: str,
db: Session = Depends(get_db),
):
"""List all versions for a generation."""
gen = await history.get_generation(generation_id, db)
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
from . import versions as versions_mod
return versions_mod.list_versions(generation_id, db)
@app.post(
"/generations/{generation_id}/versions/apply-effects",
response_model=models.GenerationVersionResponse,
)
async def apply_effects_to_generation(
generation_id: str,
data: models.ApplyEffectsRequest,
db: Session = Depends(get_db),
):
"""Apply an effects chain to an existing generation, creating a new version."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation is not completed")
from . import versions as versions_mod
from .utils.effects import apply_effects, validate_effects_chain
from .utils.audio import load_audio, save_audio
# Validate effects chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise HTTPException(status_code=400, detail=error)
# Determine source audio: use specified version, or fall back to clean/original
all_versions = versions_mod.list_versions(generation_id, db)
source_version_id = data.source_version_id
if source_version_id:
source_version = next(
(v for v in all_versions if v.id == source_version_id), None
)
if not source_version:
raise HTTPException(status_code=404, detail="Source version not found")
source_path = source_version.audio_path
else:
clean_version = next(
(v for v in all_versions if v.effects_chain is None), None
)
if not clean_version:
source_path = gen.audio_path
else:
source_path = clean_version.audio_path
source_version_id = clean_version.id
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Load, process, save (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Generate a unique filename
version_id = str(uuid.uuid4())
processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate)
# Auto-label
label = data.label or f"version-{len(all_versions) + 1}"
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,
source_version_id=source_version_id,
)
return version
@app.put(
"/generations/{generation_id}/versions/{version_id}/set-default",
response_model=models.GenerationVersionResponse,
)
async def set_default_version(
generation_id: str,
version_id: str,
db: Session = Depends(get_db),
):
"""Set a specific version as the default for a generation."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version or version.generation_id != generation_id:
raise HTTPException(status_code=404, detail="Version not found")
result = versions_mod.set_default_version(version_id, db)
if not result:
raise HTTPException(status_code=404, detail="Version not found")
return result
@app.delete("/generations/{generation_id}/versions/{version_id}")
async def delete_generation_version(
generation_id: str,
version_id: str,
db: Session = Depends(get_db),
):
"""Delete a version. Cannot delete the last remaining version."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version or version.generation_id != generation_id:
raise HTTPException(status_code=404, detail="Version not found")
if not versions_mod.delete_version(version_id, db):
raise HTTPException(
status_code=400,
detail="Cannot delete the last remaining version",
)
return {"status": "deleted"}
@app.get("/audio/version/{version_id}")
async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
"""Serve audio for a specific version."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
audio_path = Path(version.audio_path)
if not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
audio_path,
media_type="audio/wav",
filename=f"generation_{version.generation_id}_{version.label}.wav",
)
@app.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse)
async def update_profile_effects(
profile_id: str,
data: models.ProfileEffectsUpdate,
db: Session = Depends(get_db),
):
"""Set or clear the default effects chain for a voice profile."""
import json as _json
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
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 HTTPException(status_code=400, detail=error)
profile.effects_chain = _json.dumps(chain_dicts)
else:
profile.effects_chain = None
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return _profile_to_response(profile)
def _profile_to_response(profile) -> models.VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse with parsed effects_chain."""
import json as _json
import logging
effects_chain = None
if profile.effects_chain:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [models.EffectConfig(**e) for e in raw]
except Exception as e:
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return models.VoiceProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description,
language=profile.language,
avatar_path=profile.avatar_path,
effects_chain=effects_chain,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
# ============================================ # ============================================
# FILE SERVING # FILE SERVING
# ============================================ # ============================================
@app.get("/audio/{generation_id}") @app.get("/audio/{generation_id}")
async def get_audio(generation_id: str, db: Session = Depends(get_db)): async def get_audio(generation_id: str, db: Session = Depends(get_db)):
"""Serve generated audio file.""" """Serve generated audio file (serves the default version)."""
generation = await history.get_generation(generation_id, db) generation = await history.get_generation(generation_id, db)
if not generation: if not generation:
raise HTTPException(status_code=404, detail="Generation not found") raise HTTPException(status_code=404, detail="Generation not found")
+112
View File
@@ -21,6 +21,9 @@ class VoiceProfileResponse(BaseModel):
description: Optional[str] description: Optional[str]
language: str language: str
avatar_path: Optional[str] = None avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -61,6 +64,7 @@ class GenerationRequest(BaseModel):
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting") max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)") crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)")
normalize: bool = Field(default=True, description="Normalize output audio volume") normalize: bool = Field(default=True, description="Normalize output audio volume")
effects_chain: Optional[List["EffectConfig"]] = Field(None, description="Effects chain to apply after generation (overrides profile default)")
class GenerationResponse(BaseModel): class GenerationResponse(BaseModel):
@@ -77,7 +81,10 @@ class GenerationResponse(BaseModel):
model_size: Optional[str] = None model_size: Optional[str] = None
status: str = "completed" status: str = "completed"
error: Optional[str] = None error: Optional[str] = None
is_favorited: bool = False
created_at: datetime created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -106,7 +113,10 @@ class HistoryResponse(BaseModel):
model_size: Optional[str] = None model_size: Optional[str] = None
status: str = "completed" status: str = "completed"
error: Optional[str] = None error: Optional[str] = None
is_favorited: bool = False
created_at: datetime created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -268,6 +278,7 @@ class StoryItemDetail(BaseModel):
id: str id: str
story_id: str story_id: str
generation_id: str generation_id: str
version_id: Optional[str] = None
start_time_ms: int start_time_ms: int
track: int = 0 track: int = 0
trim_start_ms: int = 0 trim_start_ms: int = 0
@@ -283,6 +294,9 @@ class StoryItemDetail(BaseModel):
seed: Optional[int] seed: Optional[int]
instruct: Optional[str] instruct: Optional[str]
generation_created_at: datetime generation_created_at: datetime
# Versions available for this generation
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -339,3 +353,101 @@ class StoryItemTrim(BaseModel):
class StoryItemSplit(BaseModel): class StoryItemSplit(BaseModel):
"""Request model for splitting a story item.""" """Request model for splitting a story item."""
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start) split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
class StoryItemVersionUpdate(BaseModel):
"""Request model for setting a story item's pinned version."""
version_id: Optional[str] = None # null = use generation default
# ============================================
# Effects & Versions
# ============================================
class EffectConfig(BaseModel):
"""A single effect in an effects chain."""
type: str
enabled: bool = True
params: dict = Field(default_factory=dict)
class EffectsChain(BaseModel):
"""An ordered list of effects to apply."""
effects: List[EffectConfig] = Field(default_factory=list)
class EffectPresetCreate(BaseModel):
"""Request model for creating an effect preset."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
effects_chain: List[EffectConfig]
class EffectPresetUpdate(BaseModel):
"""Request model for updating an effect preset."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
effects_chain: Optional[List[EffectConfig]] = None
class EffectPresetResponse(BaseModel):
"""Response model for effect preset."""
id: str
name: str
description: Optional[str] = None
effects_chain: List[EffectConfig]
is_builtin: bool = False
created_at: datetime
class Config:
from_attributes = True
class GenerationVersionResponse(BaseModel):
"""Response model for a generation version."""
id: str
generation_id: str
label: str
audio_path: str
effects_chain: Optional[List[EffectConfig]] = None
source_version_id: Optional[str] = None
is_default: bool
created_at: datetime
class Config:
from_attributes = True
class ApplyEffectsRequest(BaseModel):
"""Request to apply effects to an existing generation."""
effects_chain: List[EffectConfig]
source_version_id: Optional[str] = Field(None, description="Version to use as source audio (defaults to clean/original)")
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
set_as_default: bool = Field(default=True, description="Set this version as the default")
class ProfileEffectsUpdate(BaseModel):
"""Request to update the default effects chain on a profile."""
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
class AvailableEffectParam(BaseModel):
"""Description of a single effect parameter."""
default: float
min: float
max: float
step: float
description: str
class AvailableEffect(BaseModel):
"""Description of an available effect type."""
type: str
label: str
description: str
params: dict # param_name -> AvailableEffectParam
class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
+64 -7
View File
@@ -8,7 +8,7 @@ import uuid
import shutil import shutil
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import func, select
from .models import ( from .models import (
VoiceProfileCreate, VoiceProfileCreate,
@@ -19,12 +19,43 @@ from .models import (
from .database import ( from .database import (
VoiceProfile as DBVoiceProfile, VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample, ProfileSample as DBProfileSample,
Generation as DBGeneration,
) )
from .models import EffectConfig
from .utils.audio import validate_reference_audio, load_audio, save_audio from .utils.audio import validate_reference_audio, load_audio, save_audio
from .utils.images import validate_image, process_avatar from .utils.images import validate_image, process_avatar
from .utils.cache import _get_cache_dir, clear_profile_cache from .utils.cache import _get_cache_dir, clear_profile_cache
from .tts import get_tts_model from .tts import get_tts_model
from . import config from . import config
import json as _json
def _profile_to_response(
profile: DBVoiceProfile,
generation_count: int = 0,
sample_count: int = 0,
) -> VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain."""
effects_chain = None
if profile.effects_chain:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
except Exception as e:
import logging
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return VoiceProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description,
language=profile.language,
avatar_path=profile.avatar_path,
effects_chain=effects_chain,
generation_count=generation_count,
sample_count=sample_count,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _get_profiles_dir() -> Path: def _get_profiles_dir() -> Path:
@@ -72,7 +103,7 @@ async def create_profile(
profile_dir = _get_profiles_dir() / db_profile.id profile_dir = _get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
return VoiceProfileResponse.model_validate(db_profile) return _profile_to_response(db_profile)
async def add_profile_sample( async def add_profile_sample(
@@ -154,7 +185,7 @@ async def get_profile(
if not profile: if not profile:
return None return None
return VoiceProfileResponse.model_validate(profile) return _profile_to_response(profile)
async def get_profile_samples( async def get_profile_samples(
@@ -177,7 +208,7 @@ async def get_profile_samples(
async def list_profiles(db: Session) -> List[VoiceProfileResponse]: async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
""" """
List all voice profiles. List all voice profiles with generation and sample counts.
Args: Args:
db: Database session db: Database session
@@ -189,7 +220,33 @@ async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
DBVoiceProfile.created_at.desc() DBVoiceProfile.created_at.desc()
).all() ).all()
return [VoiceProfileResponse.model_validate(p) for p in profiles] if not profiles:
return []
# Batch-fetch generation counts
gen_counts_rows = (
db.query(DBGeneration.profile_id, func.count(DBGeneration.id))
.group_by(DBGeneration.profile_id)
.all()
)
gen_counts = {row[0]: row[1] for row in gen_counts_rows}
# Batch-fetch sample counts
sample_counts_rows = (
db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id))
.group_by(DBProfileSample.profile_id)
.all()
)
sample_counts = {row[0]: row[1] for row in sample_counts_rows}
return [
_profile_to_response(
p,
generation_count=gen_counts.get(p.id, 0),
sample_count=sample_counts.get(p.id, 0),
)
for p in profiles
]
async def update_profile( async def update_profile(
@@ -230,7 +287,7 @@ async def update_profile(
db.commit() db.commit()
db.refresh(profile) db.refresh(profile)
return VoiceProfileResponse.model_validate(profile) return _profile_to_response(profile)
async def delete_profile( async def delete_profile(
@@ -472,7 +529,7 @@ async def upload_avatar(
db.commit() db.commit()
db.refresh(profile) db.refresh(profile)
return VoiceProfileResponse.model_validate(profile) return _profile_to_response(profile)
async def delete_avatar( async def delete_avatar(
+1
View File
@@ -38,6 +38,7 @@ librosa>=0.10.0
soundfile>=0.12.0 soundfile>=0.12.0
numpy>=1.24.0 numpy>=1.24.0
numba>=0.60.0,<0.61.0 numba>=0.60.0,<0.61.0
pedalboard>=0.9.0
# HTTP client (for CUDA backend download) # HTTP client (for CUDA backend download)
httpx>=0.27.0 httpx>=0.27.0
+119 -177
View File
@@ -20,12 +20,55 @@ from .models import (
StoryItemMove, StoryItemMove,
StoryItemTrim, StoryItemTrim,
StoryItemSplit, StoryItemSplit,
StoryItemVersionUpdate,
) )
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .history import _get_versions_for_generation
from .utils.audio import load_audio, save_audio from .utils.audio import load_audio, save_audio
import numpy as np import numpy as np
def _build_item_detail(
item: DBStoryItem,
generation: DBGeneration,
profile_name: str,
db: Session,
) -> StoryItemDetail:
"""Build a StoryItemDetail with version info from a story item and its generation."""
versions, active_version_id = _get_versions_for_generation(generation.id, db)
# Resolve the audio path: if version_id is set, use that version's audio
audio_path = generation.audio_path
if item.version_id and versions:
for v in versions:
if v.id == item.version_id:
audio_path = v.audio_path
break
return StoryItemDetail(
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
version_id=getattr(item, 'version_id', None),
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
versions=versions,
active_version_id=active_version_id,
)
async def create_story( async def create_story(
data: StoryCreate, data: StoryCreate,
db: Session, db: Session,
@@ -125,26 +168,7 @@ async def get_story(
# Build item details # Build item details
item_details = [] item_details = []
for item, generation, profile_name in items: for item, generation, profile_name in items:
item_detail = StoryItemDetail( item_details.append(_build_item_detail(item, generation, profile_name, db))
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
item_details.append(item_detail)
response = StoryDetailResponse.model_validate(story) response = StoryDetailResponse.model_validate(story)
response.items = item_details response.items = item_details
@@ -250,25 +274,7 @@ async def add_item_to_story(
if existing: if existing:
# Return existing item # Return existing item
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail( return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db)
id=existing.id,
story_id=existing.story_id,
generation_id=existing.generation_id,
start_time_ms=existing.start_time_ms,
track=existing.track,
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
created_at=existing.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
# Get track from data or default to 0 # Get track from data or default to 0
track = data.track if data.track is not None else 0 track = data.track if data.track is not None else 0
@@ -321,25 +327,7 @@ async def add_item_to_story(
# Get profile name # Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def move_story_item( async def move_story_item(
@@ -388,25 +376,7 @@ async def move_story_item(
# Get profile name # Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def remove_item_from_story( async def remove_item_from_story(
@@ -495,25 +465,7 @@ async def trim_story_item(
# Get profile name # Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=item.trim_start_ms,
trim_end_ms=item.trim_end_ms,
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def split_story_item( async def split_story_item(
@@ -568,6 +520,7 @@ async def split_story_item(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
story_id=story_id, story_id=story_id,
generation_id=item.generation_id, # Same generation, different trim generation_id=item.generation_id, # Same generation, different trim
version_id=getattr(item, 'version_id', None), # Preserve pinned version
start_time_ms=item.start_time_ms + data.split_time_ms, start_time_ms=item.start_time_ms + data.split_time_ms,
track=item.track, track=item.track,
trim_start_ms=absolute_split_ms, trim_start_ms=absolute_split_ms,
@@ -590,48 +543,10 @@ async def split_story_item(
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
profile_name = profile.name if profile else "Unknown" profile_name = profile.name if profile else "Unknown"
# Build response items return [
original_item_detail = StoryItemDetail( _build_item_detail(item, generation, profile_name, db),
id=item.id, _build_item_detail(new_item, generation, profile_name, db),
story_id=item.story_id, ]
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=item.trim_start_ms,
trim_end_ms=item.trim_end_ms,
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
new_item_detail = StoryItemDetail(
id=new_item.id,
story_id=new_item.story_id,
generation_id=new_item.generation_id,
start_time_ms=new_item.start_time_ms,
track=new_item.track,
trim_start_ms=new_item.trim_start_ms,
trim_end_ms=new_item.trim_end_ms,
created_at=new_item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
return [original_item_detail, new_item_detail]
async def duplicate_story_item( async def duplicate_story_item(
@@ -674,6 +589,7 @@ async def duplicate_story_item(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
story_id=story_id, story_id=story_id,
generation_id=original_item.generation_id, # Same generation as original generation_id=original_item.generation_id, # Same generation as original
version_id=getattr(original_item, 'version_id', None), # Preserve pinned version
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
track=original_item.track, track=original_item.track,
trim_start_ms=current_trim_start, trim_start_ms=current_trim_start,
@@ -694,25 +610,7 @@ async def duplicate_story_item(
# Get profile name # Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail( return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db)
id=new_item.id,
story_id=new_item.story_id,
generation_id=new_item.generation_id,
start_time_ms=new_item.start_time_ms,
track=new_item.track,
trim_start_ms=new_item.trim_start_ms,
trim_end_ms=new_item.trim_end_ms,
created_at=new_item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def update_story_item_times( async def update_story_item_times(
@@ -813,25 +711,7 @@ async def reorder_story_items(
current_time_ms += duration_ms + gap_ms current_time_ms += duration_ms + gap_ms
# Build the response item # Build the response item
updated_items.append(StoryItemDetail( updated_items.append(_build_item_detail(item, generation, profile_name, db))
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
))
# Update story updated_at # Update story updated_at
story.updated_at = datetime.utcnow() story.updated_at = datetime.utcnow()
@@ -840,6 +720,60 @@ async def reorder_story_items(
return updated_items return updated_items
async def set_story_item_version(
story_id: str,
item_id: str,
data: StoryItemVersionUpdate,
db: Session,
) -> Optional[StoryItemDetail]:
"""
Pin a story item to a specific generation version.
Args:
story_id: Story ID
item_id: Story item ID
data: Version update data (version_id or null for default)
db: Database session
Returns:
Updated item detail or None if not found
"""
item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
).first()
if not item:
return None
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation:
return None
# Validate version_id belongs to this generation if provided
if data.version_id:
from .database import GenerationVersion as DBGenerationVersion
version = db.query(DBGenerationVersion).filter_by(
id=data.version_id,
generation_id=item.generation_id,
).first()
if not version:
return None
item.version_id = data.version_id
# Update story updated_at
story = db.query(DBStory).filter_by(id=story_id).first()
if story:
story.updated_at = datetime.utcnow()
db.commit()
db.refresh(item)
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def export_story_audio( async def export_story_audio(
story_id: str, story_id: str,
db: Session, db: Session,
@@ -877,7 +811,15 @@ async def export_story_audio(
sample_rate = 24000 # Default sample rate sample_rate = 24000 # Default sample rate
for item, generation in items: for item, generation in items:
audio_path = Path(generation.audio_path) # Resolve audio path: use pinned version if set, otherwise generation default
resolved_audio_path = generation.audio_path
if getattr(item, 'version_id', None):
from .database import GenerationVersion as DBGenerationVersion
version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first()
if version:
resolved_audio_path = version.audio_path
audio_path = Path(resolved_audio_path)
if not audio_path.exists(): if not audio_path.exists():
continue continue
+356
View File
@@ -0,0 +1,356 @@
"""
Audio post-processing effects engine.
Uses Spotify's pedalboard library to apply professional-grade DSP effects
to generated audio. Effects are described as a JSON-serializable chain
(list of effect dicts) so they can be stored in the database and sent
over the API.
Supported effect types:
- chorus (flanger-style with short delays)
- reverb (room reverb)
- delay (echo / delay line)
- compressor (dynamic range compression)
- gain (volume adjustment in dB)
- highpass (high-pass filter)
- lowpass (low-pass filter)
- pitch_shift (semitone pitch shifting)
"""
from __future__ import annotations
import numpy as np
from typing import Any, Dict, List, Optional
from pedalboard import (
Pedalboard,
Chorus,
Reverb,
Compressor,
Gain,
HighpassFilter,
LowpassFilter,
Delay,
PitchShift,
)
# ---------------------------------------------------------------------------
# Effect registry: maps type names -> (pedalboard class, param definitions)
# ---------------------------------------------------------------------------
# Each param definition: (default, min, max, description)
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"chorus": {
"cls": Chorus,
"label": "Chorus / Flanger",
"description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
"params": {
"rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
"depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
"feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"centre_delay_ms": {"default": 7.0, "min": 0.5, "max": 50.0, "step": 0.1, "description": "Centre delay (ms)"},
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
},
},
"reverb": {
"cls": Reverb,
"label": "Reverb",
"description": "Room reverb effect.",
"params": {
"room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
"damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
"wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
"dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
"width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
},
},
"delay": {
"cls": Delay,
"label": "Delay",
"description": "Echo / delay line.",
"params": {
"delay_seconds": {"default": 0.3, "min": 0.01, "max": 2.0, "step": 0.01, "description": "Delay time (seconds)"},
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
},
},
"compressor": {
"cls": Compressor,
"label": "Compressor",
"description": "Dynamic range compression for consistent loudness.",
"params": {
"threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
"ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
"attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
"release_ms": {"default": 100.0, "min": 10.0, "max": 1000.0,"step": 1.0, "description": "Release time (ms)"},
},
},
"gain": {
"cls": Gain,
"label": "Gain",
"description": "Volume adjustment in decibels.",
"params": {
"gain_db": {"default": 0.0, "min": -40.0, "max": 40.0, "step": 0.5, "description": "Gain (dB)"},
},
},
"highpass": {
"cls": HighpassFilter,
"label": "High-Pass Filter",
"description": "Removes frequencies below the cutoff.",
"params": {
"cutoff_frequency_hz": {"default": 80.0, "min": 20.0, "max": 8000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
},
},
"lowpass": {
"cls": LowpassFilter,
"label": "Low-Pass Filter",
"description": "Removes frequencies above the cutoff.",
"params": {
"cutoff_frequency_hz": {"default": 8000.0, "min": 200.0, "max": 20000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
},
},
"pitch_shift": {
"cls": PitchShift,
"label": "Pitch Shift",
"description": "Shift pitch up or down by semitones.",
"params": {
"semitones": {"default": 0.0, "min": -12.0, "max": 12.0, "step": 0.5, "description": "Semitones to shift"},
},
},
}
# ---------------------------------------------------------------------------
# Built-in presets
# ---------------------------------------------------------------------------
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
"robotic": {
"name": "Robotic",
"sort_order": 0,
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)",
"effects_chain": [
{
"type": "chorus",
"enabled": True,
"params": {
"rate_hz": 0.2,
"depth": 1.0,
"feedback": 0.35,
"centre_delay_ms": 7.0,
"mix": 0.5,
},
},
],
},
"radio": {
"name": "Radio",
"sort_order": 1,
"description": "Thin AM-radio voice with band-pass filtering and light compression",
"effects_chain": [
{
"type": "highpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 300.0},
},
{
"type": "lowpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 3500.0},
},
{
"type": "compressor",
"enabled": True,
"params": {
"threshold_db": -15.0,
"ratio": 6.0,
"attack_ms": 5.0,
"release_ms": 50.0,
},
},
{
"type": "gain",
"enabled": True,
"params": {"gain_db": 6.0},
},
],
},
"echo_chamber": {
"name": "Echo Chamber",
"sort_order": 2,
"description": "Spacious reverb with trailing echo",
"effects_chain": [
{
"type": "reverb",
"enabled": True,
"params": {
"room_size": 0.85,
"damping": 0.3,
"wet_level": 0.45,
"dry_level": 0.55,
"width": 1.0,
},
},
{
"type": "delay",
"enabled": True,
"params": {
"delay_seconds": 0.25,
"feedback": 0.3,
"mix": 0.2,
},
},
],
},
"deep_voice": {
"name": "Deep Voice",
"sort_order": 99,
"description": "Lower pitch with added warmth",
"effects_chain": [
{
"type": "pitch_shift",
"enabled": True,
"params": {"semitones": -3.0},
},
{
"type": "lowpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 6000.0},
},
{
"type": "compressor",
"enabled": True,
"params": {
"threshold_db": -18.0,
"ratio": 3.0,
"attack_ms": 10.0,
"release_ms": 150.0,
},
},
],
},
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_available_effects() -> List[Dict[str, Any]]:
"""Return the list of available effect types with their parameter definitions.
Used by the frontend to build the effects chain editor UI.
"""
result = []
for effect_type, info in EFFECT_REGISTRY.items():
result.append({
"type": effect_type,
"label": info["label"],
"description": info["description"],
"params": {
name: {k: v for k, v in pdef.items()}
for name, pdef in info["params"].items()
},
})
return result
def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
"""Return all built-in effect presets."""
return BUILTIN_PRESETS
def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
"""Validate an effects chain configuration.
Returns None if valid, or an error message string.
"""
if not isinstance(effects_chain, list):
return "effects_chain must be a list"
for i, effect in enumerate(effects_chain):
if not isinstance(effect, dict):
return f"Effect at index {i} must be a dict"
effect_type = effect.get("type")
if effect_type not in EFFECT_REGISTRY:
return f"Unknown effect type '{effect_type}' at index {i}. Available: {list(EFFECT_REGISTRY.keys())}"
params = effect.get("params", {})
if not isinstance(params, dict):
return f"Effect '{effect_type}' at index {i}: params must be a dict"
registry = EFFECT_REGISTRY[effect_type]
for param_name, value in params.items():
if param_name not in registry["params"]:
return f"Effect '{effect_type}' at index {i}: unknown param '{param_name}'"
pdef = registry["params"][param_name]
if not isinstance(value, (int, float)):
return f"Effect '{effect_type}' at index {i}: param '{param_name}' must be a number"
if value < pdef["min"] or value > pdef["max"]:
return (
f"Effect '{effect_type}' at index {i}: param '{param_name}' "
f"must be between {pdef['min']} and {pdef['max']} (got {value})"
)
return None
def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
"""Build a Pedalboard instance from an effects chain config.
Skips effects where ``enabled`` is ``False``.
"""
plugins = []
for effect in effects_chain:
if not effect.get("enabled", True):
continue
effect_type = effect["type"]
registry = EFFECT_REGISTRY[effect_type]
cls = registry["cls"]
# Merge defaults with provided params
params = {}
for pname, pdef in registry["params"].items():
params[pname] = effect.get("params", {}).get(pname, pdef["default"])
plugins.append(cls(**params))
return Pedalboard(plugins)
def apply_effects(
audio: np.ndarray,
sample_rate: int,
effects_chain: List[Dict[str, Any]],
) -> np.ndarray:
"""Apply an effects chain to audio data.
Args:
audio: Input audio array (1-D mono float32).
sample_rate: Sample rate in Hz.
effects_chain: List of effect configuration dicts.
Returns:
Processed audio array.
"""
if not effects_chain:
return audio
board = build_pedalboard(effects_chain)
# pedalboard expects shape (channels, samples)
if audio.ndim == 1:
audio_2d = audio[np.newaxis, :]
else:
audio_2d = audio
processed = board(audio_2d.astype(np.float32), sample_rate)
# Return same dimensionality as input
if audio.ndim == 1:
return processed[0]
return processed
+211
View File
@@ -0,0 +1,211 @@
"""
Generation versions management module.
Each generation can have multiple audio versions: a clean (unprocessed)
version and any number of processed versions with different effects chains.
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import List, Optional
from sqlalchemy.orm import Session
from .database import (
GenerationVersion as DBGenerationVersion,
Generation as DBGeneration,
)
from .models import GenerationVersionResponse, EffectConfig
from . import config
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
"""Convert a DB version row to a Pydantic response."""
effects_chain = None
if v.effects_chain:
raw = json.loads(v.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
return GenerationVersionResponse(
id=v.id,
generation_id=v.generation_id,
label=v.label,
audio_path=v.audio_path,
effects_chain=effects_chain,
source_version_id=v.source_version_id,
is_default=v.is_default,
created_at=v.created_at,
)
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
"""List all versions for a generation."""
versions = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.all()
)
return [_version_response(v) for v in versions]
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Get a specific version by ID."""
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not v:
return None
return _version_response(v)
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Get the default version for a generation."""
v = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id, is_default=True)
.first()
)
if not v:
# Fallback: return the first version
v = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.first()
)
if not v:
return None
return _version_response(v)
def create_version(
generation_id: str,
label: str,
audio_path: str,
db: Session,
effects_chain: Optional[List[dict]] = None,
is_default: bool = False,
source_version_id: Optional[str] = None,
) -> GenerationVersionResponse:
"""Create a new version for a generation.
If ``is_default`` is True, all other versions for this generation
are un-defaulted first.
"""
if is_default:
_clear_defaults(generation_id, db)
version = DBGenerationVersion(
id=str(uuid.uuid4()),
generation_id=generation_id,
label=label,
audio_path=audio_path,
effects_chain=json.dumps(effects_chain) if effects_chain else None,
source_version_id=source_version_id,
is_default=is_default,
)
db.add(version)
db.commit()
db.refresh(version)
# If this version is the default, update the generation's audio_path
if is_default:
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if gen:
gen.audio_path = audio_path
db.commit()
return _version_response(version)
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Set a version as the default for its generation."""
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not version:
return None
_clear_defaults(version.generation_id, db)
version.is_default = True
db.commit()
db.refresh(version)
# Update generation's audio_path to point to this version
gen = db.query(DBGeneration).filter_by(id=version.generation_id).first()
if gen:
gen.audio_path = version.audio_path
db.commit()
return _version_response(version)
def delete_version(version_id: str, db: Session) -> bool:
"""Delete a version. Cannot delete the last remaining version."""
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not version:
return False
# Don't allow deleting the last version
count = (
db.query(DBGenerationVersion)
.filter_by(generation_id=version.generation_id)
.count()
)
if count <= 1:
return False
was_default = version.is_default
gen_id = version.generation_id
# Delete audio file
audio_path = Path(version.audio_path)
if audio_path.exists():
audio_path.unlink()
db.delete(version)
db.commit()
# 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()
return True
def delete_versions_for_generation(generation_id: str, db: Session) -> int:
"""Delete all versions for a generation (used when deleting a generation)."""
versions = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.all()
)
count = 0
for v in versions:
audio_path = Path(v.audio_path)
if audio_path.exists():
audio_path.unlink()
db.delete(v)
count += 1
if count > 0:
db.commit()
return count
def _clear_defaults(generation_id: str, db: Session) -> None:
"""Clear the is_default flag on all versions for a generation."""
db.query(DBGenerationVersion).filter_by(
generation_id=generation_id, is_default=True
).update({"is_default": False})
db.flush()
+143 -14
View File
@@ -8,12 +8,17 @@ tauri_dir := "tauri"
app_dir := "app" app_dir := "app"
web_dir := "web" web_dir := "web"
venv := backend_dir / "venv" venv := backend_dir / "venv"
venv_bin := venv / "bin"
python := venv_bin / "python"
pip := venv_bin / "pip"
# Detect best python for venv creation # Platform-aware paths
system_python := `command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3` venv_bin := if os() == "windows" { venv / "Scripts" } else { venv / "bin" }
python := if os() == "windows" { venv_bin / "python.exe" } else { venv_bin / "python" }
pip := if os() == "windows" { venv_bin / "pip.exe" } else { venv_bin / "pip" }
# Shell selection: use powershell on Windows, bash elsewhere
set windows-shell := ["powershell", "-NoProfile", "-Command"]
# Detect best python for venv creation (platform-aware)
system_python := if os() == "windows" { "python" } else { `command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3` }
# ─── Setup ──────────────────────────────────────────────────────────── # ─── Setup ────────────────────────────────────────────────────────────
@@ -23,6 +28,7 @@ setup: setup-python setup-js
@echo "Setup complete! Run: just dev" @echo "Setup complete! Run: just dev"
# Create venv and install Python dependencies # Create venv and install Python dependencies
[unix]
setup-python: setup-python:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -48,60 +54,136 @@ setup-python:
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git {{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
echo "Python environment ready." echo "Python environment ready."
[windows]
setup-python:
if (-not (Test-Path "{{ venv }}")) { \
Write-Host "Creating Python virtual environment..."; \
$pyMinor = & {{ system_python }} -c "import sys; print(sys.version_info[1])"; \
if ([int]$pyMinor -gt 13) { \
Write-Host "Warning: Python 3.$pyMinor detected. ML packages may not be compatible."; \
}; \
& {{ system_python }} -m venv {{ venv }}; \
}
Write-Host "Installing Python dependencies..."
& "{{ python }}" -m pip install --upgrade pip -q
$hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
if ($hasNvidia) { \
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126; \
}
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
& "{{ pip }}" install --no-deps chatterbox-tts
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
Write-Host "Python environment ready."
# Install JavaScript dependencies # Install JavaScript dependencies
setup-js: setup-js:
bun install bun install
# ─── Development ────────────────────────────────────────────────────── # ─── Development ──────────────────────────────────────────────────────
# Start backend + frontend for development (two processes, one terminal) # Start backend (if not already running) + frontend for development
[unix]
dev: _ensure-venv _ensure-sidecar dev: _ensure-venv _ensure-sidecar
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
trap 'kill 0' EXIT
backend_pid=""
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
echo "Backend already running on http://localhost:17493"
else
echo "Starting backend on http://localhost:17493 ..." echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
backend_pid=$!
sleep 2 sleep 2
fi
trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT
echo "Starting Tauri desktop app..." echo "Starting Tauri desktop app..."
cd {{ tauri_dir }} && bun run tauri dev & cd {{ tauri_dir }} && bun run tauri dev
wait [windows]
dev: _ensure-venv _ensure-sidecar
$backendJob = $null
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
Write-Host "Starting backend on http://localhost:17493 ..."; \
$backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \
Start-Sleep -Seconds 2; \
}
Write-Host "Starting Tauri desktop app..."
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } }
# Start backend only # Start backend only
[unix]
dev-backend: _ensure-venv dev-backend: _ensure-venv
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
[windows]
dev-backend: _ensure-venv
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
# Start Tauri desktop app only (backend must be running separately) # Start Tauri desktop app only (backend must be running separately)
dev-frontend: _ensure-sidecar dev-frontend: _ensure-sidecar
cd {{ tauri_dir }} && bun run tauri dev cd {{ tauri_dir }} && bun run tauri dev
# Start backend + web app (no Tauri) # Start backend (if not already running) + web app (no Tauri)
[unix]
dev-web: _ensure-venv dev-web: _ensure-venv
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
trap 'kill 0' EXIT
backend_pid=""
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
echo "Backend already running on http://localhost:17493"
else
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
backend_pid=$!
sleep 2 sleep 2
cd {{ web_dir }} && bun run dev & fi
wait
trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT
cd {{ web_dir }} && bun run dev
[windows]
dev-web: _ensure-venv
$backendJob = $null
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
Write-Host "Starting backend on http://localhost:17493 ..."; \
$backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \
Start-Sleep -Seconds 2; \
}
Write-Host "Starting web app..."
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } }
# Kill all dev processes # Kill all dev processes
[unix]
kill: kill:
-pkill -f "uvicorn backend.main:app" 2>/dev/null || true -pkill -f "uvicorn backend.main:app" 2>/dev/null || true
-pkill -f "vite" 2>/dev/null || true -pkill -f "vite" 2>/dev/null || true
@echo "Dev processes killed." @echo "Dev processes killed."
[windows]
kill:
Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*uvicorn*backend.main*' -or $_.CommandLine -like '*vite*' } | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host "Dev processes killed."
# ─── Build ──────────────────────────────────────────────────────────── # ─── Build ────────────────────────────────────────────────────────────
# Build everything (server binary + desktop app) # Build everything (server binary + desktop app)
build: build-server build-tauri build: build-server build-tauri
# Build Python server binary # Build Python server binary
[unix]
build-server: _ensure-venv build-server: _ensure-venv
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
[windows]
build-server: _ensure-venv
$env:PATH = "{{ venv_bin }};$env:PATH"; & "{{ python }}" -m PyInstaller backend/voicebox-server.spec
# Build Tauri desktop app # Build Tauri desktop app
build-tauri: build-tauri:
cd {{ tauri_dir }} && bun run tauri build cd {{ tauri_dir }} && bun run tauri build
@@ -135,38 +217,73 @@ db-init: _ensure-venv
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()" cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
# Reset database (delete + reinit) # Reset database (delete + reinit)
[unix]
db-reset: db-reset:
rm -f {{ backend_dir }}/data/voicebox.db rm -f {{ backend_dir }}/data/voicebox.db
just db-init just db-init
[windows]
db-reset:
if (Test-Path "{{ backend_dir }}/data/voicebox.db") { Remove-Item -Force "{{ backend_dir }}/data/voicebox.db" }
just db-init
# ─── Utilities ──────────────────────────────────────────────────────── # ─── Utilities ────────────────────────────────────────────────────────
# Generate TypeScript API client (backend must be running) # Generate TypeScript API client (backend must be running)
[unix]
generate-api: generate-api:
./scripts/generate-api.sh ./scripts/generate-api.sh
[windows]
generate-api:
bash scripts/generate-api.sh
# Open API docs in browser # Open API docs in browser
[unix]
docs: docs:
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
[windows]
docs:
Start-Process "http://localhost:17493/docs"
# Tail backend logs # Tail backend logs
[unix]
logs: logs:
tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found" tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found"
[windows]
logs:
Get-ChildItem {{ backend_dir }}/logs/*.log -ErrorAction SilentlyContinue | ForEach-Object { Get-Content $_.FullName -Tail 50 -Wait } ; if (-not $?) { Write-Host "No log files found" }
# ─── Clean ──────────────────────────────────────────────────────────── # ─── Clean ────────────────────────────────────────────────────────────
# Clean build artifacts # Clean build artifacts
[unix]
clean: clean:
rm -rf {{ tauri_dir }}/src-tauri/target/release rm -rf {{ tauri_dir }}/src-tauri/target/release
rm -rf {{ web_dir }}/dist rm -rf {{ web_dir }}/dist
rm -rf {{ app_dir }}/dist rm -rf {{ app_dir }}/dist
[windows]
clean:
if (Test-Path "{{ tauri_dir }}/src-tauri/target/release") { Remove-Item -Recurse -Force "{{ tauri_dir }}/src-tauri/target/release" }
if (Test-Path "{{ web_dir }}/dist") { Remove-Item -Recurse -Force "{{ web_dir }}/dist" }
if (Test-Path "{{ app_dir }}/dist") { Remove-Item -Recurse -Force "{{ app_dir }}/dist" }
# Clean Python venv and cache # Clean Python venv and cache
[unix]
clean-python: clean-python:
rm -rf {{ venv }} rm -rf {{ venv }}
find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
[windows]
clean-python:
if (Test-Path "{{ venv }}") { Remove-Item -Recurse -Force "{{ venv }}" }
Get-ChildItem -Path "{{ backend_dir }}" -Directory -Recurse -Filter "__pycache__" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force
# Nuclear clean (everything including node_modules) # Nuclear clean (everything including node_modules)
[unix]
clean-all: clean clean-python clean-all: clean clean-python
rm -rf node_modules rm -rf node_modules
rm -rf {{ app_dir }}/node_modules rm -rf {{ app_dir }}/node_modules
@@ -174,10 +291,18 @@ clean-all: clean clean-python
rm -rf {{ web_dir }}/node_modules rm -rf {{ web_dir }}/node_modules
cd {{ tauri_dir }}/src-tauri && cargo clean cd {{ tauri_dir }}/src-tauri && cargo clean
[windows]
clean-all: clean clean-python
if (Test-Path "node_modules") { Remove-Item -Recurse -Force "node_modules" }
if (Test-Path "{{ app_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ app_dir }}/node_modules" }
if (Test-Path "{{ tauri_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ tauri_dir }}/node_modules" }
if (Test-Path "{{ web_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ web_dir }}/node_modules" }
Push-Location "{{ tauri_dir }}/src-tauri"; cargo clean; Pop-Location
# ─── Internal ───────────────────────────────────────────────────────── # ─── Internal ─────────────────────────────────────────────────────────
# Ensure venv exists (prompt to run setup if not) # Ensure venv exists (prompt to run setup if not)
[private] [private, unix]
_ensure-venv: _ensure-venv:
#!/usr/bin/env bash #!/usr/bin/env bash
if [ ! -d "{{ venv }}" ]; then if [ ! -d "{{ venv }}" ]; then
@@ -185,6 +310,10 @@ _ensure-venv:
exit 1 exit 1
fi fi
[private, windows]
_ensure-venv:
if (-not (Test-Path "{{ venv }}")) { Write-Host "Python venv not found. Run: just setup"; exit 1 }
# Ensure Tauri dev sidecar placeholder exists # Ensure Tauri dev sidecar placeholder exists
[private] [private]
_ensure-sidecar: _ensure-sidecar:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.13", "version": "0.2.0",
"description": "Landing page for voicebox.sh", "description": "Landing page for voicebox.sh",
"scripts": { "scripts": {
"dev": "bun --bun next dev --turbo", "dev": "bun --bun next dev --turbo",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "voicebox", "name": "voicebox",
"version": "0.1.13", "version": "0.2.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"app", "app",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"private": true, "private": true,
"version": "0.1.13", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.1.13" version = "0.2.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "voicebox" name = "voicebox"
version = "0.1.13" version = "0.2.0"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation" description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"] authors = ["you"]
license = "" license = ""
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox", "productName": "Voicebox",
"version": "0.1.13", "version": "0.2.0",
"identifier": "sh.voicebox.app", "identifier": "sh.voicebox.app",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+16 -4
View File
@@ -1,4 +1,4 @@
import type { PlatformFilesystem, FileFilter } from '@/platform/types'; import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const tauriFilesystem: PlatformFilesystem = { export const tauriFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) { async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
@@ -12,9 +12,8 @@ export const tauriFilesystem: PlatformFilesystem = {
if (!filePath) return; // User cancelled the dialog if (!filePath) return; // User cancelled the dialog
const resolvedPath = typeof filePath === 'string' const resolvedPath =
? filePath typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
: (filePath as { path: string }).path;
if (!resolvedPath) { if (!resolvedPath) {
throw new Error('Failed to resolve save path from dialog'); throw new Error('Failed to resolve save path from dialog');
@@ -23,4 +22,17 @@ export const tauriFilesystem: PlatformFilesystem = {
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer)); await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
}, },
async openPath(path: string) {
const { open } = await import('@tauri-apps/plugin-shell');
await open(path);
},
async pickDirectory(title: string) {
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({ directory: true, title });
if (!selected) return null;
const dir = typeof selected === 'string' ? selected : (selected as { path: string }).path;
return dir || null;
},
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/web", "name": "@voicebox/web",
"private": true, "private": true,
"version": "0.1.13", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+9 -1
View File
@@ -1,4 +1,4 @@
import type { PlatformFilesystem, FileFilter } from '@/platform/types'; import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const webFilesystem: PlatformFilesystem = { export const webFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) { async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
@@ -12,4 +12,12 @@ export const webFilesystem: PlatformFilesystem = {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
}, },
async openPath(_path: string) {
// No filesystem access in browser
},
async pickDirectory(_title: string) {
return null;
},
}; };