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
Jamie Pine 49ebf6222e fix SSE cleanup, filter add popover to completed only, derive isGenerating from pending set, handle not_found status 2026-03-13 10:57:28 -07:00
Jamie Pine 509b0e71cc responsive layout fixes, version in sidebar, fixed voice card height, hide player title at small widths 2026-03-13 10:44:53 -07:00
Jamie Pine 81f8be1a94 defer story add until TTS completes, add generating pill to story editor, fix item placement per-track 2026-03-13 10:28:20 -07:00
Jamie Pine 655a60ca81 feat: async generation queue with serial execution
Generations now return immediately with a 'generating' status and appear
in history right away. TTS runs in a serial background queue to avoid
GPU contention. Users can kick off multiple generations without blocking.

- Async POST /generate creates DB record immediately, queues TTS work
- Serial generation queue prevents concurrent GPU access (Metal/CUDA/CPU)
- SSE endpoint GET /generate/{id}/status for real-time completion tracking
- Retry endpoint POST /generate/{id}/retry for failed generations
- Store engine and model_size on generation records for retry support
- History cards show animated loader (react-loaders) for generating/playing
- Failed generations show retry button instead of actions menu
- Model downloads happen inline in the queue instead of rejecting with 202
- Stale 'generating' records marked as failed on server startup
- Autoplay on generate setting (default: on)
- Show engine name on generation cards
- Remove sidebar generation spinner
- Checkbox alignment fix in settings
2026-03-13 10:02:41 -07:00
Jamie PineandGitHub 52285362ce Merge pull request #268 from jamiepine/feat/model-management-improvements
feat: model management improvements and folder migration
2026-03-13 09:16:43 -07:00
Jamie Pine 3ea587797f feat: model management improvements and folder migration
- Add model folder migration with byte-level progress tracking (backend + UI)
- Custom models directory support via VOICEBOX_MODELS_DIR env var passed to sidecar
- Hardcoded model descriptions displayed in model detail cards
- Open model folder button in storage location row
- Remove 'not downloaded' badge from model cards
- Fix server settings scroll offset for audio player
- Fix shell open permission to allow file paths
- Add normalize toggle to generation settings
2026-03-13 08:38:20 -07:00
Jamie PineandGitHub 325714bb83 Merge pull request #266 from jamiepine/feat/chunked-tts
feat: chunked TTS generation for long text (engine-agnostic)
2026-03-13 08:23:53 -07:00
85 changed files with 6132 additions and 1175 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": {
+2 -1
View File
@@ -94,10 +94,11 @@ function App() {
serverStartingRef.current = true; serverStartingRef.current = true;
const isRemote = useServerStore.getState().mode === 'remote'; const isRemote = useServerStore.getState().mode === 'remote';
const customModelsDir = useServerStore.getState().customModelsDir;
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`); console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
platform.lifecycle platform.lifecycle
.startServer(isRemote) .startServer(isRemote, customModelsDir)
.then((serverUrl) => { .then((serverUrl) => {
console.log('Server is ready at:', serverUrl); console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port // Update the server URL in the store with the dynamically assigned port
+26 -11
View File
@@ -7,8 +7,8 @@ import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { formatAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug'; import { debug } from '@/lib/utils/debug';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() { export function AudioPlayer() {
const platform = usePlatform(); const platform = usePlatform();
@@ -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);
}); });
@@ -360,7 +373,7 @@ export function AudioPlayer() {
if (shouldAutoPlayNow) { if (shouldAutoPlayNow) {
// Clear the flag first // Clear the flag first
usePlayerStore.getState().clearAutoPlayFlag(); usePlayerStore.getState().clearAutoPlayFlag();
// Use a small delay to ensure audio element is fully ready // Use a small delay to ensure audio element is fully ready
setTimeout(() => { setTimeout(() => {
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
@@ -665,7 +678,7 @@ export function AudioPlayer() {
// Handle shouldAutoPlay flag - for story mode auto-advance // Handle shouldAutoPlay flag - for story mode auto-advance
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay); const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag); const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
useEffect(() => { useEffect(() => {
const wavesurfer = wavesurferRef.current; const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldAutoPlay || duration === 0) { if (!wavesurfer || !shouldAutoPlay || duration === 0) {
@@ -833,11 +846,7 @@ export function AudioPlayer() {
className="shrink-0" className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={ aria-label={
duration === 0 && !isLoading duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
? 'Audio not loaded'
: isPlaying
? 'Pause'
: 'Play'
} }
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />} {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
@@ -872,7 +881,9 @@ export function AudioPlayer() {
{/* Title */} {/* Title */}
{title && ( {title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div> <div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
{title}
</div>
)} )}
{/* Loop Button */} {/* Loop Button */}
@@ -888,7 +899,11 @@ export function AudioPlayer() {
</Button> </Button>
{/* Volume Control */} {/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume"> <div
className="flex items-center gap-2 shrink-0 w-[120px]"
role="group"
aria-label="Volume"
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+6 -4
View File
@@ -23,8 +23,8 @@ import {
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface AudioDevice { interface AudioDevice {
id: string; id: string;
@@ -129,7 +129,7 @@ export function AudioTab() {
if (await confirm('Delete this channel?')) { if (await confirm('Delete this channel?')) {
deleteChannel.mutate(channelId); deleteChannel.mutate(channelId);
} }
} };
const allChannels = channels || []; const allChannels = channels || [];
const allDevices = devices || []; const allDevices = devices || [];
@@ -168,7 +168,7 @@ export function AudioTab() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3 p-2"> <div className="space-y-3">
{allChannels.map((channel) => { {allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id; const isSelected = selectedChannelId === channel.id;
return ( return (
@@ -343,7 +343,9 @@ export function AudioTab() {
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md"> <div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" /> <CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'} {platform.metadata.isTauri
? 'No audio devices found'
: 'Audio device selection requires Tauri'}
</p> </p>
</div> </div>
)} )}
@@ -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,12 +13,13 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; 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';
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories'; import { useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput'; import { ParalinguisticInput } from './ParalinguisticInput';
@@ -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();
@@ -44,8 +47,7 @@ export function FloatingGenerateBox({
const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId); const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem(); const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
// Calculate if track editor is visible (on stories route with items) // Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
@@ -53,27 +55,12 @@ export function FloatingGenerateBox({
const { form, handleSubmit, isPending } = useGenerationForm({ const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => { onSuccess: async (generationId) => {
setIsExpanded(false); setIsExpanded(false);
// If on stories route and a story is selected, add generation to story // Defer the story add until TTS completes — useGenerationProgress handles it
if (isStoriesRoute && selectedStoryId && generationId) { if (isStoriesRoute && selectedStoryId && generationId) {
try { addPendingStoryAdd(generationId, selectedStoryId);
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description:
error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
} }
}, },
getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
}); });
// Click away handler to collapse the box // Click away handler to collapse the box
@@ -182,7 +169,7 @@ export function FloatingGenerateBox({
isStoriesRoute isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px ? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]' 'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]', : 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
)} )}
style={{ style={{
// On stories route: offset by track editor height when visible // On stories route: offset by track editor height when visible
@@ -372,7 +359,9 @@ 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'
: 'bg-card border border-border hover:bg-background/50', : 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',
)} )}
aria-label={ aria-label={
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions' isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
@@ -381,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>
@@ -390,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 }}
+444 -99
View File
@@ -1,13 +1,22 @@
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { import {
AlignCenter,
AudioLines,
AudioWaveform, AudioWaveform,
Download, Download,
FileArchive, FileArchive,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Play, Play,
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 { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -23,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,
@@ -36,7 +52,8 @@ import {
useImportGeneration, useImportGeneration,
} from '@/lib/hooks/useHistory'; } from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatDate, formatDuration } from '@/lib/utils/format'; import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
@@ -54,9 +71,21 @@ export function HistoryTable() {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null); const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | 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 { const {
data: historyData, data: historyData,
@@ -71,6 +100,7 @@ export function HistoryTable() {
const exportGeneration = useExportGeneration(); const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio(); const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration(); const importGeneration = useImportGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio); const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId); const currentAudioId = usePlayerStore((state) => state.audioId);
@@ -194,6 +224,120 @@ export function HistoryTable() {
} }
}; };
const handleRetry = async (generationId: string) => {
try {
const result = await apiClient.retryGeneration(generationId);
addPendingGeneration(result.id);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Retry failed',
description: error instanceof Error ? error.message : 'Could not retry generation',
variant: 'destructive',
});
}
};
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, {
@@ -250,117 +394,266 @@ export function HistoryTable() {
> >
{history.map((gen) => { {history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying; const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
const isGenerating = gen.status === 'generating';
const isFailed = gen.status === 'failed';
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}
role="button"
tabIndex={0}
className={cn( className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full', 'border rounded-md bg-card transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70', isCurrentlyPlaying && 'bg-muted/70',
)} )}
aria-label={
isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
}
onMouseDown={(e) => {
// Don't trigger play if clicking on textarea or if text is selected
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay(gen.id, gen.text, gen.profile_id);
}}
onKeyDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest('textarea') || target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlay(gen.id, gen.text, gen.profile_id);
}
}}
> >
{/* Waveform icon */} {/* Main row */}
<div className="flex items-center shrink-0">
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
</div>
{/* Left side - Meta information */}
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
<div className="font-medium text-sm truncate" title={gen.profile_name}>
{gen.profile_name}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{gen.language}</span>
<span className="text-xs text-muted-foreground">
{formatDuration(gen.duration)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{formatDate(gen.created_at)}
</div>
</div>
{/* Right side - Transcript textarea */}
<div className="flex-1 min-w-0 flex">
<Textarea
value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
/>
</div>
{/* Far right - Ellipsis actions */}
<div <div
className="w-10 shrink-0 flex justify-end" role={isPlayable ? 'button' : undefined}
onMouseDown={(e) => e.stopPropagation()} tabIndex={isPlayable ? 0 : undefined}
onClick={(e) => e.stopPropagation()} className={cn(
'flex items-stretch gap-4 h-26 p-3',
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
isVersionsExpanded && 'rounded-b-none',
)}
aria-label={
isGenerating
? `Generating speech for ${gen.profile_name}...`
: isFailed
? `Generation failed for ${gen.profile_name}`
: isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
}
onMouseDown={(e) => {
if (!isPlayable) return;
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay(gen.id, gen.text, gen.profile_id);
}}
onKeyDown={(e) => {
if (!isPlayable) return;
const target = e.target as HTMLElement;
if (target.closest('textarea') || target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlay(gen.id, gen.text, gen.profile_id);
}
}}
> >
<DropdownMenu> {/* Status icon */}
<DropdownMenuTrigger asChild> <div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
<div className="scale-50">
<Loader
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
active={isGenerating || isCurrentlyPlaying}
/>
</div>
</div>
{/* Left side - Meta information */}
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
<div className="font-medium text-sm truncate" title={gen.profile_name}>
{gen.profile_name}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{gen.language}</span>
<span className="text-xs text-muted-foreground">
{formatEngineName(gen.engine, gen.model_size)}
</span>
{isFailed ? (
<span className="text-xs text-destructive">Failed</span>
) : !isGenerating ? (
<span className="text-xs text-muted-foreground">
{formatDuration(gen.duration ?? 0)}
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{isGenerating ? (
<span className="text-accent">Generating...</span>
) : (
formatDate(gen.created_at)
)}
</div>
</div>
{/* Right side - Transcript textarea */}
<div className="flex-1 min-w-0 flex">
<Textarea
value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
/>
</div>
{/* Far right - Actions */}
<div
className="shrink-0 flex flex-col justify-center items-center gap-0.5"
onMouseDown={(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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className={cn(
aria-label="Actions" '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)}
> >
<MoreHorizontal className="h-4 w-4" /> <AudioLines className="h-2 w-2" />
</Button> </Button>
</DropdownMenuTrigger> )}
<DropdownMenuContent align="end">
<DropdownMenuItem {isFailed ? (
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)} <Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
> >
<Play className="mr-2 h-4 w-4" /> <RotateCcw className="h-2 w-2" />
Play </Button>
</DropdownMenuItem> ) : (
<DropdownMenuItem <>
onClick={() => handleDownloadAudio(gen.id, gen.text)} <DropdownMenu>
disabled={exportGenerationAudio.isPending} <DropdownMenuTrigger asChild>
> <Button
<Download className="mr-2 h-4 w-4" /> variant="ghost"
Export Audio size="icon"
</DropdownMenuItem> className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
<DropdownMenuItem aria-label="Actions"
onClick={() => handleExportPackage(gen.id, gen.text)} disabled={isGenerating}
disabled={exportGeneration.isPending} >
> <MoreHorizontal className="h-2 w-2" />
<FileArchive className="mr-2 h-4 w-4" /> </Button>
Export Package </DropdownMenuTrigger>
</DropdownMenuItem> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)} onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
disabled={deleteGeneration.isPending} >
className="text-destructive focus:text-destructive" <Play className="mr-2 h-4 w-4" />
> Play
<Trash2 className="mr-2 h-4 w-4" /> </DropdownMenuItem>
Delete <DropdownMenuItem
</DropdownMenuItem> onClick={() => handleDownloadAudio(gen.id, gen.text)}
</DropdownMenuContent> disabled={exportGenerationAudio.isPending}
</DropdownMenu> >
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</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
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
// className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</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> </div>
); );
})} })}
@@ -387,7 +680,8 @@ export function HistoryTable() {
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Generation</DialogTitle> <DialogTitle>Delete Generation</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone. Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -441,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>
); );
} }
+7 -7
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles'; import { useImportProfile } 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';
@@ -77,9 +77,9 @@ export function MainEditor() {
return ( return (
// Main view: Profiles top left, Generator bottom left, History right // Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative"> <div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */} {/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative"> <div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
{/* Scroll Mask - Always visible, behind content */} {/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" /> <div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
@@ -110,10 +110,7 @@ export function MainEditor() {
{/* Scrollable Content */} {/* Scrollable Content */}
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
'flex-1 min-h-0 overflow-y-auto pt-14',
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
)}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col"> <div className="shrink-0 flex flex-col">
@@ -123,6 +120,9 @@ export function MainEditor() {
</div> </div>
</div> </div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */} {/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable /> <HistoryTable />
+1 -1
View File
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
export function ModelsTab() { export function ModelsTab() {
return ( return (
<div className="h-full flex flex-col p-4"> <div className="h-full flex flex-col">
<ModelManagement /> <ModelManagement />
</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>
@@ -124,6 +124,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="keepServerRunning" id="keepServerRunning"
className="mt-[6px]"
checked={keepServerRunningOnClose} checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => { onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked); setKeepServerRunningOnClose(checked);
@@ -158,6 +159,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="allowNetworkAccess" id="allowNetworkAccess"
className="mt-[6px]"
checked={mode === 'remote'} checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => { onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local'); setMode(checked ? 'remote' : 'local');
@@ -1,4 +1,5 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
@@ -7,6 +8,10 @@ export function GenerationSettings() {
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars); const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs); const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs); const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return ( return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}> <Card role="region" aria-label="Generation Settings" tabIndex={0}>
@@ -32,7 +37,7 @@ export function GenerationSettings() {
value={[maxChunkChars]} value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)} onValueChange={([value]) => setMaxChunkChars(value)}
min={100} min={100}
max={2000} max={5000}
step={50} step={50}
aria-label="Auto-chunking character limit" aria-label="Auto-chunking character limit"
/> />
@@ -64,6 +69,46 @@ export function GenerationSettings() {
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut. Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p> </p>
</div> </div>
<div className="flex items-start gap-3">
<Checkbox
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="normalizeAudio"
className="text-sm font-medium leading-none cursor-pointer"
>
Normalize audio
</label>
<p className="text-sm text-muted-foreground">
Adjusts output volume to a consistent level across generations.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -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 text-muted-foreground"> <>
{isCurrentlyCuda <div className="text-sm font-medium">
? 'CUDA (GPU accelerated)' {health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
: hasNativeGpu health.gpu_type}
? `${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 text-muted-foreground">
</div> {health.gpu_type.replace(/\s*\(.+\)$/, '')}
)} {health.vram_used_mb != null && health.vram_used_mb > 0
? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
: ''}
</div>
</>
) : (
<>
<div className="text-sm font-medium">CPU</div>
<div className="text-sm text-muted-foreground">No GPU acceleration available</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 && (
@@ -7,6 +7,7 @@ import {
CircleX, CircleX,
Download, Download,
ExternalLink, ExternalLink,
FolderOpen,
HardDrive, HardDrive,
Heart, Heart,
Loader2, Loader2,
@@ -41,6 +42,8 @@ import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types'; import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> { async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
const response = await fetch(`https://huggingface.co/api/models/${repoId}`); const response = await fetch(`https://huggingface.co/api/models/${repoId}`);
@@ -48,6 +51,29 @@ async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceMod
return response.json(); return response.json();
} }
const MODEL_DESCRIPTIONS: Record<string, string> = {
'qwen-tts-1.7B':
'High-quality multilingual TTS by Alibaba. Supports 10 languages with natural prosody and voice cloning from short reference audio.',
'qwen-tts-0.6B':
'Lightweight version of Qwen TTS. Same language support with faster inference, ideal for lower-end hardware.',
luxtts:
'Lightweight ZipVoice-based TTS designed for high quality voice cloning and 48kHz speech generation at speeds exceeding 150x realtime.',
'chatterbox-tts':
'Production-grade open source TTS by Resemble AI. Supports 23 languages with voice cloning and emotion exaggeration control.',
'chatterbox-turbo':
'Streamlined 350M parameter TTS by Resemble AI. High-quality English speech with less compute and VRAM than larger models.',
'whisper-base':
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
'whisper-small':
'Whisper Small (244M parameters). Good balance of speed and accuracy for transcription.',
'whisper-medium':
'Whisper Medium (769M parameters). Higher accuracy transcription at moderate speed.',
'whisper-large':
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
'whisper-turbo':
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
};
function formatDownloads(n: number): string { function formatDownloads(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
@@ -85,6 +111,18 @@ function formatBytes(bytes: number): string {
export function ModelManagement() { export function ModelManagement() {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const platform = usePlatform();
const customModelsDir = useServerStore((state) => state.customModelsDir);
const setCustomModelsDir = useServerStore((state) => state.setCustomModelsDir);
const [migrating, setMigrating] = useState(false);
const [migrationProgress, setMigrationProgress] = useState<{
current: number;
total: number;
progress: number;
filename?: string;
status: string;
} | null>(null);
const [pendingMigrateDir, setPendingMigrateDir] = useState<string | null>(null);
const [downloadingModel, setDownloadingModel] = useState<string | null>(null); const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
const [consoleOpen, setConsoleOpen] = useState(false); const [consoleOpen, setConsoleOpen] = useState(false);
@@ -104,6 +142,12 @@ export function ModelManagement() {
refetchInterval: 5000, refetchInterval: 5000,
}); });
const { data: cacheDir } = useQuery({
queryKey: ['modelsCacheDir'],
queryFn: () => apiClient.getModelsCacheDir(),
staleTime: 1000 * 60 * 5,
});
const { data: activeTasks } = useQuery({ const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'], queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(), queryFn: () => apiClient.getActiveTasks(),
@@ -382,6 +426,81 @@ export function ModelManagement() {
</p> </p>
</div> </div>
{/* Model storage location */}
{platform.metadata.isTauri && cacheDir && (
<div className="shrink-0 pb-4 border-b mb-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground">Storage location</span>
<p
className="text-xs font-mono text-muted-foreground/70 truncate"
title={cacheDir.path}
>
{cacheDir.path}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => {
try {
await platform.filesystem.openPath(cacheDir.path);
} catch {
toast({ title: 'Failed to open model folder', variant: 'destructive' });
}
}}
>
<FolderOpen className="h-3 w-3" />
Open
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => {
try {
const newDir = await platform.filesystem.pickDirectory(
'Choose model storage folder',
);
if (!newDir) return;
setPendingMigrateDir(newDir);
} catch {
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
}
}}
disabled={migrating}
>
{migrating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<FolderOpen className="h-3 w-3" />
)}
{migrating ? 'Migrating...' : 'Change'}
</Button>
{customModelsDir && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground h-7 px-2"
disabled={migrating}
onClick={async () => {
setCustomModelsDir(null);
toast({ title: 'Reset to default location. Restarting server...' });
await platform.lifecycle.restartServer('');
queryClient.invalidateQueries();
}}
>
<RotateCcw className="h-3 w-3" />
Reset
</Button>
)}
</div>
</div>
</div>
)}
{/* Model list */} {/* Model list */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -457,9 +576,7 @@ export function ModelManagement() {
{formatSize(model.size_mb)} {formatSize(model.size_mb)}
</span> </span>
)} )}
{!model.downloaded && !isDownloading && !hasError && (
<span className="text-xs text-muted-foreground/60">Not downloaded</span>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" /> <ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</div> </div>
</button> </button>
@@ -571,13 +688,6 @@ export function ModelManagement() {
Error Error
</Badge> </Badge>
)} )}
{!freshSelectedModel.downloaded &&
!selectedState?.isDownloading &&
!selectedState?.hasError && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not downloaded
</Badge>
)}
</div> </div>
{/* HuggingFace model card info */} {/* HuggingFace model card info */}
@@ -588,6 +698,13 @@ export function ModelManagement() {
</div> </div>
)} )}
{/* Description */}
{MODEL_DESCRIPTIONS[freshSelectedModel.model_name] && (
<p className="text-xs text-muted-foreground leading-relaxed">
{MODEL_DESCRIPTIONS[freshSelectedModel.model_name]}
</p>
)}
{hfModelInfo && ( {hfModelInfo && (
<div className="space-y-3"> <div className="space-y-3">
{/* Pipeline tag + author */} {/* Pipeline tag + author */}
@@ -810,6 +927,126 @@ export function ModelManagement() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Migration confirmation dialog */}
<AlertDialog
open={!!pendingMigrateDir}
onOpenChange={(open) => !open && setPendingMigrateDir(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
<AlertDialogDescription>
The server will shut down while models are being moved to the new folder. It will
restart automatically once the migration is complete.
</AlertDialogDescription>
</AlertDialogHeader>
<div
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
title={pendingMigrateDir ?? ''}
>
{pendingMigrateDir}
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!pendingMigrateDir) return;
const newDir = pendingMigrateDir;
setPendingMigrateDir(null);
setMigrating(true);
setMigrationProgress({
current: 0,
total: 0,
progress: 0,
status: 'downloading',
filename: 'Preparing...',
});
try {
// Start the migration (background task)
await apiClient.migrateModels(newDir);
// Connect to SSE for progress
await new Promise<void>((resolve, reject) => {
const es = new EventSource(apiClient.getMigrationProgressUrl());
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setMigrationProgress(data);
if (data.status === 'complete') {
es.close();
resolve();
} else if (data.status === 'error') {
es.close();
reject(new Error(data.error || 'Migration failed'));
}
} catch {
/* ignore parse errors */
}
};
es.onerror = () => {
es.close();
reject(new Error('Lost connection during migration'));
};
});
setCustomModelsDir(newDir);
setMigrationProgress({
current: 1,
total: 1,
progress: 100,
status: 'complete',
filename: 'Restarting server...',
});
await platform.lifecycle.restartServer(newDir);
queryClient.invalidateQueries();
toast({ title: 'Models moved successfully' });
} catch (e) {
toast({
title: 'Migration failed',
description: e instanceof Error ? e.message : 'Failed to migrate models',
variant: 'destructive',
});
} finally {
setMigrating(false);
setMigrationProgress(null);
}
}}
>
Move Models
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Migration progress overlay */}
{migrating && migrationProgress && (
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex items-center justify-center">
<div className="w-full max-w-md px-8 space-y-6 text-center">
<div className="space-y-2">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<h2 className="text-lg font-semibold">Moving models</h2>
<p className="text-sm text-muted-foreground">
{migrationProgress.status === 'complete'
? 'Restarting server...'
: 'The server is offline while models are being moved.'}
</p>
</div>
{migrationProgress.total > 0 && (
<div className="space-y-2">
<Progress value={migrationProgress.progress} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground">
<span className="truncate max-w-[60%]">{migrationProgress.filename}</span>
<span>
{formatBytes(migrationProgress.current)} /{' '}
{formatBytes(migrationProgress.total)}
</span>
</div>
</div>
)}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -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,97 +29,110 @@ 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>
<Button {!isDev && (
onClick={checkForUpdates} <Button
disabled={status.checking || status.downloading || status.readyToInstall} onClick={checkForUpdates}
variant="outline" disabled={status.checking || status.downloading || status.readyToInstall}
size="sm" variant="outline"
> size="sm"
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} /> >
Check for Updates <RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
</Button> Check for Updates
</Button>
)}
</div> </div>
{status.checking && ( {isDev ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Auto-updates are disabled in development mode.
Checking for updates...
</div> </div>
)} ) : (
<>
{status.error && ( {status.checking && (
<div className="flex items-center gap-2 text-sm text-destructive"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" /> <RefreshCw className="h-4 w-4 animate-spin" />
{status.error} Checking for updates...
</div>
)}
{status.available && !status.downloading && !status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Update Available</div>
<div className="text-sm text-muted-foreground">Version {status.version}</div>
</div> </div>
<Badge>New</Badge> )}
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
</Button>
</div>
)}
{status.downloading && ( {status.error && (
<div className="space-y-2"> <div className="flex items-center gap-2 text-sm text-destructive">
<div className="flex items-center justify-between text-sm"> <AlertCircle className="h-4 w-4" />
<div className="flex items-center gap-2"> {status.error}
<Download className="h-4 w-4" />
Downloading update...
</div> </div>
{status.downloadProgress !== undefined && ( )}
<span className="text-muted-foreground">{status.downloadProgress}%</span>
)} {status.available && !status.downloading && !status.readyToInstall && (
</div> <div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<Progress value={status.downloadProgress} /> <div className="flex items-center justify-between">
{status.downloadedBytes !== undefined && <div>
status.totalBytes !== undefined && <div className="font-semibold">Update Available</div>
status.totalBytes > 0 && ( <div className="text-sm text-muted-foreground">Version {status.version}</div>
<div className="text-xs text-muted-foreground"> </div>
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '} <Badge>New</Badge>
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
</div> </div>
)} <Button onClick={downloadAndInstall} className="w-full" size="sm">
</div> <Download className="h-4 w-4 mr-2" />
)} Download Update
</Button>
</div>
)}
{status.readyToInstall && ( {status.downloading && (
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between text-sm">
<div> <div className="flex items-center gap-2">
<div className="font-semibold">Update Ready to Install</div> <Download className="h-4 w-4" />
Downloading update...
</div>
{status.downloadProgress !== undefined && (
<span className="text-muted-foreground">{status.downloadProgress}%</span>
)}
</div>
<Progress value={status.downloadProgress} />
{status.downloadedBytes !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0 && (
<div className="text-xs text-muted-foreground">
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
</div>
)}
</div>
)}
{status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
<div className="flex items-center gap-2">
<div>
<div className="font-semibold">Update Ready to Install</div>
<div className="text-sm text-muted-foreground">
Version {status.version} has been downloaded
</div>
</div>
</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Version {status.version} has been downloaded The app needs to restart to complete the installation. You can do this now or
later at your convenience.
</div> </div>
<Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</div> </div>
</div> )}
<div className="text-sm text-muted-foreground">
The app needs to restart to complete the installation. You can do this now or later at
your convenience.
</div>
<Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</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>
+7 -1
View File
@@ -2,12 +2,18 @@ import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings'; import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration'; import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function ServerTab() { export function ServerTab() {
const platform = usePlatform(); const platform = usePlatform();
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
return ( return (
<div className="overflow-y-auto flex flex-col"> <div
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ConnectionForm /> <ConnectionForm />
<GenerationSettings /> <GenerationSettings />
+37 -28
View File
@@ -1,9 +1,9 @@
import { Link, useMatchRoute } from '@tanstack/react-router'; import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, BookOpen, Loader2, 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 { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { version } from '../../package.json';
interface SidebarProps { interface SidebarProps {
isMacOS?: boolean; isMacOS?: boolean;
@@ -11,18 +11,17 @@ 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' },
]; ];
export function Sidebar({ isMacOS }: SidebarProps) { export function Sidebar({ isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
return ( return (
<div <div
@@ -33,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 */}
@@ -42,42 +49,44 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const Icon = tab.icon; const Icon = tab.icon;
// For index route, use exact match; for others, use default matching // For index route, use exact match; for others, use default matching
const isActive = const isActive =
tab.path === '/' tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
return ( return (
<Link <Link
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>
); );
})} })}
</div> </div>
{/* Spacer to push loader to bottom */} {/* Version */}
<div className="flex-1" /> <div
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
{/* Generation Loader */} style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
{isGenerating && ( >
<div v{version}
className={cn( </div>
'w-full flex items-center justify-center transition-all duration-200',
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
)}
>
<Loader2 className="h-6 w-6 text-accent animate-spin" />
</div>
)}
</div> </div>
); );
} }
+33 -6
View File
@@ -13,8 +13,11 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react'; import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -28,6 +31,7 @@ import {
useStory, useStory,
} from '@/lib/hooks/useStories'; } from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem'; import { SortableStoryChatItem } from './StoryChatItem';
@@ -40,6 +44,7 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem(); const addStoryItem = useAddStoryItem();
const { toast } = useToast(); const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
// Add generation popover state // Add generation popover state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -53,9 +58,9 @@ export function StoryContent() {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return historyData.items.filter( return historyData.items.filter(
(gen) => (gen) =>
gen.status === 'completed' &&
!storyGenerationIds.has(gen.id) && !storyGenerationIds.has(gen.id) &&
(gen.text.toLowerCase().includes(query) || (gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
gen.profile_name.toLowerCase().includes(query)),
); );
}, [historyData, story, searchQuery]); }, [historyData, story, searchQuery]);
@@ -267,7 +272,31 @@ export function StoryContent() {
<p className="text-sm text-muted-foreground mt-1">{story.description}</p> <p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<AnimatePresence>
{pendingCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9, width: 0 }}
animate={{ opacity: 1, scale: 1, width: 'auto' }}
exit={{ opacity: 0, scale: 0.9, width: 0 }}
transition={{ duration: 0.2 }}
>
<Link
to="/"
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
<div className="scale-[0.45]">
<Loader type="line-scale" active />
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}> <Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -287,9 +316,7 @@ export function StoryContent() {
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? ( {availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery {searchQuery ? 'No matching generations found' : 'No available generations'}
? 'No matching generations found'
: 'No available generations'}
</div> </div>
) : ( ) : (
availableGenerations.map((gen) => ( availableGenerations.map((gen) => (
+96 -79
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,20 +185,29 @@ 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="flex items-center justify-between mb-4 px-1"> <div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm"> {/* Fixed Header */}
<Plus className="mr-2 h-4 w-4" /> <div className="absolute top-0 left-0 right-0 z-20">
New Story <div className="flex items-center justify-between mb-4 px-1">
</Button> <h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Story
</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,75 +215,68 @@ 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">
<div {storyList.map((story) => (
key={story.id} <div
role="button" key={story.id}
tabIndex={0} role="button"
className={cn( tabIndex={0}
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer', className={cn(
selectedStoryId === story.id && 'bg-muted border-primary', 'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
)} selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
aria-label={ )}
selectedStoryId === story.id aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.` aria-pressed={selectedStoryId === story.id}
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.` onClick={() => setSelectedStoryId(story.id)}
} onKeyDown={(e) => {
aria-pressed={selectedStoryId === story.id} if (e.target !== e.currentTarget) return;
onClick={() => setSelectedStoryId(story.id)} if (e.key === 'Enter' || e.key === ' ') {
onKeyDown={(e) => { e.preventDefault();
if (e.target !== e.currentTarget) return; setSelectedStoryId(story.id);
if (e.key === 'Enter' || e.key === ' ') { }
e.preventDefault(); }}
setSelectedStoryId(story.id); >
} <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">
> <h3 className="text-sm font-medium truncate">{story.name}</h3>
<div className="flex items-start justify-between gap-2 w-full min-w-0"> <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<div className="flex-1 min-w-0 text-left overflow-hidden"> <span>
<h3 className="font-medium truncate">{story.name}</h3> {story.item_count} {story.item_count === 1 ? 'item' : 'items'}
{story.description && ( </span>
<p className="text-sm text-muted-foreground mt-1 truncate"> <span>·</span>
{story.description} <span>{formatDate(story.updated_at)}</span>
</p> </div>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span>
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
</span>
<span>•</span>
<span>{formatDate(story.updated_at)}</span>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditClick(story)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(story.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditClick(story)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(story.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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);
}; };
@@ -78,8 +74,8 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<> <>
<Card <Card
className={cn( className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col', '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>
@@ -41,9 +41,11 @@ export function ProfileList() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]"> <div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{allProfiles.map((profile) => ( {allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} /> <div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
</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>
);
}
+141 -124
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,56 +101,76 @@ 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="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" /> <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" />
{/* 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>
<Button onClick={() => setDialogOpen(true)}> <div className="flex-1" />
<Plus className="h-4 w-4 mr-2" /> <div className="relative w-[240px]">
New Voice <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
</Button> <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)}>
<Plus className="h-4 w-4 mr-2" />
New Voice
</Button>
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">Name</TableHead>
<TableHead className="w-[10%]">Language</TableHead>
<TableHead className="w-[10%]">Generations</TableHead>
<TableHead className="w-[8%]">Samples</TableHead>
<TableHead className="w-[8%]">Effects</TableHead>
<TableHead className="w-[24%]">Channels</TableHead>
<TableHead className="w-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProfiles.map((profile) => (
<VoiceRow
key={profile.id}
profile={profile}
isSelected={selectedVoiceId === profile.id}
onSelect={() => setSelectedVoiceId(profile.id)}
channelIds={channelAssignments?.[profile.id] || []}
channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
/>
))}
</TableBody>
</Table>
</div> </div>
</div> </div>
{/* Scrollable Content */} {/* Right: Inspector */}
<div {selectedVoiceId && (
ref={scrollRef} <div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
className={cn( <VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
'flex-1 overflow-y-auto pt-16 relative z-0', </div>
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING, )}
)}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Language</TableHead>
<TableHead>Generations</TableHead>
<TableHead>Samples</TableHead>
<TableHead>Channels</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles?.map((profile) => (
<VoiceRow
key={profile.id}
profile={profile}
generationCount={generationCounts[profile.id] || 0}
channelIds={channelAssignments?.[profile.id] || []}
channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
onEdit={() => handleEdit(profile.id)}
onDelete={() => handleProfileDelete(profile.id)}
/>
))}
</TableBody>
</Table>
</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
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
onClick={onSelect}
>
<TableCell> <TableCell>
<button <div className="flex w-full min-w-0 items-center gap-2">
type="button" <div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
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" {avatarUrl && !avatarError ? (
aria-label={rowLabel} <img
onClick={(e) => { src={avatarUrl}
e.stopPropagation(); alt={`${profile.name} avatar`}
onEdit(); className="h-full w-full object-cover"
}} onError={() => setAvatarError(true)}
> />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> ) : (
<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>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
export interface CheckboxProps { export interface CheckboxProps {
+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}
/> />
), ),
+16
View File
@@ -1,4 +1,5 @@
@import "tailwindcss" source("."); @import "tailwindcss" source(".");
@import "loaders.css/loaders.min.css";
@theme { @theme {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
@@ -155,3 +156,18 @@
animation: fadeIn 0.5s ease-out 0.15s forwards; animation: fadeIn 0.5s ease-out 0.15s forwards;
opacity: 0; opacity: 0;
} }
/* react-loaders */
.line-scale-pulse-out-rapid > div,
.line-scale > div {
background-color: hsl(var(--accent)) !important;
}
.loader-hidden {
display: block;
}
.loader-hidden > div > div {
animation-play-state: paused !important;
background-color: hsl(var(--muted-foreground)) !important;
}
+153
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,
@@ -200,6 +207,24 @@ class ApiClient {
}); });
} }
async retryGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
method: 'POST',
});
}
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();
@@ -278,6 +303,11 @@ class ApiClient {
return response.json(); return response.json();
} }
// Generation status SSE
getGenerationStatusUrl(generationId: string): string {
return `${this.getBaseUrl()}/generate/${generationId}/status`;
}
// Audio // Audio
getAudioUrl(audioId: string): string { getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`; return `${this.getBaseUrl()}/audio/${audioId}`;
@@ -316,6 +346,21 @@ class ApiClient {
return this.request<ModelStatusListResponse>('/models/status'); return this.request<ModelStatusListResponse>('/models/status');
} }
async getModelsCacheDir(): Promise<{ path: string }> {
return this.request<{ path: string }>('/models/cache-dir');
}
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
return this.request('/models/migrate', {
method: 'POST',
body: JSON.stringify({ destination }),
});
}
getMigrationProgressUrl(): string {
return `${this.getBaseUrl()}/models/migrate/progress`;
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> { async triggerModelDownload(modelName: string): Promise<{ message: string }> {
console.log( console.log(
'[API] triggerModelDownload called for:', '[API] triggerModelDownload called for:',
@@ -544,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);
@@ -557,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();
+90 -2
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;
@@ -38,6 +47,19 @@ export interface GenerationRequest {
instruct?: string; instruct?: string;
max_chunk_chars?: number; max_chunk_chars?: number;
crossfade_ms?: number; crossfade_ms?: number;
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 {
@@ -45,10 +67,18 @@ export interface GenerationResponse {
profile_id: string; profile_id: string;
text: string; text: string;
language: string; language: string;
audio_path: string; audio_path?: string;
duration: number; duration?: number;
seed?: number; seed?: number;
instruct?: string;
engine?: string;
model_size?: string;
status: 'generating' | 'completed' | 'failed';
error?: string;
is_favorited?: boolean;
created_at: string; created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
} }
export interface HistoryQuery { export interface HistoryQuery {
@@ -60,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 {
@@ -193,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;
@@ -207,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 {
@@ -250,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
+13 -14
View File
@@ -4,15 +4,15 @@ 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';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
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(),
@@ -25,15 +25,16 @@ 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 = {}) {
const { toast } = useToast(); const { toast } = useToast();
const generation = useGeneration(); const generation = useGeneration();
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const maxChunkChars = useServerStore((state) => state.maxChunkChars); const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs); const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null); const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -70,8 +71,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
} }
try { try {
setIsGenerating(true);
const engine = data.engine || 'qwen'; const engine = data.engine || 'qwen';
const modelName = const modelName =
engine === 'luxtts' engine === 'luxtts'
@@ -92,6 +91,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? 'Qwen TTS 1.7B' ? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B'; : 'Qwen TTS 0.6B';
// Check if model needs downloading
try { try {
const modelStatus = await apiClient.getModelStatus(); const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName); const model = modelStatus.models.find((m) => m.model_name === modelName);
@@ -105,6 +105,8 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
} }
const isQwen = engine === 'qwen'; const isQwen = engine === 'qwen';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({ const result = await generation.mutateAsync({
profile_id: selectedProfileId, profile_id: selectedProfileId,
text: data.text, text: data.text,
@@ -115,16 +117,14 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
instruct: isQwen ? data.instruct || undefined : undefined, instruct: isQwen ? data.instruct || undefined : undefined,
max_chunk_chars: maxChunkChars, max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs, crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
effects_chain: effectsChain?.length ? effectsChain : undefined,
}); });
toast({ // Track this generation for SSE status updates
title: 'Generation complete!', addPendingGeneration(result.id);
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
// Reset form immediately — user can start typing again
form.reset({ form.reset({
text: '', text: '',
language: data.language, language: data.language,
@@ -141,7 +141,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
setIsGenerating(false);
setDownloadingModelName(null); setDownloadingModelName(null);
setDownloadingDisplayName(null); setDownloadingDisplayName(null);
} }
+154
View File
@@ -0,0 +1,154 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
interface GenerationStatusEvent {
id: string;
status: 'generating' | 'completed' | 'failed' | 'not_found';
duration?: number;
error?: string;
}
/**
* Subscribes to SSE for all pending generations. When a generation completes,
* invalidates the history query, removes it from pending, and auto-plays
* if the player is idle.
*/
export function useGenerationProgress() {
const queryClient = useQueryClient();
const { toast } = useToast();
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
// Keep refs to avoid stale closures in EventSource handlers
const isPlayingRef = useRef(isPlaying);
const autoplayRef = useRef(autoplayOnGenerate);
isPlayingRef.current = isPlaying;
autoplayRef.current = autoplayOnGenerate;
// Track active EventSource instances
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
// Unmount-only cleanup — close all SSE connections when the hook is torn down
useEffect(() => {
const sources = eventSourcesRef.current;
return () => {
for (const source of sources.values()) {
source.close();
}
sources.clear();
};
}, []);
useEffect(() => {
const currentSources = eventSourcesRef.current;
// Close SSE connections for IDs no longer pending
for (const [id, source] of currentSources.entries()) {
if (!pendingIds.has(id)) {
source.close();
currentSources.delete(id);
}
}
// Open SSE connections for new pending IDs
for (const id of pendingIds) {
if (currentSources.has(id)) continue;
const url = apiClient.getGenerationStatusUrl(id);
const source = new EventSource(url);
source.onmessage = (event) => {
try {
const data: GenerationStatusEvent = JSON.parse(event.data);
if (data.status === 'completed') {
source.close();
currentSources.delete(id);
removePendingGeneration(id);
// Refresh history to pick up the completed generation
queryClient.invalidateQueries({ queryKey: ['history'] });
// If this generation was queued for a story, add it now
const storyId = removePendingStoryAdd(id);
if (storyId) {
apiClient
.addStoryItem(storyId, { generation_id: id })
.then(() => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
toast({
title: 'Added to story',
description: data.duration
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
: 'Audio generated and added to story',
});
})
.catch(() => {
toast({
title: 'Generation complete',
description: 'Audio generated but failed to add to story',
variant: 'destructive',
});
});
} else {
// toast({
// title: 'Generation complete!',
// description: data.duration
// ? `Audio generated (${data.duration.toFixed(2)}s)`
// : 'Audio generated',
// });
}
// Auto-play if enabled and nothing is currently playing
if (autoplayRef.current && !isPlayingRef.current) {
const genAudioUrl = apiClient.getAudioUrl(id);
setAudioWithAutoPlay(genAudioUrl, id, '', '');
}
} else if (data.status === 'failed' || data.status === 'not_found') {
source.close();
currentSources.delete(id);
removePendingGeneration(id);
removePendingStoryAdd(id);
queryClient.invalidateQueries({ queryKey: ['history'] });
toast({
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
description: data.error || 'An error occurred during generation',
variant: 'destructive',
});
}
} catch {
// Ignore parse errors from heartbeats etc
}
};
source.onerror = () => {
// EventSource auto-reconnects, but if we get repeated errors
// just clean up
source.close();
currentSources.delete(id);
removePendingGeneration(id);
};
currentSources.set(id, source);
}
}, [
pendingIds,
removePendingGeneration,
removePendingStoryAdd,
queryClient,
toast,
setAudioWithAutoPlay,
]);
}
+12 -12
View File
@@ -1,23 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useGenerationStore } from '@/stores/generationStore';
import type { ActiveDownloadTask } from '@/lib/api/types'; import type { ActiveDownloadTask } from '@/lib/api/types';
import { useGenerationStore } from '@/stores/generationStore';
// Polling interval in milliseconds // Polling interval in milliseconds
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 30000;
/** /**
* Hook to monitor active tasks (downloads and generations). * Hook to monitor active tasks (downloads and generations).
* Polls the server periodically to catch downloads triggered from anywhere * Polls the server periodically to catch downloads triggered from anywhere
* (transcription, generation, explicit download, etc.). * (transcription, generation, explicit download, etc.).
* *
* Returns the active downloads so components can render download toasts. * Returns the active downloads so components can render download toasts.
*/ */
export function useRestoreActiveTasks() { export function useRestoreActiveTasks() {
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]); const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId); const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
// Track which downloads we've seen to detect new ones // Track which downloads we've seen to detect new ones
const seenDownloadsRef = useRef<Set<string>>(new Set()); const seenDownloadsRef = useRef<Set<string>>(new Set());
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
try { try {
const tasks = await apiClient.getActiveTasks(); const tasks = await apiClient.getActiveTasks();
// Update generation state // Restore pending generations (e.g., after page refresh)
if (tasks.generations.length > 0) { if (tasks.generations.length > 0) {
setIsGenerating(true);
setActiveGenerationId(tasks.generations[0].task_id); setActiveGenerationId(tasks.generations[0].task_id);
for (const gen of tasks.generations) {
addPendingGeneration(gen.task_id);
}
} else { } else {
// Only clear if we were tracking a generation
const currentId = useGenerationStore.getState().activeGenerationId; const currentId = useGenerationStore.getState().activeGenerationId;
if (currentId) { if (currentId) {
setIsGenerating(false);
setActiveGenerationId(null); setActiveGenerationId(null);
} }
} }
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
// Update active downloads // Update active downloads
// Keep track of all active downloads (including new ones) // Keep track of all active downloads (including new ones)
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name)); const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
// Remove completed downloads from our seen set // Remove completed downloads from our seen set
for (const name of seenDownloadsRef.current) { for (const name of seenDownloadsRef.current) {
if (!currentDownloadNames.has(name)) { if (!currentDownloadNames.has(name)) {
seenDownloadsRef.current.delete(name); seenDownloadsRef.current.delete(name);
} }
} }
// Add new downloads to seen set // Add new downloads to seen set
for (const download of tasks.downloads) { for (const download of tasks.downloads) {
seenDownloadsRef.current.add(download.model_name); seenDownloadsRef.current.add(download.model_name);
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
// Silently fail - server might be temporarily unavailable // Silently fail - server might be temporarily unavailable
console.debug('Failed to fetch active tasks:', error); console.debug('Failed to fetch active tasks:', error);
} }
}, [setIsGenerating, setActiveGenerationId]); }, [setActiveGenerationId, addPendingGeneration]);
useEffect(() => { useEffect(() => {
// Fetch immediately on mount // Fetch immediately on mount
+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, [
+23 -11
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,15 +227,16 @@ 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;
} }
// Calculate when this item should start in AudioContext time // Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms); const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
// Calculate effective duration and trim offsets // Calculate effective duration and trim offsets
const trimStartSec = (item.trim_start_ms || 0) / 1000; const trimStartSec = (item.trim_start_ms || 0) / 1000;
const trimEndSec = (item.trim_end_ms || 0) / 1000; const trimEndSec = (item.trim_end_ms || 0) / 1000;
+16 -1
View File
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
} else { } else {
dateObj = date; dateObj = date;
} }
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, ''); return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
} }
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
qwen: 'Qwen',
luxtts: 'LuxTTS',
chatterbox: 'Chatterbox',
chatterbox_turbo: 'Chatterbox Turbo',
};
export function formatEngineName(engine?: string, modelSize?: string): string {
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
if (engine === 'qwen' && modelSize) {
return `${name} ${modelSize}`;
}
return name;
}
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
+4 -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 {
@@ -49,9 +51,9 @@ export interface PlatformAudio {
} }
export interface PlatformLifecycle { export interface PlatformLifecycle {
startServer(remote?: boolean): Promise<string>; startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
stopServer(): Promise<void>; stopServer(): Promise<void>;
restartServer(): Promise<string>; restartServer(modelsDir?: string | null): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>; setKeepServerRunning(keep: boolean): Promise<void>;
setupWindowCloseHandler(): Promise<void>; setupWindowCloseHandler(): Promise<void>;
onServerReady?: () => void; onServerReady?: () => void;
+14
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';
@@ -8,8 +9,10 @@ import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab'; import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab'; import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks'; import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri // Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac'); const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -18,6 +21,9 @@ function RootLayout() {
// Monitor active downloads/generations and show toasts for them // Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks(); const activeDownloads = useRestoreActiveTasks();
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
useGenerationProgress();
return ( return (
<AppFrame> <AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden">
@@ -100,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,
@@ -120,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 }) }),
}));
+47 -4
View File
@@ -1,15 +1,58 @@
import { create } from 'zustand'; import { create } from 'zustand';
interface GenerationState { interface GenerationState {
/** IDs of generations currently in progress */
pendingGenerationIds: Set<string>;
/** Whether any generation is in progress (derived from pendingGenerationIds) */
isGenerating: boolean; isGenerating: boolean;
activeGenerationId: string | null; /** Map of generationId → storyId for deferred story additions */
setIsGenerating: (generating: boolean) => void; pendingStoryAdds: Map<string, string>;
addPendingGeneration: (id: string) => void;
removePendingGeneration: (id: string) => void;
addPendingStoryAdd: (generationId: string, storyId: string) => void;
removePendingStoryAdd: (generationId: string) => string | undefined;
setActiveGenerationId: (id: string | null) => void; setActiveGenerationId: (id: string | null) => void;
activeGenerationId: string | null;
} }
export const useGenerationStore = create<GenerationState>((set) => ({ export const useGenerationStore = create<GenerationState>((set, get) => ({
pendingGenerationIds: new Set(),
isGenerating: false, isGenerating: false,
activeGenerationId: null, activeGenerationId: null,
setIsGenerating: (generating) => set({ isGenerating: generating }), pendingStoryAdds: new Map(),
addPendingGeneration: (id) =>
set((state) => {
const next = new Set(state.pendingGenerationIds);
next.add(id);
return { pendingGenerationIds: next, isGenerating: true };
}),
removePendingGeneration: (id) =>
set((state) => {
const next = new Set(state.pendingGenerationIds);
next.delete(id);
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
}),
addPendingStoryAdd: (generationId, storyId) =>
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.set(generationId, storyId);
return { pendingStoryAdds: next };
}),
removePendingStoryAdd: (generationId) => {
const storyId = get().pendingStoryAdds.get(generationId);
if (storyId) {
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.delete(generationId);
return { pendingStoryAdds: next };
});
}
return storyId;
},
setActiveGenerationId: (id) => set({ activeGenerationId: id }), setActiveGenerationId: (id) => set({ activeGenerationId: id }),
})); }));
+18
View File
@@ -19,6 +19,15 @@ interface ServerStore {
crossfadeMs: number; crossfadeMs: number;
setCrossfadeMs: (value: number) => void; setCrossfadeMs: (value: number) => void;
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
} }
export const useServerStore = create<ServerStore>()( export const useServerStore = create<ServerStore>()(
@@ -41,6 +50,15 @@ export const useServerStore = create<ServerStore>()(
crossfadeMs: 50, crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }), setCrossfadeMs: (value) => set({ crossfadeMs: value }),
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}), }),
{ {
name: 'voicebox-server', name: 'voicebox-server',
+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:
+191 -2
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)
@@ -45,10 +46,15 @@ class Generation(Base):
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False) profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
text = Column(Text, nullable=False) text = Column(Text, nullable=False)
language = Column(String, default="en") language = Column(String, default="en")
audio_path = Column(String, nullable=False) audio_path = Column(String, nullable=True)
duration = Column(Float, nullable=False) duration = Column(Float, nullable=True)
seed = Column(Integer) seed = Column(Integer)
instruct = Column(Text) instruct = Column(Text)
engine = Column(String, default="qwen")
model_size = Column(String, nullable=True)
status = Column(String, default="completed") # generating, completed, failed
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)
@@ -70,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
@@ -88,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"
@@ -165,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."""
@@ -288,6 +328,155 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added avatar_path column to profiles") print("Added avatar_path column to profiles")
# Migration: Add status and error columns to generations table
if 'generations' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'status' not in columns:
print("Migrating generations: adding status column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
conn.commit()
print("Added status column to generations")
if 'error' not in columns:
print("Migrating generations: adding error column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
conn.commit()
print("Added error column to generations")
if 'engine' not in columns:
print("Migrating generations: adding engine column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
conn.commit()
print("Added engine column to generations")
# Re-read columns after engine migration (variable name shadows outer `engine`)
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'model_size' not in columns:
print("Migrating generations: adding model_size column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
conn.commit()
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
+37 -11
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()
+96 -9
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,
@@ -29,6 +66,10 @@ async def create_generation(
seed: Optional[int], seed: Optional[int],
db: Session, db: Session,
instruct: Optional[str] = None, instruct: Optional[str] = None,
generation_id: Optional[str] = None,
status: str = "completed",
engine: Optional[str] = "qwen",
model_size: Optional[str] = None,
) -> GenerationResponse: ) -> GenerationResponse:
""" """
Create a new generation history entry. Create a new generation history entry.
@@ -42,12 +83,16 @@ async def create_generation(
seed: Random seed used (if any) seed: Random seed used (if any)
db: Database session db: Database session
instruct: Natural language instruction used (if any) instruct: Natural language instruction used (if any)
generation_id: Pre-assigned ID (for async generation flow)
status: Generation status (generating, completed, failed)
engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
Returns: Returns:
Created generation entry Created generation entry
""" """
db_generation = DBGeneration( db_generation = DBGeneration(
id=str(uuid.uuid4()), id=generation_id or str(uuid.uuid4()),
profile_id=profile_id, profile_id=profile_id,
text=text, text=text,
language=language, language=language,
@@ -55,6 +100,9 @@ async def create_generation(
duration=duration, duration=duration,
seed=seed, seed=seed,
instruct=instruct, instruct=instruct,
engine=engine,
model_size=model_size,
status=status,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
@@ -65,6 +113,32 @@ async def create_generation(
return GenerationResponse.model_validate(db_generation) return GenerationResponse.model_validate(db_generation)
async def update_generation_status(
generation_id: str,
status: str,
db: Session,
audio_path: Optional[str] = None,
duration: Optional[float] = None,
error: Optional[str] = None,
) -> Optional[GenerationResponse]:
"""Update the status of a generation (used by async generation flow)."""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
return None
generation.status = status
if audio_path is not None:
generation.audio_path = audio_path
if duration is not None:
generation.duration = duration
if error is not None:
generation.error = error
db.commit()
db.refresh(generation)
return GenerationResponse.model_validate(generation)
async def get_generation( async def get_generation(
generation_id: str, generation_id: str,
db: Session, db: Session,
@@ -133,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,
@@ -143,7 +218,14 @@ async def list_generations(
duration=generation.duration, duration=generation.duration,
seed=generation.seed, seed=generation.seed,
instruct=generation.instruct, instruct=generation.instruct,
engine=generation.engine or "qwen",
model_size=generation.model_size,
status=generation.status or "completed",
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(
@@ -169,12 +251,17 @@ async def delete_generation(
generation = db.query(DBGeneration).filter_by(id=generation_id).first() generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation: if not generation:
return False return False
# Delete audio file # Delete all version files and records
audio_path = Path(generation.audio_path) from . import versions as versions_mod
if audio_path.exists(): versions_mod.delete_versions_for_generation(generation_id, db)
audio_path.unlink()
# Delete main audio file (if not already removed by version cleanup)
if generation.audio_path:
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path.unlink()
# Delete from database # Delete from database
db.delete(generation) db.delete(generation)
db.commit() db.commit()
+947 -196
View File
File diff suppressed because it is too large Load Diff
+134 -8
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
@@ -60,6 +63,8 @@ class GenerationRequest(BaseModel):
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$") engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
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")
effects_chain: Optional[List["EffectConfig"]] = Field(None, description="Effects chain to apply after generation (overrides profile default)")
class GenerationResponse(BaseModel): class GenerationResponse(BaseModel):
@@ -68,11 +73,18 @@ class GenerationResponse(BaseModel):
profile_id: str profile_id: str
text: str text: str
language: str language: str
audio_path: str audio_path: Optional[str] = None
duration: float duration: Optional[float] = None
seed: Optional[int] seed: Optional[int] = None
instruct: Optional[str] instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
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
@@ -93,11 +105,18 @@ class HistoryResponse(BaseModel):
profile_name: str profile_name: str
text: str text: str
language: str language: str
audio_path: str audio_path: Optional[str] = None
duration: float duration: Optional[float] = None
seed: Optional[int] seed: Optional[int] = None
instruct: Optional[str] instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
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
@@ -170,6 +189,11 @@ class ModelDownloadRequest(BaseModel):
model_name: str model_name: str
class ModelMigrateRequest(BaseModel):
"""Request model for migrating models to a new directory."""
destination: str
class ActiveDownloadTask(BaseModel): class ActiveDownloadTask(BaseModel):
"""Response model for active download task.""" """Response model for active download task."""
model_name: str model_name: str
@@ -254,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
@@ -269,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
@@ -325,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]
+65 -8
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
@@ -188,8 +219,34 @@ async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
profiles = db.query(DBVoiceProfile).order_by( profiles = db.query(DBVoiceProfile).order_by(
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
+125 -183
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,31 +274,16 @@ 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, # Get track from data or default to 0
generation_id=existing.generation_id, track = data.track if data.track is not None else 0
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,
)
# Calculate start_time_ms if not provided # Calculate start_time_ms if not provided
if data.start_time_ms is not None: if data.start_time_ms is not None:
start_time_ms = data.start_time_ms start_time_ms = data.start_time_ms
else: else:
# Find the maximum end time (start_time_ms + duration_ms) of existing items # Find the maximum end time on the target track only
existing_items = db.query( existing_items = db.query(
DBStoryItem, DBStoryItem,
DBGeneration DBGeneration
@@ -282,11 +291,11 @@ async def add_item_to_story(
DBGeneration, DBGeneration,
DBStoryItem.generation_id == DBGeneration.id DBStoryItem.generation_id == DBGeneration.id
).filter( ).filter(
DBStoryItem.story_id == story_id DBStoryItem.story_id == story_id,
DBStoryItem.track == track,
).all() ).all()
if not existing_items: if not existing_items:
# First item starts at 0
start_time_ms = 0 start_time_ms = 0
else: else:
max_end_time_ms = 0 max_end_time_ms = 0
@@ -297,9 +306,6 @@ async def add_item_to_story(
# Add 200ms gap after the last item # Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200 start_time_ms = max_end_time_ms + 200
# Get track from data or default to 0
track = data.track if data.track is not None else 0
# Create item # Create item
item = DBStoryItem( item = DBStoryItem(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -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()
+19 -4
View File
@@ -4,6 +4,10 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "voicebox", "name": "voicebox",
"dependencies": {
"loaders.css": "^0.1.2",
"react-loaders": "^3.0.1",
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.12", "@biomejs/biome": "2.3.12",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
@@ -13,7 +17,7 @@
}, },
"app": { "app": {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.11", "version": "0.1.13",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -68,7 +72,7 @@
}, },
"landing": { "landing": {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.11", "version": "0.1.13",
"dependencies": { "dependencies": {
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@@ -93,7 +97,7 @@
}, },
"tauri": { "tauri": {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"version": "0.1.11", "version": "0.1.13",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
@@ -116,7 +120,7 @@
}, },
"web": { "web": {
"name": "@voicebox/web", "name": "@voicebox/web",
"version": "0.1.11", "version": "0.1.13",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"react": "^18.3.0", "react": "^18.3.0",
@@ -125,6 +129,7 @@
"zustand": "^4.5.0", "zustand": "^4.5.0",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -677,6 +682,8 @@
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -873,6 +880,8 @@
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -959,6 +968,8 @@
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -969,6 +980,10 @@
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], "react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+67
View File
@@ -0,0 +1,67 @@
# Voicebox Issue Pain Points (Snapshot)
## Scope
- Dataset: **128 total issues** (**107 open**, **21 closed**)
- Source: GitHub issues in `jamiepine/voicebox`
- Classification: keyword/theme clustering
- Note: counts below are **non-exclusive** (one issue can belong to multiple pain points)
## Most Common Pain Points (Open Issues)
| Rank | Pain Point | Open Issues | What users are reporting |
|---|---|---:|---|
| 1 | Model download & offline reliability | **32** | Downloads failing/stalling, cache/offline behavior inconsistent, wrong model size selected, Errno issues |
| 2 | GPU/backend compatibility | **22** | GPU not detected, backend fallback surprises, platform-specific runtime failures (Windows/Mac) |
| 3 | Export/save/file persistence | **15** | Export fails, "failed to fetch/download audio", samples/profiles not saving |
| 4 | Language/accent quality & coverage | **14** | Missing language support, accent mismatch, robotic outputs |
| 5 | Update/restart safety + long-op controls | **4** | Auto-restart without warning, update confusion, lack of cancel/pause controls |
## Representative Issues by Pain Point
### 1) Model download & offline reliability (32)
- [#159](https://github.com/jamiepine/voicebox/issues/159) - Qwen download fails with Errno 22
- [#151](https://github.com/jamiepine/voicebox/issues/151) - Model loading hangs / server crashes
- [#150](https://github.com/jamiepine/voicebox/issues/150) - Internet required despite downloaded models
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Cancel/pause controls for large downloads
- [#96](https://github.com/jamiepine/voicebox/issues/96) - 0.6B selection still uses/downloads 1.7B
### 2) GPU/backend compatibility (22)
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Windows: no GPU usage + multiple breakages
- [#141](https://github.com/jamiepine/voicebox/issues/141) - Using CPU only, GPU not used
- [#131](https://github.com/jamiepine/voicebox/issues/131) - Numpy ABI mismatch in bundled app
- [#130](https://github.com/jamiepine/voicebox/issues/130) - Intel Mac tensor/padding generation error
- [#127](https://github.com/jamiepine/voicebox/issues/127) - GPU not found
### 3) Export/save/file persistence (15)
- [#148](https://github.com/jamiepine/voicebox/issues/148) - Japanese export fails on 0.1.12
- [#143](https://github.com/jamiepine/voicebox/issues/143) - Samples not saving
- [#134](https://github.com/jamiepine/voicebox/issues/134) - Can't save profile
- [#105](https://github.com/jamiepine/voicebox/issues/105) - Export audio fails (failed to fetch)
- [#49](https://github.com/jamiepine/voicebox/issues/49) - Export filename/location ignored on Windows
### 4) Language/accent quality & coverage (14)
- [#162](https://github.com/jamiepine/voicebox/issues/162) - Persian audio request/problem
- [#117](https://github.com/jamiepine/voicebox/issues/117) - Arabic language support
- [#113](https://github.com/jamiepine/voicebox/issues/113) - Polish language support
- [#109](https://github.com/jamiepine/voicebox/issues/109) - Ukrainian support
- [#100](https://github.com/jamiepine/voicebox/issues/100) - Non-US accent quality issues
### 5) Update/restart safety + controls (4)
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Update behavior + usability failures
- [#136](https://github.com/jamiepine/voicebox/issues/136) - Auto-restart without warning
- [#86](https://github.com/jamiepine/voicebox/issues/86) - Unexpected restart with no confirmation
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Need pause/cancel and pre-download confirmation
## Additional Signal
- There is also a large **feature-request/misc** bucket (**36 open**) that is competing with stability triage (audiobook, Linux build, additional ASR/TTS models, integrations).
## Takeaway
Most user pain is concentrated in four stability areas: **download/offline path**, **GPU/backend detection**, **save/export reliability**, and **language/accent correctness**. Addressing those first should reduce the majority of current support friction.
+30 -12
View File
@@ -321,7 +321,7 @@ Notable requests:
## New Model Integration — Landscape ## New Model Integration — Landscape
### Models Worth Supporting (2026 SOTA) ### Models Worth Supporting (2026 SOTA — updated March 13)
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Status | | Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Status |
|-------|---------|-------|-------------|-----------|------|-----------------|--------| |-------|---------|-------|-------------|-----------|------|-----------------|--------|
@@ -329,10 +329,23 @@ Notable requests:
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | **Shipped** | PR #254 | | **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | **Shipped** | PR #254 |
| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | **Shipped** | PR #257 | | **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | **Shipped** | PR #257 |
| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | **PR #258** | In review | | **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | **PR #258** | In review |
| **HumeAI TADA 1B/3B** | Zero-shot | 5× faster than LLM-TTS | — | EN (1B), Multilingual (3B) | Medium | Needs vetting | MIT, 700s+ coherent, synced transcript output |
| **MOSS-TTS Family** | Zero-shot | — | — | Multilingual | Medium | Needs vetting | Apache 2.0, multi-speaker dialogue, text-to-voice design (no ref audio) |
| **VoxCPM 1.5** | Zero-shot (seconds) | ~0.15 RTF streaming | — | Bilingual (EN/ZH) | Medium | Needs vetting | Apache 2.0, tokenizer-free continuous diffusion, LoRA-friendly |
| **Pocket TTS** | Zero-shot + streaming | >1× RT on CPU | — | English | ~100M params, CPU-first | Needs vetting | MIT, Kyutai Labs, no GPU required |
| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny (82M) | Ready | Apache 2.0, multi-engine arch in place |
| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Ready | Multi-engine arch in place | | **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Ready | Multi-engine arch in place |
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Ready | Multi-engine arch in place | | **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Ready | Multi-engine arch in place |
| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Ready | Multi-engine arch in place | | **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Ready | Multi-engine arch in place |
| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny | Ready | Multi-engine arch in place |
#### Notes on New Candidates (March 2026)
- **HumeAI TADA** — Text-Audio Dual Alignment arch. Near-zero hallucinations/drift, free synced transcript. 700+ seconds coherent audio. Best candidate for Stories long-form reliability. [HF: HumeAI/tada-1b](https://huggingface.co/HumeAI/tada-1b) | [GitHub: HumeAI/tada](https://github.com/HumeAI/tada)
- **MOSS-TTS** — Modular suite: flagship cloning, MOSS-TTSD (multi-speaker dialogue), MOSS-VoiceGenerator (create voices from text descriptions, no ref audio). Unique UX for Stories voice design. [GitHub: OpenMOSS/MOSS-TTS](https://github.com/OpenMOSS/MOSS-TTS)
- **VoxCPM 1.5** — Tokenizer-free continuous diffusion + autoregressive. No discrete token artifacts. Context-aware prosody/emotion, real-time streaming, LoRA fine-tuning. Trained on 1.8M+ hours. [GitHub: OpenBMB/VoxCPM](https://github.com/OpenBMB/VoxCPM)
- **Pocket TTS** — 100M param CPU-first model from Kyutai Labs (Moshi team). Runs >1× realtime without GPU. Broadens hardware support significantly. [GitHub: kyutai-labs/pocket-tts](https://github.com/kyutai-labs/pocket-tts)
- **Watch list:** MioTTS-2.6B (fast LLM-based EN/JP, vLLM compatible), Oolel-Voices (Soynade Research, expressive modular control)
- **Skipped:** Fish Audio S2 — restrictive research license (commercial use requires approval), despite strong features
### Adding a New Engine (Now Straightforward) ### Adding a New Engine (Now Straightforward)
@@ -402,16 +415,21 @@ The generation form now uses a flat model dropdown with engine-based routing. Pe
### Tier 3 — Future (v0.3.0+) ### Tier 3 — Future (v0.3.0+)
| Item | Notes | | Priority | Item | Notes |
|------|-------| |----------|------|-------|
| XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation | | 1 | **HumeAI TADA** | Long-form reliability for Stories, synced transcripts. Addresses #234, #203, #191, #111, #69. Needs API vetting. |
| OpenAI-compatible API (plan doc exists) | Low effort once API is stable | | 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model, broadens hardware support. Kyutai ships clean code. Needs API vetting. |
| LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine | | 3 | **MOSS-TTS** | Text-to-voice design (no ref audio) is unique. Multi-speaker dialogue for Stories. Needs thorough API vetting. |
| External/remote providers | Depends on use case demand | | 4 | **Kokoro-82M** | 82M params, CPU realtime, Apache 2.0. Easy win. |
| GGUF support (#226) | Depends on model ecosystem maturity | | 5 | **Model config registry refactor** | Reduce 5-dispatch-point duplication in main.py — do before adding 3+ more engines |
| Queue system (#234) | Batch generation | | 6 | XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation |
| Streaming for non-MLX engines | Currently MLX-only | | 7 | **VoxCPM 1.5** | Tokenizer-free streaming, interesting but uncertain integration surface |
| Kokoro-82M | Tiny model, great for CPU-only machines | | 8 | OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
| 9 | LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine |
| 10 | External/remote providers | Depends on use case demand |
| 11 | GGUF support (#226) | Depends on model ecosystem maturity |
| 12 | Queue system (#234) | Batch generation |
| 13 | Streaming for non-MLX engines | Currently MLX-only |
--- ---
+148 -19
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
echo "Starting backend on http://localhost:17493 ..." backend_pid=""
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
sleep 2 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 &
backend_pid=$!
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
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & backend_pid=""
sleep 2 if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
cd {{ web_dir }} && bun run dev & echo "Backend already running on http://localhost:17493"
wait else
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
backend_pid=$!
sleep 2
fi
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",
+6 -2
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",
@@ -40,5 +40,9 @@
"engines": { "engines": {
"bun": ">=1.0.0" "bun": ">=1.0.0"
}, },
"packageManager": "[email protected]" "packageManager": "[email protected]",
"dependencies": {
"loaders.css": "^0.1.2",
"react-loaders": "^3.0.1"
}
} }
+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.
Binary file not shown.
+35 -2
View File
@@ -16,6 +16,7 @@ struct ServerState {
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>, child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
server_pid: Mutex<Option<u32>>, server_pid: Mutex<Option<u32>>,
keep_running_on_close: Mutex<bool>, keep_running_on_close: Mutex<bool>,
models_dir: Mutex<Option<String>>,
} }
#[command] #[command]
@@ -23,7 +24,16 @@ async fn start_server(
app: tauri::AppHandle, app: tauri::AppHandle,
state: State<'_, ServerState>, state: State<'_, ServerState>,
remote: Option<bool>, remote: Option<bool>,
models_dir: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// Store models_dir for use on restart (empty string means reset to default)
if let Some(ref dir) = models_dir {
if dir.is_empty() {
*state.models_dir.lock().unwrap() = None;
} else {
*state.models_dir.lock().unwrap() = Some(dir.clone());
}
}
// Check if server is already running (managed by this app instance) // Check if server is already running (managed by this app instance)
if state.child.lock().unwrap().is_some() { if state.child.lock().unwrap().is_some() {
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
@@ -274,6 +284,12 @@ async fn start_server(
let port_str = SERVER_PORT.to_string(); let port_str = SERVER_PORT.to_string();
let is_remote = remote.unwrap_or(false); let is_remote = remote.unwrap_or(false);
// Resolve the custom models directory from the parameter or stored state
let effective_models_dir = models_dir.or_else(|| state.models_dir.lock().unwrap().clone());
if let Some(ref dir) = effective_models_dir {
println!("Custom models directory: {}", dir);
}
// If CUDA binary exists, launch it directly instead of the bundled sidecar // If CUDA binary exists, launch it directly instead of the bundled sidecar
let spawn_result = if let Some(ref cuda_path) = cuda_binary { let spawn_result = if let Some(ref cuda_path) = cuda_binary {
println!("Launching CUDA backend: {:?}", cuda_path); println!("Launching CUDA backend: {:?}", cuda_path);
@@ -282,6 +298,9 @@ async fn start_server(
if is_remote { if is_remote {
cmd = cmd.args(["--host", "0.0.0.0"]); cmd = cmd.args(["--host", "0.0.0.0"]);
} }
if let Some(ref dir) = effective_models_dir {
cmd = cmd.env("VOICEBOX_MODELS_DIR", dir);
}
cmd.spawn() cmd.spawn()
} else { } else {
// Use the bundled CPU sidecar // Use the bundled CPU sidecar
@@ -289,6 +308,9 @@ async fn start_server(
if is_remote { if is_remote {
sidecar = sidecar.args(["--host", "0.0.0.0"]); sidecar = sidecar.args(["--host", "0.0.0.0"]);
} }
if let Some(ref dir) = effective_models_dir {
sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir);
}
println!("Spawning server process..."); println!("Spawning server process...");
sidecar.spawn() sidecar.spawn()
}; };
@@ -613,9 +635,19 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
async fn restart_server( async fn restart_server(
app: tauri::AppHandle, app: tauri::AppHandle,
state: State<'_, ServerState>, state: State<'_, ServerState>,
models_dir: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
println!("restart_server: stopping current server..."); println!("restart_server: stopping current server...");
// Update stored models_dir: empty string means reset to default, non-empty means set
if let Some(ref dir) = models_dir {
if dir.is_empty() {
*state.models_dir.lock().unwrap() = None;
} else {
*state.models_dir.lock().unwrap() = Some(dir.clone());
}
}
// Stop the current server // Stop the current server
stop_server(state.clone()).await?; stop_server(state.clone()).await?;
@@ -623,9 +655,9 @@ async fn restart_server(
println!("restart_server: waiting for port release..."); println!("restart_server: waiting for port release...");
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
// Start server again (will auto-detect CUDA binary) // Start server again (will auto-detect CUDA binary and use stored models_dir)
println!("restart_server: starting server..."); println!("restart_server: starting server...");
start_server(app, state, None).await start_server(app, state, None, None).await
} }
#[command] #[command]
@@ -686,6 +718,7 @@ pub fn run() {
child: Mutex::new(None), child: Mutex::new(None),
server_pid: Mutex::new(None), server_pid: Mutex::new(None),
keep_running_on_close: Mutex::new(false), keep_running_on_close: Mutex::new(false),
models_dir: Mutex::new(None),
}) })
.manage(audio_capture::AudioCaptureState::new()) .manage(audio_capture::AudioCaptureState::new())
.manage(audio_output::AudioOutputState::new()) .manage(audio_output::AudioOutputState::new())
+2 -2
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",
@@ -56,7 +56,7 @@
}, },
"plugins": { "plugins": {
"shell": { "shell": {
"open": true "open": ".*"
}, },
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxRENBQkRBQjdBNTM1OTIKUldTU05hVzMycXZjNGJGcUxmcVVocll2QjdSaTJNdlFxR2M3VDJsMnVvbDdyZGRPMmRlOW9aWTcK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxRENBQkRBQjdBNTM1OTIKUldTU05hVzMycXZjNGJGcUxmcVVocll2QjdSaTJNdlFxR2M3VDJsMnVvbDdyZGRPMmRlOW9aWTcK",
+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;
},
}; };
+9 -4
View File
@@ -5,9 +5,12 @@ import type { PlatformLifecycle } from '@/platform/types';
class TauriLifecycle implements PlatformLifecycle { class TauriLifecycle implements PlatformLifecycle {
onServerReady?: () => void; onServerReady?: () => void;
async startServer(remote = false): Promise<string> { async startServer(remote = false, modelsDir?: string | null): Promise<string> {
try { try {
const result = await invoke<string>('start_server', { remote }); const result = await invoke<string>('start_server', {
remote,
modelsDir: modelsDir ?? undefined,
});
console.log('Server started:', result); console.log('Server started:', result);
this.onServerReady?.(); this.onServerReady?.();
return result; return result;
@@ -27,9 +30,11 @@ class TauriLifecycle implements PlatformLifecycle {
} }
} }
async restartServer(): Promise<string> { async restartServer(modelsDir?: string | null): Promise<string> {
try { try {
const result = await invoke<string>('restart_server'); const result = await invoke<string>('restart_server', {
modelsDir: modelsDir ?? undefined,
});
console.log('Server restarted:', result); console.log('Server restarted:', result);
this.onServerReady?.(); this.onServerReady?.();
return result; return result;
+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;
},
}; };
+2 -2
View File
@@ -3,7 +3,7 @@ import type { PlatformLifecycle } from '@/platform/types';
class WebLifecycle implements PlatformLifecycle { class WebLifecycle implements PlatformLifecycle {
onServerReady?: () => void; onServerReady?: () => void;
async startServer(_remote = false): Promise<string> { async startServer(_remote = false, _modelsDir?: string | null): Promise<string> {
// Web assumes server is running externally // Web assumes server is running externally
// Return a default URL - this should be configured via env vars // Return a default URL - this should be configured via env vars
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493'; const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
@@ -15,7 +15,7 @@ class WebLifecycle implements PlatformLifecycle {
// No-op for web - server is managed externally // No-op for web - server is managed externally
} }
async restartServer(): Promise<string> { async restartServer(_modelsDir?: string | null): Promise<string> {
// No-op for web - server is managed externally // No-op for web - server is managed externally
return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493'; return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
} }