mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 14:15:16 -07:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d261c44a1 | ||
|
|
103e98b38f | ||
|
|
1c61b47a64 | ||
|
|
626e3740e1 | ||
|
|
310a4acb02 | ||
|
|
899b90202b | ||
|
|
e8d54d52d3 | ||
|
|
00c5b75ffb | ||
|
|
25134b4ba9 | ||
|
|
3d922ec846 | ||
|
|
638820c839 | ||
|
|
7cbf5a1ded | ||
|
|
e89a7eb7e7 | ||
|
|
e18757bab3 | ||
|
|
0e6c678fc3 | ||
|
|
942dabbcac | ||
|
|
b915825165 | ||
|
|
f3fc63942f | ||
|
|
9044b986f3 | ||
|
|
b01076b6b3 | ||
|
|
5121c76e39 | ||
|
|
49ebf6222e | ||
|
|
509b0e71cc | ||
|
|
81f8be1a94 | ||
|
|
655a60ca81 | ||
|
|
52285362ce | ||
|
|
3ea587797f | ||
|
|
325714bb83 |
+1
-1
@@ -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}
|
||||||
|
|||||||
@@ -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
|
|
||||||
# ...
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 }) }),
|
||||||
|
}));
|
||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.13"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -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
@@ -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)."""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+134
-8
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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.
@@ -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())
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user