Compare commits

..
Author SHA1 Message Date
James Pine a5269d23db Fix keep-server-running on macOS: ignore SIGHUP, watchdog grace period, build script fixes 2026-03-15 08:22:35 -07:00
Jamie Pine fc450e5024 Hide console window for server binary on Windows 2026-03-15 07:57:58 -07:00
Jamie Pine a99c2b572d Show download progress bar for CUDA backend download 2026-03-15 07:50:23 -07:00
Jamie Pine 96289e95f1 Bump version: 0.2.0 → 0.2.1 2026-03-15 06:36:38 -07:00
Jamie PineandGitHub e316b0b4bb Merge pull request #274 from jamiepine/feat/landing-page-redesign
Landing page v0.2.0 redesign
2026-03-15 06:22:04 -07:00
Jamie PineandGitHub 732270b571 Merge pull request #272 from jamiepine/windows-support
Windows support: CUDA detection, cross-platform justfile, clean server shutdown
2026-03-15 06:20:46 -07:00
James Pine 0c6aa15746 Responsive polish: pointer-events-none on animations, sticky header with scroll fade, desktop scroll-to-active fix, iOS audio unlock, player and UI tweaks
- Add pointer-events-none/select-none to feature cards, voice creator, and ControlUI mock
- Sticky header with gradient fade overlay (matching real app 3-layer technique)
- Fix desktop scroll-to-active: separate mobile/desktop card refs to prevent mobile refs overwriting desktop
- Scroll selected card to 2nd row when outside safe zone above generate box
- iOS Safari audio unlock via WaveSurfer's actual media element
- Player: accent fill play/pause button, padding on volume slider, remove close button
- Profile cards: fixed 143px height, mobile edge fades with scroll-aware left fade
- Generate box: accent effect pill when active, white fill sparkle icon, edge-aligned on desktop
- Voice creator: animated waveform background with height-based bars
- 12 profiles (added Attenborough, Zendaya, Obama) for 4-row grid with scroll
2026-03-15 06:17:54 -07:00
Jamie Pine 410413dc57 Watchdog respects keep-server-running setting via /watchdog/disable endpoint 2026-03-15 06:05:17 -07:00
Jamie Pine e239be5bbb Review fixes: CUDA restore in finally, os._exit on Windows, taskkill /T for process tree, build-server-cuda error handling, db-init path 2026-03-15 05:43:26 -07:00
James Pine f80782a90a Landing page v0.2.0 updates: multi-engine copy, star count, model cards, voice creator section, responsive ControlUI, iOS audio fix
- Replace Qwen-specific copy with multi-engine messaging across hero, meta, and features
- Add GitHub star count fetched server-side via /api/stars with Spacedrive-style navbar badge
- Replace 'Why Voicebox exists' section with model cards for all 4 TTS engines
- Enable Linux download card (was 'Coming soon')
- Update GPU support copy to include ROCm, Intel Arc, DirectML
- Add Voice Creator section with animated 3-tab UI (upload, mic, system audio) and waveform background
- Make ControlUI responsive: horizontal scroll cards on mobile, stacked layout, scroll-to-active profile
- Fix iOS Safari audio autoplay (unlock AudioContext on user gesture)
- Fix hero logo square background with mix-blend-lighten
- Remove generation length green coloring, use gray with accent highlights
- Comment out grain overlay (visible tile seams)
- Remove player close button, stack waveform above controls on mobile
- Fixed-height profile cards (143px) with space between badges and buttons
2026-03-15 04:53:28 -07:00
Jamie Pine f1ba73a386 Address review: validate parent-pid, ensure binaries dir exists, fix Xcode typo 2026-03-15 04:09:49 -07:00
Jamie Pine f1963740b4 Fix server binary build, watchdog logging, pedalboard import, window close loop 2026-03-15 04:04:56 -07:00
Jamie Pine 4d6c976ad9 Windows support: CUDA detection, justfile cross-platform, clean server shutdown 2026-03-15 00:02:13 -07:00
Jamie Pine 8377152d86 Redesign landing page with animated ControlUI hero
New Spacedrive-inspired landing page with dark warm color system, glassmorphic navbar, feature cards with animated illustrations, and an interactive ControlUI mockup that cycles through voice generations with real audio playback via WaveSurfer.

The ControlUI demo script is fully data-driven - profiles, generation text, audio samples, and effects are all configurable from a single DEMO_SCRIPT array.

Includes 6 real voice samples (Jarvis, Morgan Freeman, Sam Altman, Samuel L. Jackson, Linus Tech Tips, Fireship) converted to webm opus.
2026-03-14 23:08:29 -07:00
Jamie PineandGitHub 7a511e3756 Merge pull request #271 from jamiepine/feat/post-processing-effects
Add post-processing audio effects system
2026-03-14 12:14:39 -07:00
Jamie PineandGitHub 6d261c44a1 Merge branch 'main' into feat/post-processing-effects 2026-03-14 12:14:26 -07:00
Jamie Pine 103e98b38f github runners suck 2026-03-14 12:13:45 -07:00
Jamie Pine 1c61b47a64 Glassmorphic active state for sidebar buttons with accent border shine 2026-03-14 12:11:07 -07:00
Jamie Pine 626e3740e1 Auto-select first story when navigating to Stories tab 2026-03-14 11:14:46 -07:00
Jamie Pine 310a4acb02 Add source version selection when applying effects, voices tab overhaul with inline inspector 2026-03-14 11:07:32 -07:00
Jamie Pine 899b90202b Add version control to track editor, restyle story list
- Story items can be pinned to a specific generation version via
  toolbar dropdown (shows when clip is selected and has >1 version)
- version_id column on story_items with migration, validated against
  the generation's versions before saving
- Split/duplicate preserve the source clip's pinned version
- Export and playback resolve version-specific audio paths
- Extracted _build_item_detail helper in stories.py (DRY cleanup)
- Story list restyled from rounded cards to flat rows with rounded
  hover/active states, gradient header fade, and dynamic bottom
  padding that accounts for track editor + generate box
2026-03-14 09:56:27 -07:00
Jamie Pine e8d54d52d3 Add favorites, effects badge on profiles, UI polish
- Add is_favorited column with toggle endpoint and star button on history
- Show sparkles icon on profile cards that have effects configured
- Gold ring on selected profile cards
- Smaller, gray action buttons with brighter hover
- Clamp player time to duration to prevent runaway playback
- Align profile card icon to top for wrapped names
- Flush bottom corners on history card when versions expanded
- Simplify .gitignore data/ rule
2026-03-14 09:10:56 -07:00
Jamie Pine 00c5b75ffb Regenerate as new version, UI polish, and bugfixes
- Add /generate/{id}/regenerate endpoint that creates a new take version
- Wire regenerate into history dropdown with SSE progress + autoplay
- Add normalize_audio to regenerate path
- Remove duplicate Regenerate menu item
- Show disabled ellipsis menu during generation instead of hiding it
- Fix sf.write format kwarg broken by asyncio.to_thread migration
- Rename clean version label to 'original', effects to 'version-N'
- Show effects chain names in version list instead of 'N fx'
- Include all versions in export package
- Clean up version panel padding
2026-03-14 08:34:58 -07:00
Jamie Pine 25134b4ba9 Fix player not loading new version after applying effects
Reload the player with the version-specific audio URL when effects are
applied to the currently playing generation. Also consolidate the
instruct/effects buttons into a single button with the effects editor
shown inline when instruct mode is open.
2026-03-14 08:01:45 -07:00
Jamie Pine 3d922ec846 Fix review findings: toggle logic, preset saving, version lookup, async audio ops
- Fix inverted effects toggle in FloatingGenerateBox
- Add Save button + API method for editing custom effect presets
- Return early on effects save failure in ProfileForm
- Fix no-op ternary in effectsStore
- Handle duplicate preset names with proper 400 response
- Use effects_chain is None instead of label for clean version lookup
- Move blocking audio ops to asyncio.to_thread in async endpoints
- Log warnings instead of silently swallowing parse errors
2026-03-14 07:47:06 -07:00
James Pine 638820c839 Add post-processing audio effects system
Adds a full effects pipeline powered by Spotify's pedalboard library,
enabling users to apply professional DSP effects (flanger, reverb, delay,
compressor, pitch shift, filters, gain) to generated audio.

Key features:
- Effects chain editor with drag-and-drop reordering (dnd-kit)
- Generation versions: clean copy always saved, processed versions created on top
- Built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) + custom user presets
- Per-profile default effects chain (auto-applied to new generations)
- Per-generation effects override from the generation form
- Apply effects to existing generations from history (creates new version)
- Ephemeral preview endpoint for auditioning effects without persisting
- Dedicated Effects sidebar tab with preset management and live preview
- Version switcher in history cards with expandable panel
- Backward-compatible: existing generations backfilled as clean versions
2026-03-14 07:11:56 -07:00
Jamie Pine 7cbf5a1ded Use Namespace runner for Linux release build 2026-03-14 05:38:25 -07:00
Jamie Pine e89a7eb7e7 Windows dev experience: CUDA detection, GPU display cleanup, hide drag region, dev-mode update status 2026-03-14 03:24:39 -07:00
Jamie Pine e18757bab3 Skip AppImage on Linux, build deb/rpm only 2026-03-14 03:04:59 -07:00
Jamie Pine 0e6c678fc3 Add Windows support to justfile 2026-03-14 02:41:59 -07:00
Jamie Pine 942dabbcac Install CPU-only PyTorch before requirements.txt on Linux
The previous ordering let requirements.txt pull CUDA-enabled torch
with all nvidia deps first, then the CPU override only swapped the
torch wheel while leaving ~3GB of nvidia packages installed.
2026-03-14 00:55:43 -07:00
Jamie Pine b915825165 Add libasound2-dev to Linux release deps 2026-03-13 21:36:46 -07:00
Jamie Pine f3fc63942f Fix Linux release build exceeding 4GB PyInstaller limit
Install CPU-only PyTorch on Linux and exclude nvidia modules from
non-CUDA PyInstaller builds.
2026-03-13 21:19:24 -07:00
Jamie Pine 9044b986f3 Move tauri imports behind platform abstraction, merge CUDA build into release workflow
- Add openPath and pickDirectory to PlatformFilesystem interface
- Remove direct @tauri-apps/plugin-shell and plugin-dialog imports from app
- Merge build-cuda.yml into release.yml as a parallel job
2026-03-13 11:33:32 -07:00
Jamie Pine b01076b6b3 Bump version: 0.1.13 → 0.2.0 2026-03-13 11:06:40 -07:00
Jamie PineandGitHub 5121c76e39 Merge pull request #269 from jamiepine/feat/async-generation-queue
feat: async generation queue
2026-03-13 10:58:18 -07:00
92 changed files with 8591 additions and 1677 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.1.13
current_version = 0.2.1
commit = True
tag = True
tag_name = v{new_version}
-73
View File
@@ -1,73 +0,0 @@
name: Build CUDA Backend
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact (for testing)
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
# Linux CUDA build can be added later with:
# build-cuda-linux:
# runs-on: ubuntu-22.04
# ...
+68 -13
View File
@@ -22,10 +22,6 @@ jobs:
args: "--target x86_64-apple-darwin"
python-version: "3.12"
backend: "pytorch"
- platform: "ubuntu-22.04"
args: ""
python-version: "3.12"
backend: "pytorch"
- platform: "windows-latest"
args: ""
python-version: "3.12"
@@ -37,10 +33,10 @@ jobs:
- uses: actions/checkout@v4
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
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)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
@@ -55,6 +51,11 @@ jobs:
python-version: ${{ matrix.python-version }}
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
run: |
python -m pip install --upgrade pip
@@ -66,12 +67,6 @@ jobs:
run: |
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)
if: matrix.platform != 'windows-latest'
run: |
@@ -151,10 +146,70 @@ jobs:
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
- **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.
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
includeUpdaterJson: true
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Install PyTorch with CUDA 12.1
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Split binary for GitHub Releases
shell: bash
run: |
python scripts/split_binary.py \
backend/dist/voicebox-server-cuda.exe \
--output release-assets/
- name: Upload split parts to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release-assets/voicebox-server-cuda.part*.exe
release-assets/voicebox-server-cuda.sha256
release-assets/voicebox-server-cuda.manifest
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload binary as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda.exe
retention-days: 7
+1 -4
View File
@@ -35,10 +35,7 @@ target/
Thumbs.db
# Data (user-generated)
data/profiles/*
data/generations/*
data/projects/*
data/voicebox.db
data/
!data/.gitkeep
# Logs
+36 -98
View File
@@ -33,101 +33,41 @@ Thank you for your interest in contributing to Voicebox! This document provides
### Development Setup
**Using `just` (recommended):**
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
```bash
git clone https://github.com/YOUR_USERNAME/voicebox.git
cd voicebox
just setup # creates venv, installs Python + JS deps
just dev # starts backend + desktop app in one terminal
just dev # starts backend + desktop app
```
`just setup` handles everything automatically, including:
- Creating a Python virtual environment
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
- Installing MLX dependencies on Apple Silicon
- Installing JavaScript dependencies
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
Other useful commands:
```bash
just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only
just dev-frontend # Tauri app only (backend must be running)
just kill # stop all dev processes
just clean-all # nuke everything and start fresh
just --list # see all available commands
```
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
> **Note:** In dev mode, the app connects to a manually-started Python server.
> The bundled server binary is only used in production builds.
**Manual setup (required for Windows):**
#### Windows Notes
1. **Fork and clone the repository**
```bash
git clone https://github.com/YOUR_USERNAME/voicebox.git
cd voicebox
```
2. **Install JavaScript dependencies**
```bash
bun install
```
This installs dependencies for:
- `app/` - Shared React frontend
- `tauri/` - Tauri desktop wrapper
- `web/` - Web deployment wrapper
3. **Set up Python backend**
```bash
cd backend
# Create virtual environment
python -m venv venv
# Activate virtual environment
source venv/bin/activate # On macOS/Linux
# or
venv\Scripts\activate # On Windows
# Install Python dependencies
pip install -r requirements.txt
# Install MLX dependencies (Apple Silicon only - for faster inference)
# On Apple Silicon, this enables native Metal acceleration
if [[ $(uname -m) == "arm64" ]]; then
pip install -r requirements-mlx.txt
fi
# Install Qwen3-TTS (required for voice synthesis)
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
```
4. **Start development servers**
Development requires two terminals: one for the Python backend, one for the Tauri app.
**Terminal 1: Backend server** (start this first)
```bash
cd backend
source venv/bin/activate # Activate venv if not already active
bun run dev:server
# Or manually: uvicorn main:app --reload --port 17493
```
Backend will be available at `http://localhost:17493`
**Terminal 2: Desktop app**
```bash
bun run dev
```
This will:
- Create a placeholder sidecar binary (for Tauri compilation)
- Start Vite dev server on port 5173
- Launch Tauri window pointing to localhost:5173
- Connect to the Python server you started in Terminal 1
- Enable hot reload
> **Note:** In dev mode, the app connects to your manually-started Python server.
> The bundled server binary is only used in production builds.
**Optional: Web app**
```bash
bun run dev:web
```
Web app will be available at `http://localhost:5174`
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
### Model Downloads
@@ -139,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will
### Building
**Build everything (recommended):**
**Build production app:**
```bash
bun run build
just build # Build CPU server binary + Tauri installer
```
This automatically:
1. Builds the Python server binary (`./scripts/build-server.sh`)
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
On Windows, to build with CUDA support for local testing:
```bash
just build-local # Build CPU + CUDA server binaries + Tauri installer
```
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
**Individual build targets:**
**Build server binary only:**
```bash
bun run build:server
# or
./scripts/build-server.sh
just build-server # CPU server binary only
just build-server-cuda # CUDA server binary only (Windows)
just build-tauri # Tauri desktop app only
just build-web # Web app only
```
Creates platform-specific binary in `tauri/src-tauri/binaries/`
**Building with local Qwen3-TTS development version:**
@@ -165,17 +110,10 @@ If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_
```bash
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
bun run build:server
just build-server
```
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
**Build web app:**
```bash
cd web
bun run build
```
Output in `web/dist/`
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
### Generate OpenAPI Client
+17 -6
View File
@@ -85,7 +85,7 @@ Voicebox is available now for macOS and Windows.
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
| Windows (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.
---
@@ -240,13 +240,24 @@ just dev # starts backend + desktop app
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/).
### Platform Notes
**Performance:**
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
| Platform | GPU Backend | Notes |
|----------|-------------|-------|
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster inference via Neural Engine |
| Windows (NVIDIA) | PyTorch (CUDA) | `just setup` auto-installs CUDA PyTorch |
| Windows/Linux (no NVIDIA) | PyTorch (CPU) | Works but slower |
### Building Locally
```bash
just build # Build CPU server binary + Tauri app
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
```
`just build-local` produces a production-ready installer with the CUDA binary pre-placed for GPU switching.
### Project Structure
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.1.13",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
+45 -134
View File
@@ -139,7 +139,11 @@ export function AudioPlayer() {
barRadius: 2,
height: 80,
normalize: true,
backend: 'WebAudio',
// Use MediaElement backend (default). Unlike the WebAudio backend,
// MediaElement uses a standard <audio> element for playback which
// benefits from the browser/webview's built-in audio session recovery.
// This prevents audio loss when another app steals audio output or
// the system audio session is interrupted.
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
});
@@ -157,8 +161,21 @@ export function AudioPlayer() {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return;
// Update store when time changes
// Update store when time changes, stop if past duration
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) {
setCurrentTime(dur);
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
setCurrentTime(time);
});
@@ -176,15 +193,6 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
// (unless we're using native playback, which will be set later)
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement && !isUsingNativePlaybackRef.current) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready - check if we should use native playback
// Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl;
@@ -251,21 +259,8 @@ export function AudioPlayer() {
debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) {
debug.log('No custom devices assigned, falling back to WaveSurfer');
// Reset native playback flag and unmute WaveSurfer
debug.log('No custom devices assigned, using standard playback');
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
} else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
debug.log('Device IDs to play to:', deviceIds);
@@ -286,19 +281,10 @@ export function AudioPlayer() {
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer's audio element to prevent UI audio output
// Keep WaveSurfer running for visualization
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log(
'WaveSurfer muted for native playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
// Mute WaveSurfer's audio output — native handles the actual sound
// Keep WaveSurfer running for waveform visualization
wavesurfer.setVolume(0);
wavesurfer.setMuted(true);
// Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => {
@@ -321,38 +307,15 @@ export function AudioPlayer() {
'Native playback failed during auto-play, falling back to WaveSurfer:',
error,
);
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted after native playback failure - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
// Fall through to WaveSurfer playback
}
} else {
debug.log('Not using native playback, using WaveSurfer');
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
}
// Standard playback path — ensure WaveSurfer is unmuted
if (!isUsingNativePlaybackRef.current) {
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
@@ -376,28 +339,6 @@ export function AudioPlayer() {
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
// Ensure audio element volume is set correctly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
// Double-check: if using native playback, keep WaveSurfer muted
// Otherwise, ensure it's unmuted
if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
} else {
// Ensure WaveSurfer is unmuted for normal playback
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'Playing (normal mode) - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
@@ -479,11 +420,6 @@ export function AudioPlayer() {
if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance');
try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy();
} catch (error) {
debug.error('Error destroying WaveSurfer:', error);
@@ -524,13 +460,10 @@ export function AudioPlayer() {
}
// Reset native playback flag when loading new audio
// Also unmute WaveSurfer if it was muted
// Unmute WaveSurfer if it was muted for native playback
if (isUsingNativePlaybackRef.current) {
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
mediaElement.volume = usePlayerStore.getState().volume;
}
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
isUsingNativePlaybackRef.current = false;
@@ -546,16 +479,7 @@ export function AudioPlayer() {
wavesurfer.pause();
}
// Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
debug.log('Stopping media element');
mediaElement.pause();
mediaElement.currentTime = 0;
mediaElement.src = '';
}
// Use empty() to completely destroy the waveform and media element
// Use empty() to completely destroy the waveform and reset media
debug.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
@@ -610,20 +534,13 @@ export function AudioPlayer() {
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
// If using native playback, keep WaveSurfer muted regardless of volume setting
if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else {
mediaElement.volume = volume;
mediaElement.muted = volume === 0;
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
// If using native playback, keep WaveSurfer muted regardless of volume setting
if (isUsingNativePlaybackRef.current) {
wavesurferRef.current.setVolume(0);
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else {
wavesurferRef.current.setVolume(volume);
debug.log('Volume synced:', volume);
}
}
}, [volume]);
@@ -744,11 +661,8 @@ export function AudioPlayer() {
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.volume = 0;
mediaElement.muted = true;
}
wavesurferRef.current.setVolume(0);
wavesurferRef.current.setMuted(true);
// Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => {
@@ -772,11 +686,8 @@ export function AudioPlayer() {
} else {
// Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
mediaElement.volume = volume;
}
wavesurferRef.current.setMuted(false);
wavesurferRef.current.setVolume(volume);
}
wavesurferRef.current.play().catch((error) => {
@@ -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,422 @@
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
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);
// "Save as Custom" dialog state
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState('');
const [saveAsDescription, setSaveAsDescription] = useState('');
// 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);
}
}
function handleSaveAsNew() {
// Open the dialog with a suggested name based on the current preset
setSaveAsName(`${name} (Copy)`);
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: saveAsName.trim(),
description: saveAsDescription.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(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 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>
{/* Save as Custom dialog */}
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Custom Preset</DialogTitle>
<DialogDescription>
Create a new custom preset based on the current effects chain.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="My preset..."
className="h-9"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && saveAsName.trim()) {
handleSaveAsConfirm();
}
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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 { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
@@ -12,6 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { EffectConfig } from '@/lib/api/types';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
@@ -37,6 +39,7 @@ export function FloatingGenerateBox({
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructMode, setIsInstructMode] = useState(false);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const matchRoute = useMatchRoute();
@@ -57,6 +60,7 @@ export function FloatingGenerateBox({
addPendingStoryAdd(generationId, selectedStoryId);
}
},
getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
});
// Click away handler to collapse the box
@@ -355,7 +359,9 @@ export function FloatingGenerateBox({
'h-10 w-10 rounded-full transition-all duration-200',
isInstructMode
? '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={
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
@@ -364,7 +370,7 @@ export function FloatingGenerateBox({
<SlidersHorizontal className="h-4 w-4" />
</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]">
Fine tune instructions
Fine tune instructions & effects
</span>
</div>
</motion.div>
@@ -373,6 +379,23 @@ export function FloatingGenerateBox({
</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>
<motion.div
initial={{ height: 0, opacity: 0 }}
+415 -129
View File
@@ -1,15 +1,22 @@
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
AlignCenter,
AudioLines,
AudioWaveform,
Download,
FileArchive,
Loader2,
MoreHorizontal,
Play,
RotateCcw,
Star,
Trash2,
Wand2,
} from 'lucide-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 {
Dialog,
@@ -25,10 +32,17 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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 type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useDeleteGeneration,
@@ -60,6 +74,15 @@ export function HistoryTable() {
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 { toast } = useToast();
const queryClient = useQueryClient();
@@ -215,6 +238,106 @@ export function HistoryTable() {
}
};
const handleRegenerate = async (generationId: string) => {
try {
await apiClient.regenerateGeneration(generationId);
addPendingGeneration(generationId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Regenerate failed',
description: error instanceof Error ? error.message : 'Could not regenerate',
variant: 'destructive',
});
}
};
const handleToggleFavorite = async (generationId: string) => {
try {
await apiClient.toggleFavorite(generationId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Failed to update favorite',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handleApplyEffects = (generationId: string) => {
const gen = allHistory.find((g) => g.id === generationId);
const versions = gen?.versions ?? [];
setEffectsTargetId(generationId);
setEffectsTargetVersions(versions);
// Default to clean/original version (no effects chain)
const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0);
setEffectsSourceVersionId(cleanVersion?.id ?? null);
setEffectsChain([]);
setEffectsDialogOpen(true);
};
const handleApplyEffectsConfirm = async () => {
if (!effectsTargetId || effectsChain.length === 0) return;
setApplyingEffects(true);
try {
const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, {
effects_chain: effectsChain,
source_version_id: effectsSourceVersionId ?? undefined,
set_as_default: true,
});
queryClient.invalidateQueries({ queryKey: ['history'] });
// If the player is currently on this generation, reload with the new version audio
if (currentAudioId === effectsTargetId) {
const gen = allHistory.find((g) => g.id === effectsTargetId);
if (gen) {
const versionUrl = apiClient.getVersionAudioUrl(newVersion.id);
setAudioWithAutoPlay(
versionUrl,
effectsTargetId,
gen.profile_id,
gen.text.substring(0, 50),
);
}
}
setEffectsDialogOpen(false);
toast({ title: 'Effects applied', description: 'A new version has been created.' });
} catch (error) {
toast({
title: 'Failed to apply effects',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setApplyingEffects(false);
}
};
const handleSwitchVersion = async (generationId: string, versionId: string) => {
try {
await apiClient.setDefaultVersion(generationId, versionId);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Failed to switch version',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handlePlayVersion = (
generationId: string,
versionId: string,
text: string,
profileId: string,
) => {
const audioUrl = apiClient.getVersionAudioUrl(versionId);
setAudioWithAutoPlay(audioUrl, generationId, profileId, text.substring(0, 50));
};
const handleImportConfirm = () => {
if (selectedFile) {
importGeneration.mutate(selectedFile, {
@@ -274,151 +397,263 @@ export function HistoryTable() {
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 (
<div
key={gen.id}
role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined}
className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
isPlayable && 'hover:bg-muted/70 cursor-pointer',
'border rounded-md bg-card transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70',
)}
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);
}
}}
>
{/* Status icon */}
<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}
{/* Main row */}
<div
role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined}
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);
}
}}
>
{/* Status icon */}
<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>
</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>
{/* 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
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
isVersionsExpanded && 'text-accent hover:text-accent',
)}
aria-label="Toggle versions"
onClick={() => setExpandedVersionsId(isVersionsExpanded ? null : gen.id)}
>
<AudioLines className="h-2 w-2" />
</Button>
)}
{isFailed ? (
<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>
<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)}
>
<RotateCcw className="h-2 w-2" />
</Button>
) : (
formatDate(gen.created_at)
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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="Actions"
disabled={isGenerating}
>
<MoreHorizontal className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<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>
{/* 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="w-10 shrink-0 flex justify-end items-center"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{isFailed ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
{/* 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"
>
<RotateCcw className="h-4 w-4" />
</Button>
) : isPlayable ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<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={() => 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>
) : null}
</div>
<div className="border-t border-border/50">
<div className="divide-y divide-border/40">
{gen.versions.map((v) => {
// Show source provenance when effects were applied to a non-clean version
const sourceVersion = v.source_version_id
? gen.versions?.find((sv) => sv.id === v.source_version_id)
: null;
const showSource =
sourceVersion &&
sourceVersion.effects_chain &&
sourceVersion.effects_chain.length > 0;
return (
<button
key={v.id}
type="button"
className="flex items-center gap-2 w-full h-9 px-3 text-left hover:bg-muted/50 transition-colors"
onClick={() => {
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
if (!v.is_default) {
handleSwitchVersion(gen.id, v.id);
}
}}
>
<AudioLines className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">{v.label}</span>
{v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-[10px] text-muted-foreground truncate">
{v.effects_chain.map((e) => e.type).join(' → ')}
</span>
)}
{showSource && (
<span className="text-[10px] text-muted-foreground/60 truncate">
from {sourceVersion.label}
</span>
)}
<span className="flex-1" />
{v.is_default && (
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
active
</span>
)}
</button>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
@@ -500,6 +735,57 @@ export function HistoryTable() {
</DialogFooter>
</DialogContent>
</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>
);
}
@@ -113,7 +113,7 @@ export function ConnectionForm() {
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</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>
)}
</div>
@@ -26,7 +26,7 @@ export function GpuAcceleration() {
// Query CUDA backend status
const {
data: cudaStatus,
isLoading: cudaStatusLoading,
isLoading: _cudaStatusLoading,
refetch: refetchCudaStatus,
} = useQuery({
queryKey: ['cuda-status', serverUrl],
@@ -218,35 +218,33 @@ export function GpuAcceleration() {
<CardTitle>GPU Acceleration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current status */}
{/* GPU status */}
<div className="space-y-1">
<div className="text-sm font-medium">Backend</div>
<div className="text-sm text-muted-foreground">
{isCurrentlyCuda
? 'CUDA (GPU accelerated)'
: hasNativeGpu
? `${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
{health.gpu_available && health.gpu_type ? (
<>
<div className="text-sm font-medium">
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type}
</div>
)}
</div>
)}
<div className="text-sm text-muted-foreground">
{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 */}
{/* CUDA download section - only show when native GPU is NOT detected (i.e., Windows/Linux NVIDIA users) */}
{!hasNativeGpu && (
{/* CUDA download section - only show when no GPU is active (native or CUDA) */}
{!hasNativeGpu && !isCurrentlyCuda && (
<>
{/* Download progress */}
{cudaDownloading && downloadProgress && (
@@ -446,8 +446,7 @@ export function ModelManagement() {
className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => {
try {
const { open } = await import('@tauri-apps/plugin-shell');
await open(cacheDir.path);
await platform.filesystem.openPath(cacheDir.path);
} catch {
toast({ title: 'Failed to open model folder', variant: 'destructive' });
}
@@ -462,14 +461,9 @@ export function ModelManagement() {
className="text-xs text-muted-foreground h-7 px-2"
onClick={async () => {
try {
const { open: openDialog } = await import('@tauri-apps/plugin-dialog');
const selected = await openDialog({
directory: true,
title: 'Choose model storage folder',
});
if (!selected) return;
const newDir =
typeof selected === 'string' ? selected : (selected as { path: string }).path;
const newDir = await platform.filesystem.pickDirectory(
'Choose model storage folder',
);
if (!newDir) return;
setPendingMigrateDir(newDir);
} catch {
@@ -11,6 +11,7 @@ export function UpdateStatus() {
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
@@ -20,11 +21,7 @@ export function UpdateStatus() {
}, [platform]);
return (
<Card
role="region"
aria-label="App Updates"
tabIndex={0}
>
<Card role="region" aria-label="App Updates" tabIndex={0}>
<CardHeader>
<CardTitle>App Updates</CardTitle>
</CardHeader>
@@ -32,97 +29,110 @@ export function UpdateStatus() {
<div className="flex items-center justify-between">
<div className="space-y-1">
<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>
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.readyToInstall}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
{!isDev && (
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.readyToInstall}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
)}
</div>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
{isDev ? (
<div className="text-sm text-muted-foreground">
Auto-updates are disabled in development mode.
</div>
)}
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</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>
) : (
<>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
</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 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Download className="h-4 w-4" />
Downloading update...
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</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
)}
{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>
<Badge>New</Badge>
</div>
)}
</div>
)}
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
</Button>
</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>
{status.downloading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<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">
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>
<Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</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 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
You're up to date
</div>
{!status.available && !status.checking && !status.error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
You're up to date
</div>
)}
</>
)}
</CardContent>
</Card>
+31 -8
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
@@ -11,8 +11,9 @@ interface SidebarProps {
const tabs = [
{ 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: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
@@ -31,30 +32,52 @@ export function Sidebar({ isMacOS }: SidebarProps) {
>
{/* Logo */}
<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>
{/* Navigation Buttons */}
<div className="flex flex-col gap-3">
{tabs.map((tab) => {
{tabs.map((tab, index) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
// Accent fades as buttons get further from the logo
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
return (
<Link
key={tab.id}
to={tab.path}
className={cn(
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
'hover:bg-muted/50',
isActive ? 'bg-muted/50 text-foreground shadow-lg' : 'text-muted-foreground',
'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
isActive
? '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}
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) / ${accentOpacity})`,
}}
/>
)}
<Icon className="h-5 w-5 relative z-10" />
</Link>
);
})}
+96 -79
View File
@@ -1,5 +1,5 @@
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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 { formatDate } from '@/lib/utils/format';
import { useStoryStore } from '@/stores/storyStore';
@@ -38,6 +44,8 @@ export function StoryList() {
const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: selectedStory } = useStory(selectedStoryId);
const createStory = useCreateStory();
const updateStory = useUpdateStory();
const deleteStory = useDeleteStory();
@@ -54,6 +62,13 @@ export function StoryList() {
const [newStoryDescription, setNewStoryDescription] = useState('');
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 = () => {
if (!newStoryName.trim()) {
toast({
@@ -170,20 +185,29 @@ export function StoryList() {
}
const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4 px-1">
<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 className="h-full 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 */}
<div className="absolute top-0 left-0 right-0 z-20">
<div className="flex items-center justify-between mb-4 px-1">
<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>
{/* Story List */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{/* Scrollable Story List */}
<div
className="flex-1 overflow-y-auto pt-14 relative z-0"
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? (
<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" />
@@ -191,75 +215,68 @@ export function StoryList() {
<p className="text-xs mt-2">Create your first story to get started</p>
</div>
) : (
storyList.map((story) => (
<div
key={story.id}
role="button"
tabIndex={0}
className={cn(
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
selectedStoryId === story.id && 'bg-muted border-primary',
)}
aria-label={
selectedStoryId === story.id
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
}
aria-pressed={selectedStoryId === story.id}
onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
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="font-medium truncate">{story.name}</h3>
{story.description && (
<p className="text-sm text-muted-foreground mt-1 truncate">
{story.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span>
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
</span>
<span>•</span>
<span>{formatDate(story.updated_at)}</span>
<div className="space-y-0.5">
{storyList.map((story) => (
<div
key={story.id}
role="button"
tabIndex={0}
className={cn(
'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={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
aria-pressed={selectedStoryId === story.id}
onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
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-center gap-2 mt-1 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>
<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>
@@ -1,5 +1,7 @@
import {
Check,
Copy,
GalleryVerticalEnd,
GripHorizontal,
Minus,
Pause,
@@ -12,6 +14,12 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
@@ -19,6 +27,7 @@ import {
useDuplicateStoryItem,
useMoveStoryItem,
useRemoveStoryItem,
useSetStoryItemVersion,
useSplitStoryItem,
useTrimStoryItem,
} from '@/lib/hooks/useStories';
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support
function ClipWaveform({
generationId,
versionId,
width,
trimStartMs,
trimEndMs,
duration,
}: {
generationId: string;
versionId?: string;
width: number;
trimStartMs: number;
trimEndMs: number;
@@ -79,7 +90,9 @@ function ClipWaveform({
wavesurferRef.current = wavesurfer;
const audioUrl = apiClient.getAudioUrl(generationId);
const audioUrl = versionId
? apiClient.getVersionAudioUrl(versionId)
: apiClient.getAudioUrl(generationId);
wavesurfer.load(audioUrl).catch(() => {
// Ignore load errors
});
@@ -88,7 +101,7 @@ function ClipWaveform({
wavesurfer.destroy();
wavesurferRef.current = null;
};
}, [generationId, fullWaveformWidth]);
}, [generationId, versionId, fullWaveformWidth]);
return (
<div className="w-full h-full opacity-60 overflow-hidden">
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const { toast } = useToast();
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
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
const [trimmingItem, setTrimmingItem] = useState<string | 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" />
</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>
)}
@@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
<div className="absolute inset-0 top-3">
<ClipWaveform
generationId={item.generation_id}
versionId={item.version_id}
width={clipWidth}
trimStartMs={displayTrimStart}
trimEndMs={displayTrimEnd}
+5 -6
View File
@@ -1,8 +1,7 @@
const isWindows = navigator.userAgent.includes('Windows');
export function TitleBarDragRegion() {
return (
<div
data-tauri-drag-region
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
/>
);
if (isWindows) return null;
return <div data-tauri-drag-region 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
interface ProfileCardProps {
@@ -24,19 +23,16 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id;
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id);
};
@@ -79,7 +75,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<Card
className={cn(
'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}
tabIndex={0}
@@ -89,22 +85,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
onKeyDown={handleKeyDown}
>
<CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 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>
<CardTitle className="text-base font-medium">
<span className="break-words">{profile.name}</span>
</CardTitle>
</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">
{profile.description || 'No description'}
</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">
{profile.language}
</Badge>
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
@@ -3,6 +3,7 @@ import { Edit2, Mic, Monitor, Upload, 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 {
Dialog,
@@ -30,6 +31,8 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
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 { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
@@ -125,6 +128,8 @@ export function ProfileForm() {
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId;
const serverUrl = useServerStore((state) => state.serverUrl);
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
@@ -280,6 +285,8 @@ export function ProfileForm() {
referenceText: undefined,
avatarFile: undefined,
});
setProfileEffectsChain(editingProfile.effects_chain ?? []);
setEffectsDirty(false);
} else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
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({
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
@@ -898,6 +923,23 @@ export function ProfileForm() {
</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>
@@ -0,0 +1,340 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { SampleList } from '@/components/VoiceProfiles/SampleList';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useDeleteAvatar,
useProfile,
useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const updateProfile = useUpdateProfile();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const serverUrl = useServerStore((state) => state.serverUrl);
const { toast } = useToast();
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
},
});
// Populate form when profile loads
useEffect(() => {
if (profile) {
form.reset({
name: profile.name,
description: profile.description || '',
language: profile.language as LanguageCode,
});
setEffectsChain(profile.effects_chain ?? []);
setEffectsDirty(false);
}
}, [profile, form]);
// Avatar preview
useEffect(() => {
if (profile?.avatar_path) {
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
} else {
setAvatarPreview(null);
}
setAvatarError(false);
}, [profile, serverUrl]);
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
variant: 'destructive',
});
return;
}
// Upload immediately
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: 'Avatar updated' });
},
onError: (err) => {
toast({
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
},
},
);
}
async function handleRemoveAvatar() {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: 'Avatar removed' });
} catch (err) {
toast({
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
}
}
setAvatarPreview(null);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
async function onSubmit(data: ProfileFormValues) {
try {
await updateProfile.mutateAsync({
profileId,
data: {
name: data.name,
description: data.description,
language: data.language,
},
});
if (effectsDirty) {
try {
await apiClient.updateProfileEffects(
profileId,
effectsChain.length > 0 ? effectsChain : null,
);
setEffectsDirty(false);
} catch (fxError) {
toast({
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
variant: 'destructive',
});
return;
}
}
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
}
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
);
}
const isDirty = form.formState.isDirty || effectsDirty;
return (
<div className="h-full flex flex-col overflow-hidden">
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
{/* Avatar */}
<div className="flex justify-center pt-5 pb-3">
<div className="relative group">
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview && !avatarError ? (
<img
src={avatarPreview}
alt={profile.name}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-8 w-8 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-5 w-5 text-accent-foreground" />
</button>
{avatarPreview && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
{/* Fields */}
<div className="space-y-3 px-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Effects */}
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
Applied automatically to new generations with this voice.
</p>
<EffectsChainEditor
value={effectsChain}
onChange={(chain) => {
setEffectsChain(chain);
setEffectsDirty(true);
}}
compact
/>
</div>
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</Button>
)}
</div>
{/* Samples */}
<div className="px-5 pb-5">
<SampleList profileId={profileId} />
</div>
</form>
</Form>
</div>
</div>
);
}
+141 -124
View File
@@ -1,13 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
import { useMemo, useRef } from 'react';
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import {
Table,
@@ -21,33 +17,46 @@ import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useHistory } from '@/lib/hooks/useHistory';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles();
const { data: historyData } = useHistory({ limit: 1000 });
const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const deleteProfile = useDeleteProfile();
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const [search, setSearch] = useState('');
// Get generation counts per profile
const generationCounts = useMemo(() => {
const counts: Record<string, number> = {};
if (historyData?.items) {
historyData.items.forEach((item) => {
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
});
const filteredProfiles = useMemo(() => {
if (!profiles) return [];
if (!search.trim()) return profiles;
const q = search.toLowerCase();
return profiles.filter(
(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;
}, [historyData]);
// Clear selection if selected profile was deleted
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
const { data: channelAssignments } = useQuery({
@@ -74,17 +83,6 @@ export function VoicesTab() {
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[]) => {
try {
await apiClient.setProfileChannels(profileId, channelIds);
@@ -103,56 +101,76 @@ export function VoicesTab() {
}
return (
<div className="h-full flex flex-col relative overflow-hidden">
{/* 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-10 pointer-events-none" />
<div className="h-full flex gap-0 overflow-hidden -mx-8">
{/* Left: Table */}
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Voices</h1>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Voice
</Button>
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">Voices</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search voices..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<Button onClick={() => setDialogOpen(true)}>
<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>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-y-auto pt-16 relative z-0',
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>
{/* Right: Inspector */}
{selectedVoiceId && (
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
</div>
)}
<ProfileForm />
</div>
@@ -161,42 +179,46 @@ export function VoicesTab() {
interface VoiceRowProps {
profile: VoiceProfileResponse;
generationCount: number;
isSelected: boolean;
onSelect: () => void;
channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>;
onChannelChange: (channelIds: string[]) => void;
onEdit: () => void;
onDelete: () => void;
}
function VoiceRow({
profile,
generationCount,
isSelected,
onSelect,
channelIds,
channels,
onChannelChange,
onEdit,
onDelete,
}: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id);
const sampleCount = samples?.length || 0;
const serverUrl = useServerStore((state) => state.serverUrl);
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 (
<TableRow className="cursor-pointer" onClick={onEdit}>
<TableRow
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
onClick={onSelect}
>
<TableCell>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
aria-label={rowLabel}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mic className="h-4 w-4 text-muted-foreground" />
<div className="flex w-full min-w-0 items-center gap-2">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<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>
</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 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()}>
<MultiSelect
options={channels.map((ch) => ({
@@ -218,28 +253,10 @@ function VoiceRow({
value={channelIds}
onChange={onChannelChange}
placeholder="Select channels..."
className="min-w-[200px]"
className="w-full"
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<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>
<TableCell />
</TableRow>
);
}
+5 -3
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -73,7 +73,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
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',
className,
)}
@@ -154,7 +154,9 @@ const DropdownMenuSeparator = React.forwardRef<
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
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';
+1 -1
View File
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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,
)}
{...props}
+6 -5
View File
@@ -14,7 +14,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ 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';
@@ -42,10 +46,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
className={cn('border-b hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
),
+127
View File
@@ -2,9 +2,15 @@ import type { LanguageCode } from '@/lib/constants/languages';
import { useServerStore } from '@/stores/serverStore';
import type {
ActiveTasksResponse,
ApplyEffectsRequest,
AvailableEffectsResponse,
CudaStatus,
EffectConfig,
EffectPresetCreate,
EffectPresetResponse,
GenerationRequest,
GenerationResponse,
GenerationVersionResponse,
HealthResponse,
HistoryListResponse,
HistoryQuery,
@@ -21,6 +27,7 @@ import type {
StoryItemReorder,
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
@@ -206,6 +213,18 @@ class ApiClient {
});
}
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
method: 'POST',
});
}
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
method: 'POST',
});
}
// History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams();
@@ -570,6 +589,17 @@ class ApiClient {
});
}
async setStoryItemVersion(
storyId: string,
itemId: string,
data: StoryItemVersionUpdate,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
@@ -583,6 +613,103 @@ class ApiClient {
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();
+82
View File
@@ -13,6 +13,9 @@ export interface VoiceProfileResponse {
description?: string;
language: string;
avatar_path?: string;
effects_chain?: EffectConfig[];
generation_count: number;
sample_count: number;
created_at: string;
updated_at: string;
}
@@ -28,6 +31,12 @@ export interface ProfileSampleResponse {
reference_text: string;
}
export interface EffectConfig {
type: string;
enabled: boolean;
params: Record<string, number>;
}
export interface GenerationRequest {
profile_id: string;
text: string;
@@ -39,6 +48,18 @@ export interface GenerationRequest {
max_chunk_chars?: 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 {
@@ -54,7 +75,10 @@ export interface GenerationResponse {
model_size?: string;
status: 'generating' | 'completed' | 'failed';
error?: string;
is_favorited?: boolean;
created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface HistoryQuery {
@@ -66,6 +90,8 @@ export interface HistoryQuery {
export interface HistoryResponse extends GenerationResponse {
profile_name: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface HistoryListResponse {
@@ -199,6 +225,7 @@ export interface StoryItemDetail {
id: string;
story_id: string;
generation_id: string;
version_id?: string;
start_time_ms: number;
track: number;
trim_start_ms: number;
@@ -213,6 +240,12 @@ export interface StoryItemDetail {
seed?: number;
instruct?: string;
generation_created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface StoryItemVersionUpdate {
version_id: string | null;
}
export interface StoryDetailResponse {
@@ -256,3 +289,52 @@ export interface StoryItemTrim {
export interface StoryItemSplit {
split_time_ms: number;
}
// Effects
export interface EffectPresetResponse {
id: string;
name: string;
description?: string;
effects_chain: EffectConfig[];
is_builtin: boolean;
created_at: string;
}
export interface EffectPresetCreate {
name: string;
description?: string;
effects_chain: EffectConfig[];
}
export interface EffectPresetUpdate {
name?: string;
description?: string;
effects_chain?: EffectConfig[];
}
export interface AvailableEffectParam {
default: number;
min: number;
max: number;
step: number;
description: string;
}
export interface AvailableEffect {
type: string;
label: string;
description: string;
params: Record<string, AvailableEffectParam>;
}
export interface AvailableEffectsResponse {
effects: AvailableEffect[];
}
export interface ApplyEffectsRequest {
effects_chain: EffectConfig[];
source_version_id?: string;
label?: string;
set_as_default?: boolean;
}
+5 -2
View File
@@ -2,11 +2,14 @@
* UI layout constants for safe area padding
*/
const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
/**
* 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
+5 -1
View File
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
@@ -11,7 +12,7 @@ import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
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[]]),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
@@ -24,6 +25,7 @@ export type GenerationFormValues = z.infer<typeof generationSchema>;
interface UseGenerationFormOptions {
onSuccess?: (generationId: string) => void;
defaultValues?: Partial<GenerationFormValues>;
getEffectsChain?: () => EffectConfig[] | undefined;
}
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
@@ -103,6 +105,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
}
const isQwen = engine === 'qwen';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
@@ -115,6 +118,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
effects_chain: effectsChain?.length ? effectsChain : undefined,
});
// Track this generation for SSE status updates
+61 -8
View File
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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';
export function useStories() {
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
apiClient.moveStoryItem(storyId, itemId, data),
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemMove;
}) => apiClient.moveStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
apiClient.trimStoryItem(storyId, itemId, data),
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemTrim;
}) => apiClient.trimStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
apiClient.splitStoryItem(storyId, itemId, data),
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemSplit;
}) => apiClient.splitStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
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() {
const platform = usePlatform();
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
const blob = await apiClient.exportStoryAudio(storyId);
// 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`;
await platform.filesystem.saveFile(filename, blob, [
+23 -11
View File
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
}
}, []);
// Resolve the audio buffer key and URL for an item.
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
const getAudioKey = (item: StoryItemDetail) =>
item.version_id ? `v:${item.version_id}` : item.generation_id;
const getAudioUrlForItem = (item: StoryItemDetail) =>
item.version_id
? apiClient.getVersionAudioUrl(item.version_id)
: apiClient.getAudioUrl(item.generation_id);
// Preload audio files as AudioBuffers
useEffect(() => {
if (!items || items.length === 0) {
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return;
}
const currentIds = new Set(items.map((item) => item.generation_id));
const currentKeys = new Set(items.map(getAudioKey));
const audioContext = getAudioContext();
// Remove buffers for items that no longer exist
for (const [id] of audioBuffersRef.current) {
if (!currentIds.has(id)) {
if (!currentKeys.has(id)) {
audioBuffersRef.current.delete(id);
}
}
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Preload audio for new items
const preloadPromises: Promise<void>[] = [];
for (const item of items) {
if (!audioBuffersRef.current.has(item.generation_id)) {
const audioUrl = apiClient.getAudioUrl(item.generation_id);
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
const key = getAudioKey(item);
if (!audioBuffersRef.current.has(key)) {
const audioUrl = getAudioUrlForItem(item);
console.log('[StoryPlayback] Preloading audio buffer:', key);
const preloadPromise = fetch(audioUrl)
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
.then((audioBuffer) => {
audioBuffersRef.current.set(item.generation_id, audioBuffer);
audioBuffersRef.current.set(key, audioBuffer);
console.log(
'[StoryPlayback] Preloaded buffer:',
item.generation_id,
key,
'duration:',
audioBuffer.duration,
);
})
.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);
@@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Schedule new sources for items that should be playing
for (const item of shouldBePlaying) {
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) {
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
continue;
}
// Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
// Calculate effective duration and trim offsets
const trimStartSec = (item.trim_start_ms || 0) / 1000;
const trimEndSec = (item.trim_end_ms || 0) / 1000;
+2
View File
@@ -10,6 +10,8 @@ export interface FileFilter {
export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
openPath(path: string): Promise<void>;
pickDirectory(title: string): Promise<string | null>;
}
export interface UpdateStatus {
+9
View File
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab';
@@ -105,6 +106,13 @@ const audioRoute = createRoute({
component: AudioTab,
});
// Effects route
const effectsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/effects',
component: EffectsTab,
});
// Models route
const modelsRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -125,6 +133,7 @@ const routeTree = rootRoute.addChildren([
storiesRoute,
voicesRoute,
audioRoute,
effectsRoute,
modelsRoute,
serverRoute,
]);
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { EffectConfig } from '@/lib/api/types';
interface EffectsStore {
selectedPresetId: string | null;
setSelectedPresetId: (id: string | null) => void;
// Working chain for the detail panel (editing a preset or building a new one)
workingChain: EffectConfig[];
setWorkingChain: (chain: EffectConfig[]) => void;
// Track if editing an existing preset vs creating new
isCreatingNew: boolean;
setIsCreatingNew: (v: boolean) => void;
}
export const useEffectsStore = create<EffectsStore>((set) => ({
selectedPresetId: null,
setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
workingChain: [],
setWorkingChain: (chain) => set({ workingChain: chain }),
isCreatingNew: false,
setIsCreatingNew: (v) => set({ isCreatingNew: v, ...(v && { selectedPresetId: null }) }),
}));
+7
View File
@@ -31,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null;
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)
profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
@@ -55,6 +59,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.1.13"
__version__ = "0.2.1"
+92 -1
View File
@@ -10,6 +10,7 @@ import PyInstaller.__main__
import argparse
import os
import platform
import sys
from pathlib import Path
@@ -33,6 +34,7 @@ def build_server(cuda=False):
args = [
'server.py', # Use server.py as entry point instead of main.py
'--onefile',
'--noconsole', # No visible console window on Windows
'--name', binary_name,
]
@@ -62,6 +64,10 @@ def build_server(cuda=False):
'--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation',
'--hidden-import', 'backend.cuda_download',
'--hidden-import', 'backend.effects',
'--hidden-import', 'backend.utils.effects',
'--hidden-import', 'backend.versions',
'--hidden-import', 'pedalboard',
'--hidden-import', 'torch',
'--hidden-import', 'transformers',
'--hidden-import', 'fastapi',
@@ -90,6 +96,19 @@ def build_server(cuda=False):
'--hidden-import', 'torch.cuda',
'--hidden-import', 'torch.backends.cudnn',
])
else:
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
# When building from a venv with CUDA torch installed, PyInstaller would
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
# modules and the binary DLLs.
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)
if is_apple_silicon() and not cuda:
@@ -115,7 +134,12 @@ def build_server(cuda=False):
elif not cuda:
print("Building for non-Apple Silicon platform - PyTorch only")
dist_dir = str(backend_dir / 'dist')
build_dir = str(backend_dir / 'build')
args.extend([
'--distpath', dist_dir,
'--workpath', build_dir,
'--noconfirm',
'--clean',
])
@@ -123,12 +147,79 @@ def build_server(cuda=False):
# Change to backend directory
os.chdir(backend_dir)
# For CPU builds on Windows, ensure we're using CPU-only torch.
# If CUDA torch is installed (local dev), swap to CPU torch before building,
# then restore CUDA torch after. This prevents PyInstaller from bundling
# ~3GB of CUDA DLLs into the CPU binary.
restore_cuda = False
if not cuda and platform.system() == "Windows":
import subprocess
result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
capture_output=True, text=True
)
has_cuda_torch = bool(result.stdout.strip())
if has_cuda_torch:
print("CUDA torch detected — installing CPU torch for CPU build...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
"--index-url", "https://download.pytorch.org/whl/cpu", "--force-reinstall", "-q"],
check=True
)
restore_cuda = True
# Run PyInstaller
PyInstaller.__main__.run(args)
try:
PyInstaller.__main__.run(args)
finally:
# Restore CUDA torch if we swapped it out (even on build failure)
if restore_cuda:
print("Restoring CUDA torch...")
import subprocess
subprocess.run(
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
"--index-url", "https://download.pytorch.org/whl/cu126", "--force-reinstall", "-q"],
check=True
)
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
def _get_cuda_dll_excludes():
"""Get list of CUDA DLL filenames to exclude from CPU builds.
When building locally with CUDA torch installed, PyInstaller bundles ~3GB of
CUDA DLLs from torch/lib/. Returns a list of DLL filenames to exclude.
"""
try:
import torch
torch_lib = Path(torch.__file__).parent / 'lib'
except ImportError:
return []
cuda_prefixes = (
'torch_cuda', 'cublas', 'cublasLt', 'cudnn', 'cusparse', 'cufft',
'cusolver', 'cusolverMg', 'curand', 'nvrtc', 'nvJitLink', 'nccl',
'nvperf', 'nvrtc-builtins',
)
exclude_dlls = []
if torch_lib.exists():
for f in torch_lib.iterdir():
if f.suffix == '.dll' and any(f.name.startswith(p) for p in cuda_prefixes):
exclude_dlls.append(f.name)
if exclude_dlls:
total_mb = sum(
(torch_lib / dll).stat().st_size
for dll in exclude_dlls
if (torch_lib / dll).exists()
) / 1024 / 1024
print(f"CPU build: will exclude {len(exclude_dlls)} CUDA DLLs ({total_mb:.0f} MB)")
return exclude_dlls
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
parser.add_argument(
+13 -2
View File
@@ -129,6 +129,17 @@ async def download_cuda_binary(version: Optional[str] = None):
except Exception as e:
logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
# Get total size across all parts by issuing HEAD requests
total_size = 0
for part_name in parts:
try:
head_resp = await client.head(f"{base_url}/{part_name}")
content_length = int(head_resp.headers.get("content-length", 0))
total_size += content_length
except Exception:
pass
logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
# Download and concatenate parts
total_downloaded = 0
with open(temp_path, "wb") as f:
@@ -142,8 +153,8 @@ async def download_cuda_binary(version: Optional[str] = None):
f.write(chunk)
total_downloaded += len(chunk)
progress.update_progress(
PROGRESS_KEY, current=total_downloaded, total=0,
filename=f"Part {i + 1}/{len(parts)}",
PROGRESS_KEY, current=total_downloaded, total=total_size,
filename=f"Downloading CUDA backend ({i + 1}/{len(parts)})",
status="downloading",
)
+155
View File
@@ -23,6 +23,7 @@ class VoiceProfile(Base):
description = Column(Text)
language = Column(String, default="en")
avatar_path = Column(String, nullable=True)
effects_chain = Column(Text, nullable=True) # JSON-serialized default effects chain
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -53,6 +54,7 @@ class Generation(Base):
model_size = Column(String, nullable=True)
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)
@@ -74,6 +76,7 @@ class StoryItem(Base):
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
story_id = Column(String, ForeignKey("stories.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
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
@@ -92,6 +95,33 @@ class Project(Base):
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):
"""Audio channel (bus) database model."""
__tablename__ = "audio_channels"
@@ -169,6 +199,12 @@ def init_db():
finally:
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):
"""Run database migrations."""
@@ -322,6 +358,125 @@ def _run_migrations(engine):
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():
"""Get database session (generator for dependency injection)."""
+120
View File
@@ -0,0 +1,120 @@
"""
Effect presets CRUD operations.
"""
from __future__ import annotations
import json
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .database import EffectPreset as DBEffectPreset
from .models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
"""Convert a DB preset row to a Pydantic response."""
effects_chain = [EffectConfig(**e) for e in json.loads(p.effects_chain)]
return EffectPresetResponse(
id=p.id,
name=p.name,
description=p.description,
effects_chain=effects_chain,
is_builtin=p.is_builtin or False,
created_at=p.created_at,
)
def list_presets(db: Session) -> List[EffectPresetResponse]:
"""List all effect presets (built-in + user-created)."""
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
return [_preset_response(p) for p in presets]
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
"""Get a preset by ID."""
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not p:
return None
return _preset_response(p)
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
"""Get a preset by name."""
p = db.query(DBEffectPreset).filter_by(name=name).first()
if not p:
return None
return _preset_response(p)
def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
"""Create a new user effect preset."""
from .utils.effects import validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise ValueError(error)
# Check for duplicate name before insert
existing = db.query(DBEffectPreset).filter_by(name=data.name).first()
if existing:
raise ValueError(f"A preset named '{data.name}' already exists")
preset = DBEffectPreset(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
effects_chain=json.dumps(chain_dicts),
is_builtin=False,
)
db.add(preset)
try:
db.commit()
except IntegrityError:
db.rollback()
raise ValueError(f"A preset named '{data.name}' already exists")
db.refresh(preset)
return _preset_response(preset)
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
"""Update a user effect preset. Cannot modify built-in presets."""
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not preset:
return None
if preset.is_builtin:
raise ValueError("Cannot modify built-in presets")
if data.name is not None:
preset.name = data.name
if data.description is not None:
preset.description = data.description
if data.effects_chain is not None:
from .utils.effects import validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise ValueError(error)
preset.effects_chain = json.dumps(chain_dicts)
db.commit()
db.refresh(preset)
return _preset_response(preset)
def delete_preset(preset_id: str, db: Session) -> bool:
"""Delete a user effect preset. Cannot delete built-in presets."""
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
if not preset:
return False
if preset.is_builtin:
raise ValueError("Cannot delete built-in presets")
db.delete(preset)
db.commit()
return True
+37 -11
View File
@@ -13,7 +13,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from .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 .models import VoiceProfileCreate
from . import config
@@ -269,16 +269,33 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
if not profile:
raise ValueError(f"Profile {generation.profile_id} not found")
# Get audio file
audio_path = Path(generation.audio_path)
if not audio_path.exists():
raise ValueError(f"Audio file not found: {audio_path}")
# Get all versions for this generation
versions = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.all()
)
# Create ZIP in memory
zip_buffer = io.BytesIO()
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 = {
"version": "1.0",
"generation": {
@@ -295,13 +312,22 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"name": profile.name,
"description": profile.description,
"language": profile.language,
}
},
"versions": version_entries,
}
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
# Add audio file
filename = audio_path.name
zip_file.write(audio_path, f"audio/{filename}")
# Add all version audio files
for v in versions:
v_path = Path(v.audio_path)
if v_path.exists():
zip_file.write(v_path, f"audio/{v_path.name}")
# 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)
return zip_buffer.read()
+54 -8
View File
@@ -10,8 +10,8 @@ from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import or_
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
from .database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
from . import config
@@ -20,6 +20,43 @@ def _get_generations_dir() -> Path:
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(
profile_id: str,
text: str,
@@ -170,6 +207,7 @@ async def list_generations(
# Convert to HistoryResponse with profile_name
items = []
for generation, profile_name in results:
versions, active_version_id = _get_versions_for_generation(generation.id, db)
items.append(HistoryResponse(
id=generation.id,
profile_id=generation.profile_id,
@@ -184,7 +222,10 @@ async def list_generations(
model_size=generation.model_size,
status=generation.status or "completed",
error=generation.error,
is_favorited=bool(generation.is_favorited),
created_at=generation.created_at,
versions=versions,
active_version_id=active_version_id,
))
return HistoryListResponse(
@@ -210,12 +251,17 @@ async def delete_generation(
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
return False
# Delete audio file
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path.unlink()
# Delete all version files and records
from . import versions as versions_mod
versions_mod.delete_versions_for_generation(generation_id, db)
# Delete main audio file (if not already removed by version cleanup)
if generation.audio_path:
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path.unlink()
# Delete from database
db.delete(generation)
db.commit()
+541 -6
View File
@@ -108,6 +108,7 @@ _default_origins = [
"http://127.0.0.1:17493",
"tauri://localhost", # Tauri webview (macOS)
"https://tauri.localhost", # Tauri webview (Windows/Linux)
"http://tauri.localhost", # Tauri webview (Windows, some builds)
]
_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
_cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()]
@@ -142,6 +143,14 @@ async def shutdown():
return {"message": "Shutting down..."}
@app.post("/watchdog/disable")
async def watchdog_disable():
"""Disable the parent process watchdog so the server keeps running."""
from backend.server import disable_watchdog
disable_watchdog()
return {"message": "Watchdog disabled"}
@app.get("/health", response_model=models.HealthResponse)
async def health():
"""Health check endpoint."""
@@ -263,7 +272,7 @@ async def health():
gpu_type=gpu_type,
vram_used_mb=vram_used,
backend_type=backend_type,
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cpu"),
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
)
@@ -757,6 +766,20 @@ async def generate_speech(
text=data.text,
)
# Resolve effects chain: explicit request > profile default > none
effects_chain_config = None
if data.effects_chain is not None:
effects_chain_config = [e.model_dump() for e in data.effects_chain]
else:
# Check profile default
import json as _json
profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first()
if profile_obj and profile_obj.effects_chain:
try:
effects_chain_config = _json.loads(profile_obj.effects_chain)
except Exception:
pass
# Kick off TTS in background
async def _run_generation():
bg_db = next(get_db())
@@ -799,17 +822,55 @@ async def generate_speech(
audio = normalize_audio(audio)
duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
# Always save clean version first
clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
save_audio(audio, str(clean_audio_path), sample_rate)
from . import versions as versions_mod
has_effects = effects_chain_config and any(
e.get("enabled", True) for e in effects_chain_config
)
# Create clean version entry
versions_mod.create_version(
generation_id=generation_id,
label="original",
audio_path=str(clean_audio_path),
db=bg_db,
effects_chain=None,
is_default=not has_effects,
)
# Apply effects and create processed version if configured
final_audio_path = str(clean_audio_path)
if has_effects:
from .utils.effects import apply_effects, validate_effects_chain
error_msg = validate_effects_chain(effects_chain_config)
if error_msg:
print(f"Warning: invalid effects chain, skipping: {error_msg}")
else:
processed_audio = apply_effects(audio, sample_rate, effects_chain_config)
processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
save_audio(processed_audio, str(processed_path), sample_rate)
final_audio_path = str(processed_path)
versions_mod.create_version(
generation_id=generation_id,
label="version-2",
audio_path=str(processed_path),
db=bg_db,
effects_chain=effects_chain_config,
is_default=True,
)
# Update the record to completed
await history.update_generation_status(
generation_id=generation_id,
status="completed",
db=bg_db,
audio_path=str(audio_path),
audio_path=final_audio_path,
duration=duration,
)
@@ -926,6 +987,118 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
return models.GenerationResponse.model_validate(gen)
@app.post(
"/generate/{generation_id}/regenerate",
response_model=models.GenerationResponse,
)
async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)):
"""Re-run TTS with the same parameters and save the result as a new version."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation must be completed to regenerate")
from .backends import get_tts_backend_for_engine
from . import versions as versions_mod
regen_engine = gen.engine or "qwen"
regen_model_size = gen.model_size or "1.7B"
tts_model = get_tts_backend_for_engine(regen_engine)
# Set to generating so the UI shows the loader and SSE picks it up
gen.status = "generating"
gen.error = None
db.commit()
db.refresh(gen)
task_manager = get_task_manager()
task_manager.start_generation(
task_id=generation_id,
profile_id=gen.profile_id,
text=gen.text,
)
version_id = str(uuid.uuid4())
async def _run_regenerate():
bg_db = next(get_db())
try:
if regen_engine == "qwen":
await tts_model.load_model_async(regen_model_size)
else:
await tts_model.load_model()
voice_prompt = await profiles.create_voice_prompt_for_profile(
gen.profile_id,
bg_db,
use_cache=True,
engine=regen_engine,
)
from .utils.chunked_tts import generate_chunked
trim_fn = None
if regen_engine in ("chatterbox", "chatterbox_turbo"):
from .utils.audio import trim_tts_output
trim_fn = trim_tts_output
audio, sample_rate = await generate_chunked(
tts_model,
gen.text,
voice_prompt,
language=gen.language,
seed=None, # New seed for variation
instruct=gen.instruct,
trim_fn=trim_fn,
)
from .utils.audio import normalize_audio, save_audio
audio = normalize_audio(audio)
duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
save_audio(audio, str(audio_path), sample_rate)
# Count existing versions to auto-label
existing = versions_mod.list_versions(generation_id, bg_db)
label = f"take-{len(existing) + 1}"
versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(audio_path),
db=bg_db,
effects_chain=None,
is_default=True,
)
await history.update_generation_status(
generation_id=generation_id,
status="completed",
db=bg_db,
audio_path=str(audio_path),
duration=duration,
)
except Exception as e:
import traceback
traceback.print_exc()
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=bg_db,
error=str(e),
)
finally:
task_manager.complete_generation(generation_id)
bg_db.close()
_enqueue_generation(_run_regenerate())
return models.GenerationResponse.model_validate(gen)
@app.get("/generate/{generation_id}/status")
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
"""SSE endpoint that streams generation status updates.
@@ -1150,6 +1323,20 @@ async def get_generation(
)
@app.post("/history/{generation_id}/favorite")
async def toggle_favorite(
generation_id: str,
db: Session = Depends(get_db),
):
"""Toggle the favorite status of a generation."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
gen.is_favorited = not gen.is_favorited
db.commit()
return {"is_favorited": gen.is_favorited}
@app.delete("/history/{generation_id}")
async def delete_generation(
generation_id: str,
@@ -1245,7 +1432,7 @@ async def transcribe_audio(
try:
# Get audio duration
from .utils.audio import load_audio
audio, sr = load_audio(tmp_path)
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
duration = len(audio) / sr
# Transcribe
@@ -1466,6 +1653,20 @@ async def duplicate_story_item(
return item
@app.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail)
async def set_story_item_version(
story_id: str,
item_id: str,
data: models.StoryItemVersionUpdate,
db: Session = Depends(get_db),
):
"""Pin a story item to a specific generation version."""
item = await stories.set_story_item_version(story_id, item_id, data, db)
if item is None:
raise HTTPException(status_code=404, detail="Story item or version not found")
return item
@app.get("/stories/{story_id}/export-audio")
async def export_story_audio(
story_id: str,
@@ -1503,13 +1704,347 @@ async def export_story_audio(
raise HTTPException(status_code=500, detail=str(e))
# ============================================
# EFFECTS & VERSIONS
# ============================================
@app.post("/effects/preview/{generation_id}")
async def preview_effects(
generation_id: str,
data: models.ApplyEffectsRequest,
db: Session = Depends(get_db),
):
"""Apply effects to a generation's clean audio and stream back the result without saving.
Used for ephemeral preview/auditioning of effects chains.
"""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation is not completed")
from . import versions as versions_mod
from .utils.effects import apply_effects, validate_effects_chain
from .utils.audio import load_audio
# Validate chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise HTTPException(status_code=400, detail=error)
# Find the original unprocessed version (no effects applied)
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next((v for v in all_versions if v.effects_chain is None), None)
source_path = clean_version.audio_path if clean_version else gen.audio_path
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Process in memory (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Write to in-memory buffer
import soundfile as sf
buf = io.BytesIO()
await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
buf.seek(0)
return StreamingResponse(
buf,
media_type="audio/wav",
headers={
"Content-Disposition": f'inline; filename="preview_{generation_id}.wav"',
"Cache-Control": "no-cache, no-store",
},
)
@app.get("/effects/available", response_model=models.AvailableEffectsResponse)
async def get_available_effects():
"""List all available effect types with parameter definitions."""
from .utils.effects import get_available_effects as _get_effects
return models.AvailableEffectsResponse(effects=[
models.AvailableEffect(**e) for e in _get_effects()
])
@app.get("/effects/presets", response_model=List[models.EffectPresetResponse])
async def list_effect_presets(db: Session = Depends(get_db)):
"""List all effect presets (built-in + user-created)."""
from . import effects as effects_mod
return effects_mod.list_presets(db)
@app.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)):
"""Get a specific effect preset."""
from . import effects as effects_mod
preset = effects_mod.get_preset(preset_id, db)
if not preset:
raise HTTPException(status_code=404, detail="Preset not found")
return preset
@app.post("/effects/presets", response_model=models.EffectPresetResponse)
async def create_effect_preset(
data: models.EffectPresetCreate,
db: Session = Depends(get_db),
):
"""Create a new effect preset."""
from . import effects as effects_mod
try:
return effects_mod.create_preset(data, db)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
async def update_effect_preset(
preset_id: str,
data: models.EffectPresetUpdate,
db: Session = Depends(get_db),
):
"""Update an effect preset."""
from . import effects as effects_mod
try:
result = effects_mod.update_preset(preset_id, data, db)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/effects/presets/{preset_id}")
async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)):
"""Delete a user effect preset."""
from . import effects as effects_mod
try:
if not effects_mod.delete_preset(preset_id, db):
raise HTTPException(status_code=404, detail="Preset not found")
return {"status": "deleted"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get(
"/generations/{generation_id}/versions",
response_model=List[models.GenerationVersionResponse],
)
async def list_generation_versions(
generation_id: str,
db: Session = Depends(get_db),
):
"""List all versions for a generation."""
gen = await history.get_generation(generation_id, db)
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
from . import versions as versions_mod
return versions_mod.list_versions(generation_id, db)
@app.post(
"/generations/{generation_id}/versions/apply-effects",
response_model=models.GenerationVersionResponse,
)
async def apply_effects_to_generation(
generation_id: str,
data: models.ApplyEffectsRequest,
db: Session = Depends(get_db),
):
"""Apply an effects chain to an existing generation, creating a new version."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") != "completed":
raise HTTPException(status_code=400, detail="Generation is not completed")
from . import versions as versions_mod
from .utils.effects import apply_effects, validate_effects_chain
from .utils.audio import load_audio, save_audio
# Validate effects chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise HTTPException(status_code=400, detail=error)
# Determine source audio: use specified version, or fall back to clean/original
all_versions = versions_mod.list_versions(generation_id, db)
source_version_id = data.source_version_id
if source_version_id:
source_version = next(
(v for v in all_versions if v.id == source_version_id), None
)
if not source_version:
raise HTTPException(status_code=404, detail="Source version not found")
source_path = source_version.audio_path
else:
clean_version = next(
(v for v in all_versions if v.effects_chain is None), None
)
if not clean_version:
source_path = gen.audio_path
else:
source_path = clean_version.audio_path
source_version_id = clean_version.id
if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
# Load, process, save (off the event loop)
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
# Generate a unique filename
version_id = str(uuid.uuid4())
processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate)
# Auto-label
label = data.label or f"version-{len(all_versions) + 1}"
version = versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(processed_path),
db=db,
effects_chain=chain_dicts,
is_default=data.set_as_default,
source_version_id=source_version_id,
)
return version
@app.put(
"/generations/{generation_id}/versions/{version_id}/set-default",
response_model=models.GenerationVersionResponse,
)
async def set_default_version(
generation_id: str,
version_id: str,
db: Session = Depends(get_db),
):
"""Set a specific version as the default for a generation."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version or version.generation_id != generation_id:
raise HTTPException(status_code=404, detail="Version not found")
result = versions_mod.set_default_version(version_id, db)
if not result:
raise HTTPException(status_code=404, detail="Version not found")
return result
@app.delete("/generations/{generation_id}/versions/{version_id}")
async def delete_generation_version(
generation_id: str,
version_id: str,
db: Session = Depends(get_db),
):
"""Delete a version. Cannot delete the last remaining version."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version or version.generation_id != generation_id:
raise HTTPException(status_code=404, detail="Version not found")
if not versions_mod.delete_version(version_id, db):
raise HTTPException(
status_code=400,
detail="Cannot delete the last remaining version",
)
return {"status": "deleted"}
@app.get("/audio/version/{version_id}")
async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
"""Serve audio for a specific version."""
from . import versions as versions_mod
version = versions_mod.get_version(version_id, db)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
audio_path = Path(version.audio_path)
if not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
audio_path,
media_type="audio/wav",
filename=f"generation_{version.generation_id}_{version.label}.wav",
)
@app.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse)
async def update_profile_effects(
profile_id: str,
data: models.ProfileEffectsUpdate,
db: Session = Depends(get_db),
):
"""Set or clear the default effects chain for a voice profile."""
import json as _json
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if data.effects_chain is not None:
from .utils.effects import validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
if error:
raise HTTPException(status_code=400, detail=error)
profile.effects_chain = _json.dumps(chain_dicts)
else:
profile.effects_chain = None
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return _profile_to_response(profile)
def _profile_to_response(profile) -> models.VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse with parsed effects_chain."""
import json as _json
import logging
effects_chain = None
if profile.effects_chain:
try:
raw = _json.loads(profile.effects_chain)
effects_chain = [models.EffectConfig(**e) for e in raw]
except Exception as e:
logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
return models.VoiceProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description,
language=profile.language,
avatar_path=profile.avatar_path,
effects_chain=effects_chain,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
# ============================================
# FILE SERVING
# ============================================
@app.get("/audio/{generation_id}")
async def get_audio(generation_id: str, db: Session = Depends(get_db)):
"""Serve generated audio file."""
"""Serve generated audio file (serves the default version)."""
generation = await history.get_generation(generation_id, db)
if not generation:
raise HTTPException(status_code=404, detail="Generation not found")
+112
View File
@@ -21,6 +21,9 @@ class VoiceProfileResponse(BaseModel):
description: Optional[str]
language: str
avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime
updated_at: datetime
@@ -61,6 +64,7 @@ class GenerationRequest(BaseModel):
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
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):
@@ -77,7 +81,10 @@ class GenerationResponse(BaseModel):
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
is_favorited: bool = False
created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -106,7 +113,10 @@ class HistoryResponse(BaseModel):
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
is_favorited: bool = False
created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -268,6 +278,7 @@ class StoryItemDetail(BaseModel):
id: str
story_id: str
generation_id: str
version_id: Optional[str] = None
start_time_ms: int
track: int = 0
trim_start_ms: int = 0
@@ -283,6 +294,9 @@ class StoryItemDetail(BaseModel):
seed: Optional[int]
instruct: Optional[str]
generation_created_at: datetime
# Versions available for this generation
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -339,3 +353,101 @@ class StoryItemTrim(BaseModel):
class StoryItemSplit(BaseModel):
"""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)
class StoryItemVersionUpdate(BaseModel):
"""Request model for setting a story item's pinned version."""
version_id: Optional[str] = None # null = use generation default
# ============================================
# Effects & Versions
# ============================================
class EffectConfig(BaseModel):
"""A single effect in an effects chain."""
type: str
enabled: bool = True
params: dict = Field(default_factory=dict)
class EffectsChain(BaseModel):
"""An ordered list of effects to apply."""
effects: List[EffectConfig] = Field(default_factory=list)
class EffectPresetCreate(BaseModel):
"""Request model for creating an effect preset."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
effects_chain: List[EffectConfig]
class EffectPresetUpdate(BaseModel):
"""Request model for updating an effect preset."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
effects_chain: Optional[List[EffectConfig]] = None
class EffectPresetResponse(BaseModel):
"""Response model for effect preset."""
id: str
name: str
description: Optional[str] = None
effects_chain: List[EffectConfig]
is_builtin: bool = False
created_at: datetime
class Config:
from_attributes = True
class GenerationVersionResponse(BaseModel):
"""Response model for a generation version."""
id: str
generation_id: str
label: str
audio_path: str
effects_chain: Optional[List[EffectConfig]] = None
source_version_id: Optional[str] = None
is_default: bool
created_at: datetime
class Config:
from_attributes = True
class ApplyEffectsRequest(BaseModel):
"""Request to apply effects to an existing generation."""
effects_chain: List[EffectConfig]
source_version_id: Optional[str] = Field(None, description="Version to use as source audio (defaults to clean/original)")
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
set_as_default: bool = Field(default=True, description="Set this version as the default")
class ProfileEffectsUpdate(BaseModel):
"""Request to update the default effects chain on a profile."""
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
class AvailableEffectParam(BaseModel):
"""Description of a single effect parameter."""
default: float
min: float
max: float
step: float
description: str
class AvailableEffect(BaseModel):
"""Description of an available effect type."""
type: str
label: str
description: str
params: dict # param_name -> AvailableEffectParam
class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
+65 -8
View File
@@ -8,7 +8,7 @@ import uuid
import shutil
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy import func, select
from .models import (
VoiceProfileCreate,
@@ -19,12 +19,43 @@ from .models import (
from .database import (
VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample,
Generation as DBGeneration,
)
from .models import EffectConfig
from .utils.audio import validate_reference_audio, load_audio, save_audio
from .utils.images import validate_image, process_avatar
from .utils.cache import _get_cache_dir, clear_profile_cache
from .tts import get_tts_model
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:
@@ -72,7 +103,7 @@ async def create_profile(
profile_dir = _get_profiles_dir() / db_profile.id
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(
@@ -154,7 +185,7 @@ async def get_profile(
if not profile:
return None
return VoiceProfileResponse.model_validate(profile)
return _profile_to_response(profile)
async def get_profile_samples(
@@ -177,7 +208,7 @@ async def get_profile_samples(
async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
"""
List all voice profiles.
List all voice profiles with generation and sample counts.
Args:
db: Database session
@@ -188,8 +219,34 @@ async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
profiles = db.query(DBVoiceProfile).order_by(
DBVoiceProfile.created_at.desc()
).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(
@@ -230,7 +287,7 @@ async def update_profile(
db.commit()
db.refresh(profile)
return VoiceProfileResponse.model_validate(profile)
return _profile_to_response(profile)
async def delete_profile(
@@ -472,7 +529,7 @@ async def upload_avatar(
db.commit()
db.refresh(profile)
return VoiceProfileResponse.model_validate(profile)
return _profile_to_response(profile)
async def delete_avatar(
+1
View File
@@ -38,6 +38,7 @@ librosa>=0.10.0
soundfile>=0.12.0
numpy>=1.24.0
numba>=0.60.0,<0.61.0
pedalboard>=0.9.0
# HTTP client (for CUDA backend download)
httpx>=0.27.0
+134 -5
View File
@@ -6,6 +6,14 @@ absolute imports instead of relative imports.
"""
import sys
# Fast path: handle --version before any heavy imports so the Rust
# version check doesn't block for 30+ seconds loading torch etc.
if "--version" in sys.argv:
from backend import __version__
print(f"voicebox-server {__version__}")
sys.exit(0)
import logging
# Set up logging FIRST, before any imports that might fail
@@ -43,6 +51,115 @@ except Exception as e:
logger.error(f"Failed to import required modules: {e}", exc_info=True)
sys.exit(1)
_watchdog_disabled = False
def disable_watchdog():
"""Disable the parent watchdog so the server keeps running after parent exits."""
global _watchdog_disabled
_watchdog_disabled = True
# Ignore SIGHUP so the server survives when the parent Tauri process exits.
# On Unix, child processes receive SIGHUP when the parent's session leader
# exits, which would kill the server even though we want it to persist.
if sys.platform != "win32":
import signal
signal.signal(signal.SIGHUP, signal.SIG_IGN)
def _start_parent_watchdog(parent_pid, data_dir=None):
"""Monitor parent process and exit if it dies.
This is the clean shutdown mechanism: instead of the Tauri app trying to
forcefully kill the server (which spawns console windows on Windows),
the server monitors its parent and shuts itself down gracefully.
"""
import os
import signal
import threading
import time
# Set up a file logger so we can debug in production
watchdog_logger = logging.getLogger("watchdog")
if data_dir:
try:
log_dir = os.path.join(data_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
fh = logging.FileHandler(os.path.join(log_dir, "watchdog.log"))
fh.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
watchdog_logger.addHandler(fh)
except Exception:
pass
watchdog_logger.setLevel(logging.INFO)
def _is_pid_alive(pid):
"""Check if a process with the given PID exists (cross-platform)."""
try:
if sys.platform == "win32":
import ctypes
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if handle:
# Check if process has actually exited
STILL_ACTIVE = 259
exit_code = ctypes.c_ulong()
result = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
kernel32.CloseHandle(handle)
if result and exit_code.value == STILL_ACTIVE:
return True
watchdog_logger.info(f"PID {pid}: exited with code {exit_code.value}")
return False
# OpenProcess failed — check if it's an access error (process exists
# but we can't open it) vs process not found
error = ctypes.GetLastError()
ACCESS_DENIED = 5
if error == ACCESS_DENIED:
return True # process exists, we just can't open it
watchdog_logger.info(f"PID {pid}: OpenProcess failed, error={error}")
return False
else:
os.kill(pid, 0)
return True
except (OSError, PermissionError):
return False
def _watch():
watchdog_logger.info(f"Parent watchdog started, monitoring PID {parent_pid}, server PID {os.getpid()}")
# Verify parent is alive before starting the loop
alive = _is_pid_alive(parent_pid)
watchdog_logger.info(f"Parent PID {parent_pid} initial check: alive={alive}")
if not alive:
watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
return
while True:
if _watchdog_disabled:
watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
return
if not _is_pid_alive(parent_pid):
# Parent is gone. Before shutting down, give the app a moment
# to send /watchdog/disable — there is a race where the Tauri
# RunEvent::Exit handler sends the disable request while we are
# mid-iteration (already past the _watchdog_disabled check above).
watchdog_logger.info(f"Parent process {parent_pid} gone, waiting for possible disable request...")
time.sleep(1)
if _watchdog_disabled:
watchdog_logger.info("Watchdog was disabled during grace period, keeping server alive")
return
watchdog_logger.info("Watchdog still enabled after grace period, shutting down server...")
if sys.platform == "win32":
# sys.exit triggers SystemExit, allowing uvicorn to run
# shutdown handlers. os.kill(SIGTERM) on Windows calls
# TerminateProcess which hard-kills without cleanup.
os._exit(0)
else:
os.kill(os.getpid(), signal.SIGTERM)
return
time.sleep(2)
t = threading.Thread(target=_watch, daemon=True)
t.start()
if __name__ == "__main__":
try:
parser = argparse.ArgumentParser(description="voicebox backend server")
@@ -64,17 +181,21 @@ if __name__ == "__main__":
default=None,
help="Data directory for database, profiles, and generated audio",
)
parser.add_argument(
"--parent-pid",
type=int,
default=None,
help="PID of parent process to monitor; server exits when parent dies",
)
parser.add_argument(
"--version",
action="store_true",
help="Print version and exit",
help="Print version and exit (handled above, kept for argparse help)",
)
args = parser.parse_args()
if args.version:
from backend import __version__
print(f"voicebox-server {__version__}")
sys.exit(0)
if args.parent_pid is not None and args.parent_pid <= 0:
parser.error("--parent-pid must be a positive integer")
# Detect backend variant from binary name
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
@@ -87,6 +208,14 @@ if __name__ == "__main__":
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
logger.info("Backend variant: CPU")
# Register parent watchdog to start after server is fully ready
if args.parent_pid is not None:
_parent_pid = args.parent_pid
_data_dir = args.data_dir
@app.on_event("startup")
async def _on_startup():
_start_parent_watchdog(_parent_pid, _data_dir)
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
# Set data directory if provided
+119 -177
View File
@@ -20,12 +20,55 @@ from .models import (
StoryItemMove,
StoryItemTrim,
StoryItemSplit,
StoryItemVersionUpdate,
)
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
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(
data: StoryCreate,
db: Session,
@@ -125,26 +168,7 @@ async def get_story(
# Build item details
item_details = []
for item, generation, profile_name in items:
item_detail = StoryItemDetail(
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)
item_details.append(_build_item_detail(item, generation, profile_name, db))
response = StoryDetailResponse.model_validate(story)
response.items = item_details
@@ -250,25 +274,7 @@ async def add_item_to_story(
if existing:
# Return existing item
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
id=existing.id,
story_id=existing.story_id,
generation_id=existing.generation_id,
start_time_ms=existing.start_time_ms,
track=existing.track,
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
created_at=existing.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db)
# Get track from data or default to 0
track = data.track if data.track is not None else 0
@@ -321,25 +327,7 @@ async def add_item_to_story(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
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,
)
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def move_story_item(
@@ -388,25 +376,7 @@ async def move_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
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,
)
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def remove_item_from_story(
@@ -495,25 +465,7 @@ async def trim_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
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,
)
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def split_story_item(
@@ -568,6 +520,7 @@ async def split_story_item(
id=str(uuid.uuid4()),
story_id=story_id,
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,
track=item.track,
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_name = profile.name if profile else "Unknown"
# Build response items
original_item_detail = StoryItemDetail(
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,
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]
return [
_build_item_detail(item, generation, profile_name, db),
_build_item_detail(new_item, generation, profile_name, db),
]
async def duplicate_story_item(
@@ -674,6 +589,7 @@ async def duplicate_story_item(
id=str(uuid.uuid4()),
story_id=story_id,
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
track=original_item.track,
trim_start_ms=current_trim_start,
@@ -694,25 +610,7 @@ async def duplicate_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return 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 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,
)
return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db)
async def update_story_item_times(
@@ -813,25 +711,7 @@ async def reorder_story_items(
current_time_ms += duration_ms + gap_ms
# Build the response item
updated_items.append(StoryItemDetail(
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,
))
updated_items.append(_build_item_detail(item, generation, profile_name, db))
# Update story updated_at
story.updated_at = datetime.utcnow()
@@ -840,6 +720,60 @@ async def reorder_story_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(
story_id: str,
db: Session,
@@ -877,7 +811,15 @@ async def export_story_audio(
sample_rate = 24000 # Default sample rate
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():
continue
+356
View File
@@ -0,0 +1,356 @@
"""
Audio post-processing effects engine.
Uses Spotify's pedalboard library to apply professional-grade DSP effects
to generated audio. Effects are described as a JSON-serializable chain
(list of effect dicts) so they can be stored in the database and sent
over the API.
Supported effect types:
- chorus (flanger-style with short delays)
- reverb (room reverb)
- delay (echo / delay line)
- compressor (dynamic range compression)
- gain (volume adjustment in dB)
- highpass (high-pass filter)
- lowpass (low-pass filter)
- pitch_shift (semitone pitch shifting)
"""
from __future__ import annotations
import numpy as np
from typing import Any, Dict, List, Optional
from pedalboard import (
Pedalboard,
Chorus,
Reverb,
Compressor,
Gain,
HighpassFilter,
LowpassFilter,
Delay,
PitchShift,
)
# ---------------------------------------------------------------------------
# Effect registry: maps type names -> (pedalboard class, param definitions)
# ---------------------------------------------------------------------------
# Each param definition: (default, min, max, description)
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
"chorus": {
"cls": Chorus,
"label": "Chorus / Flanger",
"description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
"params": {
"rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
"depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
"feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"centre_delay_ms": {"default": 7.0, "min": 0.5, "max": 50.0, "step": 0.1, "description": "Centre delay (ms)"},
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
},
},
"reverb": {
"cls": Reverb,
"label": "Reverb",
"description": "Room reverb effect.",
"params": {
"room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
"damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
"wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
"dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
"width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
},
},
"delay": {
"cls": Delay,
"label": "Delay",
"description": "Echo / delay line.",
"params": {
"delay_seconds": {"default": 0.3, "min": 0.01, "max": 2.0, "step": 0.01, "description": "Delay time (seconds)"},
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
},
},
"compressor": {
"cls": Compressor,
"label": "Compressor",
"description": "Dynamic range compression for consistent loudness.",
"params": {
"threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
"ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
"attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
"release_ms": {"default": 100.0, "min": 10.0, "max": 1000.0,"step": 1.0, "description": "Release time (ms)"},
},
},
"gain": {
"cls": Gain,
"label": "Gain",
"description": "Volume adjustment in decibels.",
"params": {
"gain_db": {"default": 0.0, "min": -40.0, "max": 40.0, "step": 0.5, "description": "Gain (dB)"},
},
},
"highpass": {
"cls": HighpassFilter,
"label": "High-Pass Filter",
"description": "Removes frequencies below the cutoff.",
"params": {
"cutoff_frequency_hz": {"default": 80.0, "min": 20.0, "max": 8000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
},
},
"lowpass": {
"cls": LowpassFilter,
"label": "Low-Pass Filter",
"description": "Removes frequencies above the cutoff.",
"params": {
"cutoff_frequency_hz": {"default": 8000.0, "min": 200.0, "max": 20000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
},
},
"pitch_shift": {
"cls": PitchShift,
"label": "Pitch Shift",
"description": "Shift pitch up or down by semitones.",
"params": {
"semitones": {"default": 0.0, "min": -12.0, "max": 12.0, "step": 0.5, "description": "Semitones to shift"},
},
},
}
# ---------------------------------------------------------------------------
# Built-in presets
# ---------------------------------------------------------------------------
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
"robotic": {
"name": "Robotic",
"sort_order": 0,
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)",
"effects_chain": [
{
"type": "chorus",
"enabled": True,
"params": {
"rate_hz": 0.2,
"depth": 1.0,
"feedback": 0.35,
"centre_delay_ms": 7.0,
"mix": 0.5,
},
},
],
},
"radio": {
"name": "Radio",
"sort_order": 1,
"description": "Thin AM-radio voice with band-pass filtering and light compression",
"effects_chain": [
{
"type": "highpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 300.0},
},
{
"type": "lowpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 3500.0},
},
{
"type": "compressor",
"enabled": True,
"params": {
"threshold_db": -15.0,
"ratio": 6.0,
"attack_ms": 5.0,
"release_ms": 50.0,
},
},
{
"type": "gain",
"enabled": True,
"params": {"gain_db": 6.0},
},
],
},
"echo_chamber": {
"name": "Echo Chamber",
"sort_order": 2,
"description": "Spacious reverb with trailing echo",
"effects_chain": [
{
"type": "reverb",
"enabled": True,
"params": {
"room_size": 0.85,
"damping": 0.3,
"wet_level": 0.45,
"dry_level": 0.55,
"width": 1.0,
},
},
{
"type": "delay",
"enabled": True,
"params": {
"delay_seconds": 0.25,
"feedback": 0.3,
"mix": 0.2,
},
},
],
},
"deep_voice": {
"name": "Deep Voice",
"sort_order": 99,
"description": "Lower pitch with added warmth",
"effects_chain": [
{
"type": "pitch_shift",
"enabled": True,
"params": {"semitones": -3.0},
},
{
"type": "lowpass",
"enabled": True,
"params": {"cutoff_frequency_hz": 6000.0},
},
{
"type": "compressor",
"enabled": True,
"params": {
"threshold_db": -18.0,
"ratio": 3.0,
"attack_ms": 10.0,
"release_ms": 150.0,
},
},
],
},
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_available_effects() -> List[Dict[str, Any]]:
"""Return the list of available effect types with their parameter definitions.
Used by the frontend to build the effects chain editor UI.
"""
result = []
for effect_type, info in EFFECT_REGISTRY.items():
result.append({
"type": effect_type,
"label": info["label"],
"description": info["description"],
"params": {
name: {k: v for k, v in pdef.items()}
for name, pdef in info["params"].items()
},
})
return result
def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
"""Return all built-in effect presets."""
return BUILTIN_PRESETS
def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
"""Validate an effects chain configuration.
Returns None if valid, or an error message string.
"""
if not isinstance(effects_chain, list):
return "effects_chain must be a list"
for i, effect in enumerate(effects_chain):
if not isinstance(effect, dict):
return f"Effect at index {i} must be a dict"
effect_type = effect.get("type")
if effect_type not in EFFECT_REGISTRY:
return f"Unknown effect type '{effect_type}' at index {i}. Available: {list(EFFECT_REGISTRY.keys())}"
params = effect.get("params", {})
if not isinstance(params, dict):
return f"Effect '{effect_type}' at index {i}: params must be a dict"
registry = EFFECT_REGISTRY[effect_type]
for param_name, value in params.items():
if param_name not in registry["params"]:
return f"Effect '{effect_type}' at index {i}: unknown param '{param_name}'"
pdef = registry["params"][param_name]
if not isinstance(value, (int, float)):
return f"Effect '{effect_type}' at index {i}: param '{param_name}' must be a number"
if value < pdef["min"] or value > pdef["max"]:
return (
f"Effect '{effect_type}' at index {i}: param '{param_name}' "
f"must be between {pdef['min']} and {pdef['max']} (got {value})"
)
return None
def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
"""Build a Pedalboard instance from an effects chain config.
Skips effects where ``enabled`` is ``False``.
"""
plugins = []
for effect in effects_chain:
if not effect.get("enabled", True):
continue
effect_type = effect["type"]
registry = EFFECT_REGISTRY[effect_type]
cls = registry["cls"]
# Merge defaults with provided params
params = {}
for pname, pdef in registry["params"].items():
params[pname] = effect.get("params", {}).get(pname, pdef["default"])
plugins.append(cls(**params))
return Pedalboard(plugins)
def apply_effects(
audio: np.ndarray,
sample_rate: int,
effects_chain: List[Dict[str, Any]],
) -> np.ndarray:
"""Apply an effects chain to audio data.
Args:
audio: Input audio array (1-D mono float32).
sample_rate: Sample rate in Hz.
effects_chain: List of effect configuration dicts.
Returns:
Processed audio array.
"""
if not effects_chain:
return audio
board = build_pedalboard(effects_chain)
# pedalboard expects shape (channels, samples)
if audio.ndim == 1:
audio_2d = audio[np.newaxis, :]
else:
audio_2d = audio
processed = board(audio_2d.astype(np.float32), sample_rate)
# Return same dimensionality as input
if audio.ndim == 1:
return processed[0]
return processed
+211
View File
@@ -0,0 +1,211 @@
"""
Generation versions management module.
Each generation can have multiple audio versions: a clean (unprocessed)
version and any number of processed versions with different effects chains.
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import List, Optional
from sqlalchemy.orm import Session
from .database import (
GenerationVersion as DBGenerationVersion,
Generation as DBGeneration,
)
from .models import GenerationVersionResponse, EffectConfig
from . import config
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
"""Convert a DB version row to a Pydantic response."""
effects_chain = None
if v.effects_chain:
raw = json.loads(v.effects_chain)
effects_chain = [EffectConfig(**e) for e in raw]
return GenerationVersionResponse(
id=v.id,
generation_id=v.generation_id,
label=v.label,
audio_path=v.audio_path,
effects_chain=effects_chain,
source_version_id=v.source_version_id,
is_default=v.is_default,
created_at=v.created_at,
)
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
"""List all versions for a generation."""
versions = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.all()
)
return [_version_response(v) for v in versions]
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Get a specific version by ID."""
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not v:
return None
return _version_response(v)
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Get the default version for a generation."""
v = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id, is_default=True)
.first()
)
if not v:
# Fallback: return the first version
v = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.order_by(DBGenerationVersion.created_at)
.first()
)
if not v:
return None
return _version_response(v)
def create_version(
generation_id: str,
label: str,
audio_path: str,
db: Session,
effects_chain: Optional[List[dict]] = None,
is_default: bool = False,
source_version_id: Optional[str] = None,
) -> GenerationVersionResponse:
"""Create a new version for a generation.
If ``is_default`` is True, all other versions for this generation
are un-defaulted first.
"""
if is_default:
_clear_defaults(generation_id, db)
version = DBGenerationVersion(
id=str(uuid.uuid4()),
generation_id=generation_id,
label=label,
audio_path=audio_path,
effects_chain=json.dumps(effects_chain) if effects_chain else None,
source_version_id=source_version_id,
is_default=is_default,
)
db.add(version)
db.commit()
db.refresh(version)
# If this version is the default, update the generation's audio_path
if is_default:
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if gen:
gen.audio_path = audio_path
db.commit()
return _version_response(version)
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
"""Set a version as the default for its generation."""
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not version:
return None
_clear_defaults(version.generation_id, db)
version.is_default = True
db.commit()
db.refresh(version)
# Update generation's audio_path to point to this version
gen = db.query(DBGeneration).filter_by(id=version.generation_id).first()
if gen:
gen.audio_path = version.audio_path
db.commit()
return _version_response(version)
def delete_version(version_id: str, db: Session) -> bool:
"""Delete a version. Cannot delete the last remaining version."""
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
if not version:
return False
# Don't allow deleting the last version
count = (
db.query(DBGenerationVersion)
.filter_by(generation_id=version.generation_id)
.count()
)
if count <= 1:
return False
was_default = version.is_default
gen_id = version.generation_id
# Delete audio file
audio_path = Path(version.audio_path)
if audio_path.exists():
audio_path.unlink()
db.delete(version)
db.commit()
# If this was the default, promote the first remaining version
if was_default:
first = (
db.query(DBGenerationVersion)
.filter_by(generation_id=gen_id)
.order_by(DBGenerationVersion.created_at)
.first()
)
if first:
first.is_default = True
db.commit()
gen = db.query(DBGeneration).filter_by(id=gen_id).first()
if gen:
gen.audio_path = first.audio_path
db.commit()
return True
def delete_versions_for_generation(generation_id: str, db: Session) -> int:
"""Delete all versions for a generation (used when deleting a generation)."""
versions = (
db.query(DBGenerationVersion)
.filter_by(generation_id=generation_id)
.all()
)
count = 0
for v in versions:
audio_path = Path(v.audio_path)
if audio_path.exists():
audio_path.unlink()
db.delete(v)
count += 1
if count > 0:
db.commit()
return count
def _clear_defaults(generation_id: str, db: Session) -> None:
"""Clear the is_default flag on all versions for a generation."""
db.query(DBGenerationVersion).filter_by(
generation_id=generation_id, is_default=True
).update({"is_default": False})
db.flush()
+9 -10
View File
@@ -1,35 +1,34 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import collect_all
from PyInstaller.utils.hooks import copy_metadata
datas = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
binaries = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'backend.cuda_download', 'backend.effects', 'backend.utils.effects', 'backend.versions', 'pedalboard', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += collect_data_files('qwen_tts')
# Use collect_all (not collect_data_files) so native .dylib and .metallib
# files are bundled as binaries, not data. Without this, MLX raises OSError
# when loading Metal shaders inside the PyInstaller bundle.
from PyInstaller.utils.hooks import collect_all as _collect_all
_mlx_datas, _mlx_bins, _mlx_hidden = _collect_all('mlx')
_mlxa_datas, _mlxa_bins, _mlxa_hidden = _collect_all('mlx_audio')
datas += _mlx_datas + _mlxa_datas
datas += copy_metadata('qwen-tts')
hiddenimports += collect_submodules('qwen_tts')
hiddenimports += collect_submodules('jaraco')
hiddenimports += collect_submodules('mlx')
hiddenimports += collect_submodules('mlx_audio')
tmp_ret = collect_all('mlx')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx_audio')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['server.py'],
pathex=[],
binaries=_mlx_bins + _mlxa_bins,
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=['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'],
noarchive=False,
optimize=0,
)
+17 -4
View File
@@ -17,7 +17,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -72,13 +72,15 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -87,6 +89,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"wavesurfer.js": "^7.12.2",
},
"devDependencies": {
"@types/node": "^20.11.5",
@@ -97,7 +100,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -120,7 +123,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -274,6 +277,8 @@
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource/space-grotesk": ["@fontsource/[email protected]", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
@@ -1152,12 +1157,16 @@
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@voicebox/landing/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@voicebox/landing/wavesurfer.js": ["[email protected]", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1169,5 +1178,9 @@
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@voicebox/landing/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
"@voicebox/landing/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
}
}
+163
View File
@@ -0,0 +1,163 @@
# Voicebox v0.2.0 -- Release Notes
## The story
Voicebox v0.1.x shipped as a single-engine voice cloning app built around Qwen3-TTS. It worked, but it was limited: one model family, 10 languages, English-centric emotion, a synchronous generation pipeline that locked the UI, and a hard ceiling on how much text you could generate at once.
v0.2.0 is a ground-up rethink. Voicebox is now a **multi-engine voice cloning platform**. Four TTS engines. 23 languages. Expressive paralinguistic controls. A full post-processing effects pipeline. Unlimited generation length. Asynchronous everything. And it runs on every major GPU vendor -- NVIDIA, AMD, Intel Arc, Apple Silicon -- plus Docker for headless deployment.
This is the release where Voicebox stops being a proof of concept and starts being a real tool.
---
## Major New Features
### Multi-Engine Architecture
Voicebox now supports **four TTS engines**, each with different strengths. Switch between them per-generation from a single unified interface:
| Engine | Languages | Strengths |
|--------|-----------|-----------|
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
| **Chatterbox Multilingual** | 23 | Broadest language coverage -- Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
### Emotions and Paralinguistic Tags (Chatterbox Turbo)
Type `/` in the text input to open an autocomplete for **9 expressive tags** that the model synthesizes inline with speech:
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
Tags render as inline badges in a rich text editor and serialize cleanly to the API. This makes generated speech sound natural and expressive in a way that plain TTS can't.
### 23 Languages via Chatterbox Multilingual
The Chatterbox Multilingual engine brings zero-shot voice cloning to **23 languages**: Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish. The language dropdown dynamically filters to show only languages supported by the selected engine.
### Unlimited Generation Length (Auto-Chunking)
Previously, long text would hit model context limits and degrade. Now, text is **automatically split at sentence boundaries** and each chunk is generated independently, then crossfaded back together. This is fully engine-agnostic and works with all four engines.
- **Auto-chunking limit slider** (100-5,000 chars, default 800) -- controls when text gets split
- **Crossfade slider** (0-200ms, default 50ms) -- blends chunk boundaries smoothly, or set to 0 for a hard cut
- **Max text length raised to 50,000 characters** -- generate entire scripts, chapters, or articles in one go
- Smart splitting respects abbreviations (Dr., e.g., a.m.), CJK punctuation, and never breaks inside paralinguistic `[tags]`
### Asynchronous Generation Queue
Generation is now fully **non-blocking**. Submit a generation and immediately start typing the next one -- no more frozen UI waiting for inference to complete.
- Serial execution queue prevents GPU contention across all backends
- Real-time SSE status streaming (`generating` -> `completed` / `failed`)
- Failed generations can be retried without re-entering text
- Stale generations from crashes are auto-recovered on startup
- Generating status pill shown inline in the story editor
### Post-Processing Effects Pipeline
A full audio effects system powered by Spotify's `pedalboard` library. Apply effects after generation, preview them in real time, and build reusable presets -- all without leaving the app.
**8 effects available:**
| Effect | What it does |
|--------|-------------|
| **Pitch Shift** | Shift pitch up or down by up to 12 semitones |
| **Reverb** | Room reverb with configurable size, damping, and wet/dry mix |
| **Delay** | Echo with adjustable delay time, feedback, and mix |
| **Chorus / Flanger** | Modulated delay -- short for metallic flanger, longer for lush chorus |
| **Compressor** | Dynamic range compression with threshold, ratio, attack, and release |
| **Gain** | Volume adjustment from -40 to +40 dB |
| **High-Pass Filter** | Remove low frequencies below a configurable cutoff |
| **Low-Pass Filter** | Remove high frequencies above a configurable cutoff |
**Effects presets** -- Four built-in presets ship out of the box (Robotic, Radio, Echo Chamber, Deep Voice), and you can create unlimited custom presets. Presets are drag-and-drop chains of effects with per-parameter sliders.
**Per-profile default effects** -- Assign an effects chain to a voice profile and it applies automatically to every generation with that voice. Override per-generation from the generate box.
**Live preview** -- Audition any effects chain against an existing generation before committing. The preview streams processed audio without saving anything.
### Generation Versions
Every generation now supports **multiple versions** with full provenance tracking:
- **Original** -- the clean, unprocessed TTS output (always preserved)
- **Effects versions** -- apply different effects chains to create new versions from any source version
- **Takes** -- regenerate with the same text and voice but a new seed for variation
- **Source tracking** -- each version records which version it was derived from
- **Version pinning in stories** -- pin a specific version to a track clip in the story editor, independent of the generation's default
- **Favorites** -- star generations to mark them for quick access
---
## New Platform Support
### Linux (Native)
Full Linux support with `.deb` and `.rpm` packages. Includes PulseAudio/PipeWire audio capture for voice sample recording.
### AMD ROCm GPU Acceleration
AMD GPU users now get hardware-accelerated inference via ROCm, with automatic `HSA_OVERRIDE_GFX_VERSION` configuration for GPUs not officially in the ROCm compatibility list (e.g., RX 6600).
### NVIDIA CUDA Backend Swap
The CPU-only release can download and swap in a CUDA-accelerated backend binary from within the app -- no reinstall required. Handles GitHub's 2GB asset limit by downloading split parts and verifying SHA-256 checksums.
### Intel Arc (XPU) and DirectML
PyTorch backend also supports Intel Arc GPUs via IPEX/XPU and Windows any-GPU via DirectML.
### Docker + Web Deployment
Run Voicebox headless as a Docker container with the full web UI:
```bash
docker compose up
```
3-stage build, non-root runtime, health checks, persistent model cache across rebuilds. Binds to localhost only by default.
---
## Model Management
- **Per-model unload** -- free GPU memory without deleting downloaded models
- **Custom models directory** -- set `VOICEBOX_MODELS_DIR` to store models anywhere
- **Model folder migration** -- move all models to a new location with progress tracking
- **Whisper Turbo** -- added `openai/whisper-large-v3-turbo` as a transcription model option
- **Download cancel/clear UI** -- cancel in-progress downloads, VS Code-style problems panel for errors
---
## Security
- **CORS hardening** -- replaced wildcard `*` with an explicit allowlist of local origins; extensible via `VOICEBOX_CORS_ORIGINS` env var
- **Network access toggle** -- fully disable outbound network requests for air-gapped deployments
## Accessibility
- Comprehensive screen reader support (tested with NVDA/Narrator) across all major UI surfaces
- Keyboard navigation for voice cards, history rows, model management, and story editor
- State-aware `aria-label` attributes on all interactive controls
## Reliability
- **Atomic audio saves** -- two-phase write prevents corrupted files on crash/interrupt
- **Filesystem health endpoint** -- proactive disk space and directory writability checks
- **Errno-specific error messages** -- clear feedback for permission denied, disk full, missing directory
## UX Polish
- Responsive layout with horizontal-scroll voice cards on mobile
- App version shown in sidebar
- Voice card heights normalized
- Audio player title hidden at narrow widths to prevent overflow
---
## Installation
| Platform | Download |
|----------|----------|
| **macOS (Apple Silicon)** | `Voicebox_0.2.0_aarch64.dmg` |
| **macOS (Intel)** | `Voicebox_0.2.0_x64.dmg` |
| **Windows** | `Voicebox_0.2.0_x64_en-US.msi` or `x64-setup.exe` |
| **Linux** | `.deb` / `.rpm` packages |
| **Docker** | `docker compose up` |
The app includes automatic updates -- future patches will be installed automatically.
---
## Video Script Beats
For the marketing video, focus on these six beats:
1. **"Four engines, one app"** -- show the engine dropdown switching between Qwen, LuxTTS, Chatterbox, and Turbo
2. **"23 languages"** -- generate the same voice clone in Arabic, Japanese, Hindi, etc.
3. **"Make it expressive"** -- type `/laugh` and `/sigh` with Chatterbox Turbo, play back the result
4. **"Shape your sound"** -- apply the Robotic or Deep Voice preset, preview it live, then build a custom effects chain with drag-and-drop
5. **"No limits"** -- paste a long script, show it auto-chunk and generate seamlessly
6. **"Queue and go"** -- fire off multiple generations back-to-back without waiting
+195 -21
View File
@@ -8,12 +8,17 @@ tauri_dir := "tauri"
app_dir := "app"
web_dir := "web"
venv := backend_dir / "venv"
venv_bin := venv / "bin"
python := venv_bin / "python"
pip := venv_bin / "pip"
# Detect best python for venv creation
system_python := `command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3`
# Platform-aware paths
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 ────────────────────────────────────────────────────────────
@@ -23,6 +28,7 @@ setup: setup-python setup-js
@echo "Setup complete! Run: just dev"
# Create venv and install Python dependencies
[unix]
setup-python:
#!/usr/bin/env bash
set -euo pipefail
@@ -46,70 +52,186 @@ setup-python:
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
{{ pip }} install pyinstaller -q
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
& "{{ pip }}" install pyinstaller -q
Write-Host "Python environment ready."
# Install JavaScript dependencies
setup-js:
bun install
# ─── 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
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
backend_pid=""
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
echo "Backend already running on http://localhost:17493"
else
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
backend_pid=$!
sleep 2
fi
trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT
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-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
Start-Sleep -Seconds 2; \
}; \
Write-Host "Starting Tauri desktop app..."; \
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# Start backend only
[unix]
dev-backend: _ensure-venv
{{ 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)
[unix]
dev-frontend: _ensure-sidecar
cd {{ tauri_dir }} && bun run tauri dev
# Start backend + web app (no Tauri)
[windows]
dev-frontend: _ensure-sidecar
Set-Location "{{ tauri_dir }}"; bun run tauri dev
# Start backend (if not already running) + web app (no Tauri)
[unix]
dev-web: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
cd {{ web_dir }} && bun run dev &
wait
backend_pid=""
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
echo "Backend already running on http://localhost:17493"
else
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
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-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
Start-Sleep -Seconds 2; \
}; \
Write-Host "Starting web app..."; \
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# Kill all dev processes
[unix]
kill:
-pkill -f "uvicorn backend.main:app" 2>/dev/null || true
-pkill -f "vite" 2>/dev/null || true
@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 everything (server binary + desktop app)
build: build-server build-tauri
# Build Python server binary
# Build Python server binary (CPU)
[unix]
build-server: _ensure-venv
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
[windows]
build-server: _ensure-venv
$ErrorActionPreference = "Stop"; \
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
$triple = (rustc --print host-tuple); \
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
# Build CUDA server binary and place in app data dir for local testing
[windows]
build-server-cuda: _ensure-venv
$ErrorActionPreference = "Stop"; \
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py --cuda; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --cuda failed with exit code $LASTEXITCODE" }; \
$dest = "$env:APPDATA/com.voicebox.app/backends"; \
New-Item -ItemType Directory -Path $dest -Force | Out-Null; \
Copy-Item "backend/dist/voicebox-server-cuda.exe" "$dest/voicebox-server-cuda.exe" -Force; \
Write-Host "Copied CUDA binary to $dest"
# Build everything locally: CPU server + CUDA server + installable Tauri app
[windows]
build-local: build-server build-server-cuda build-tauri
# Build Tauri desktop app
[unix]
build-tauri:
cd {{ tauri_dir }} && bun run tauri build
[windows]
build-tauri:
Set-Location "{{ tauri_dir }}"; bun run tauri build
# Build web app
[unix]
build-web:
cd {{ web_dir }} && bun run build
[windows]
build-web:
Set-Location "{{ web_dir }}"; bun run build
# ─── Code Quality ────────────────────────────────────────────────────
# Run all checks (lint + format + typecheck)
@@ -131,42 +253,82 @@ fix:
# ─── Database ─────────────────────────────────────────────────────────
# Initialize SQLite database
[unix]
db-init: _ensure-venv
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
{{ python }} -c "from backend.database import init_db; init_db()"
[windows]
db-init: _ensure-venv
& "{{ python }}" -c "from backend.database import init_db; init_db()"
# Reset database (delete + reinit)
[unix]
db-reset:
rm -f {{ backend_dir }}/data/voicebox.db
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 ────────────────────────────────────────────────────────
# Generate TypeScript API client (backend must be running)
[unix]
generate-api:
./scripts/generate-api.sh
[windows]
generate-api:
bash scripts/generate-api.sh
# Open API docs in browser
[unix]
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
[unix]
logs:
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 build artifacts
[unix]
clean:
rm -rf {{ tauri_dir }}/src-tauri/target/release
rm -rf {{ web_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
[unix]
clean-python:
rm -rf {{ venv }}
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)
[unix]
clean-all: clean clean-python
rm -rf node_modules
rm -rf {{ app_dir }}/node_modules
@@ -174,10 +336,18 @@ clean-all: clean clean-python
rm -rf {{ web_dir }}/node_modules
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 ─────────────────────────────────────────────────────────
# Ensure venv exists (prompt to run setup if not)
[private]
[private, unix]
_ensure-venv:
#!/usr/bin/env bash
if [ ! -d "{{ venv }}" ]; then
@@ -185,6 +355,10 @@ _ensure-venv:
exit 1
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
[private]
_ensure-sidecar:
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/landing",
"version": "0.1.13",
"version": "0.2.1",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "bun --bun next dev --turbo",
@@ -9,11 +9,13 @@
"lint": "next lint"
},
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -21,7 +23,8 @@
"react-dom": "^18.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"wavesurfer.js": "^7.12.2"
},
"devDependencies": {
"@types/node": "^20.11.5",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getStarCount } from '@/lib/releases';
export const dynamic = 'force-dynamic';
export const revalidate = 600;
export async function GET() {
try {
const count = await getStarCount();
return NextResponse.json({ count });
} catch (error) {
console.error('Error fetching star count:', error);
return NextResponse.json({ error: 'Failed to fetch star count' }, { status: 500 });
}
}
+88 -23
View File
@@ -16,7 +16,7 @@
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent: 43 50% 50%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 0%;
--destructive-foreground: 0 0% 100%;
@@ -27,26 +27,52 @@
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 98%;
--card: 0 0% 8% / 0.6;
--card-foreground: 0 0% 98%;
--popover: 0 0% 8% / 0.8;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 8%;
--secondary: 0 0% 12% / 0.5;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 12% / 0.4;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 15% / 0.5;
--accent-foreground: 0 0% 98%;
/* Surfaces -- slightly warm-tinted darks */
--background: 30 4% 4%;
--foreground: 30 10% 94%;
--card: 30 4% 7%;
--card-foreground: 30 10% 94%;
--popover: 30 4% 7%;
--popover-foreground: 30 10% 94%;
--primary: 30 10% 94%;
--primary-foreground: 30 4% 7%;
--secondary: 30 4% 10%;
--secondary-foreground: 30 10% 94%;
--muted: 30 3% 12%;
--muted-foreground: 30 5% 55%;
--accent: 43 50% 45%;
--accent-foreground: 30 10% 94%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 15% / 0.5;
--input: 0 0% 15% / 0.5;
--ring: 0 0% 98% / 0.2;
--radius: 1rem;
--destructive-foreground: 30 10% 94%;
--border: 30 4% 13%;
--input: 30 4% 13%;
--ring: 30 10% 94% / 0.2;
--radius: 0.75rem;
/* App-specific surface tokens */
--app: 30 4% 4%;
--app-box: 30 4% 7%;
--app-dark-box: 30 4% 5%;
--app-darker-box: 30 4% 3%;
--app-light-box: 30 4% 14%;
--app-line: 30 4% 13%;
--app-button: 30 4% 11%;
--app-hover: 30 4% 15%;
--app-selected: 30 4% 17%;
/* Text hierarchy */
--ink: 30 10% 94%;
--ink-dull: 30 5% 55%;
--ink-faint: 30 3% 38%;
/* Accent shades */
--accent-faint: 43 45% 55%;
--accent-deep: 43 55% 35%;
--accent-glow: 43 60% 50%;
/* Sidebar */
--sidebar: 30 4% 3%;
--sidebar-line: 30 4% 10%;
}
}
@@ -60,9 +86,9 @@
body {
@apply bg-background text-foreground antialiased;
overflow-x: hidden;
background-image:
radial-gradient(at 0% 0%, rgba(255, 255, 255, 0.03) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(255, 255, 255, 0.02) 0px, transparent 50%);
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
}
@@ -71,3 +97,42 @@
text-wrap: balance;
}
}
/* Staggered fade-in animation for hero elements */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
opacity: 0;
animation: fadeUp 0.6s ease-out forwards;
}
/* Noise texture overlay for hero glow */
/* .hero-glow::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
opacity: 0.35;
mix-blend-mode: overlay;
will-change: transform;
} */
/* Scrollbar hiding */
::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none;
}
+23 -20
View File
@@ -1,17 +1,19 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Banner } from '@/components/Banner';
import { Footer } from '@/components/Footer';
import { Header } from '@/components/Header';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = {
title: 'Voicebox - Open Source Voice Cloning Desktop App Powered by Qwen3-TTS',
title: 'Voicebox - Open Source Voice Cloning Desktop App',
description:
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: [
'voice cloning',
'TTS',
'multi-engine',
'desktop app',
'AI voice',
'open source',
'text to speech',
],
icons: {
icon: [
{ url: '/favicon.png', type: 'image/png' },
@@ -20,8 +22,8 @@ export const metadata: Metadata = {
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
openGraph: {
title: 'voicebox',
description: 'Professional voice cloning with Qwen3-TTS',
title: 'Voicebox',
description: 'Open source voice cloning. Local-first. Free forever.',
type: 'website',
url: 'https://voicebox.sh',
},
@@ -30,15 +32,16 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}>
<div className="relative min-h-screen bg-background font-sans flex flex-col">
<Banner />
<Header />
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
{children}
</main>
<Footer />
</div>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div className="relative min-h-screen bg-background font-sans">{children}</div>
</body>
</html>
);
+294 -274
View File
@@ -1,304 +1,324 @@
'use client';
import { Cloud, Code, Cpu, Github, Shield, Zap } from 'lucide-react';
import Image from 'next/image';
import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ControlUI } from '@/components/ControlUI';
import { Features } from '@/components/Features';
import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { Button } from '@/components/ui/button';
import { Section } from '@/components/ui/section';
import { VoiceCreator } from '@/components/VoiceCreator';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
import { FeatureCard } from '../components/ui/feature-card';
export default function Home() {
const [downloadLinks, setDownloadLinks] = useState<DownloadLinks>(DOWNLOAD_LINKS);
useEffect(() => {
// Fetch latest release info
fetch('/api/releases')
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch releases');
}
if (!res.ok) throw new Error('Failed to fetch releases');
return res.json();
})
.then((data) => {
if (data.downloadLinks) {
setDownloadLinks(data.downloadLinks);
}
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
})
.catch((error) => {
console.error('Failed to fetch release info:', error);
// Keep fallback links (releases page) on error
});
}, []);
const features = [
{
title: 'Near-Perfect Voice Cloning',
description:
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
icon: <Zap className="h-6 w-6" />,
},
{
title: 'Stories Editor',
description:
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations.',
icon: <Code className="h-6 w-6" />,
},
{
title: 'Multi-Sample Support',
description:
'Combine multiple voice samples for higher quality and more natural-sounding results.',
icon: <Code className="h-6 w-6" />,
},
{
title: 'Local or Remote',
description:
'Run GPU inference locally or connect to a remote machine. One-click server setup.',
icon: <Cloud className="h-6 w-6" />,
},
{
title: 'Audio Transcription',
description:
'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
icon: <Shield className="h-6 w-6" />,
},
{
title: 'Cross-Platform',
description: 'Available for macOS, Windows, and Linux. No Python installation required.',
icon: <Cpu className="h-6 w-6" />,
},
];
return (
<div className="space-y-12 sm:space-y-16 md:space-y-20">
{/* Hero Section */}
<section className="relative py-12 sm:py-16 md:py-20 lg:py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-start">
{/* Left side - Content */}
<div className="space-y-6 lg:pr-8">
<div className="flex lg:justify-start justify-center mb-6">
<Image
src="/voicebox-logo-2.png"
alt="Voicebox Logo"
width={1024}
height={1024}
className="w-32 sm:w-40 md:w-48 h-auto"
priority
/>
</div>
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold leading-tight text-center lg:text-left">
Voicebox
</h1>
<p className="text-lg sm:text-xl md:text-2xl text-foreground/70 max-w-xl text-center lg:text-left mx-auto lg:mx-0">
Open source voice cloning powered by Qwen3-TTS. Create natural-sounding speech from
text with near-perfect voice replication.
</p>
<>
<Navbar />
{/* Mobile: centered screenshot above download buttons */}
<div className="flex justify-center lg:hidden my-8">
<div className="w-full max-w-2xl">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Application Screenshot"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
priority
/>
</div>
</div>
{/* Download buttons under left content */}
<div className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.macArm}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (ARM)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.macIntel}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (Intel)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.windows}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<WindowsIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Windows</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0" disabled>
<a
href={downloadLinks.linux}
onClick={(e) => e.preventDefault()}
className="flex items-center w-full relative opacity-50 cursor-not-allowed"
title="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
aria-label="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<LinuxIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Linux</span>
</a>
</Button>
</div>
<Button variant="outline" size="lg" asChild className="w-full max-w-2xl">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" />
View on GitHub
</a>
</Button>
</div>
</div>
{/* Desktop: Large screenshot positioned off-screen */}
<div className="hidden lg:block relative">
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Application Screenshot"
width={1920}
height={1080}
className="w-full h-auto"
priority
/>
</div>
</div>
</div>
{/* ── Hero Section ─────────────────────────────────────────────── */}
<section className="relative pt-32 pb-16">
{/* Background glow */}
<div className="hero-glow pointer-events-none absolute inset-0 -top-32">
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
</div>
</section>
{/* Screenshots Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="w-full md:w-[150%] md:-ml-[25%]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8">
<div className="w-full">
<Image
src="/assets/app-screenshot-2.webp"
alt="Voicebox Screenshot 2"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<div className="w-full">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Screenshot 1"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<div className="w-full">
<Image
src="/assets/app-screenshot-3.webp"
alt="Voicebox Screenshot 3"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
</div>
</div>
</section>
{/* Description Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-center mb-6 sm:mb-8">
What is Voicebox?
</h2>
<div className="space-y-6 text-lg text-foreground/80 text-center">
<p>
Voicebox is a <strong>local-first voice cloning studio</strong> with DAW-like features
for professional voice synthesis. Think of it as a{' '}
<strong>local, free and open-source alternative to ElevenLabs</strong> — download
models, clone voices, and generate speech entirely on your machine.
</p>
<p>
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives
you complete privacy, professional tools, and native performance. Download a voice
model, clone any voice from a few seconds of audio, and compose multi-voice projects
with studio-grade editing tools.
</p>
<p>
Optimized for performance with <strong>Metal acceleration on Mac</strong> and{' '}
<strong>CUDA acceleration on Windows/Linux</strong> for fast, local inference.
</p>
<p className="text-foreground/60">No Python install required.</p>
</div>
</div>
</section>
{/* Demo Video Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-center mb-8 sm:mb-12">
See it in action...
</h2>
<div className="flex justify-center">
<div className="w-full max-w-5xl">
{/** biome-ignore lint/a11y/useMediaCaption: not generating captions for this, ya damn linter */}
<video
className="w-full h-auto rounded-lg shadow-lg"
controls
playsInline
preload="metadata"
poster="/assets/app-screenshot-1.webp"
>
<source
src="/voicebox-demo.webm"
type="video/webm"
aria-label="Voicebox Demo Video"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</section>
{/* Features Section */}
<Section id="features">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{features.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
<div className="relative mx-auto max-w-7xl px-6 text-center">
{/* Logo */}
<div
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
style={{
animationDelay: '0ms',
filter:
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/voicebox-logo-app.webp"
alt="Voicebox"
className="h-full w-full object-contain mix-blend-lighten"
/>
))}
</div>
{/* Headline */}
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground drop-shadow-[0_16px_50px_rgba(0,0,0,0.95)] md:text-7xl lg:text-8xl">
Your voice, your machine.
</h1>
</div>
{/* Subtitle */}
<p
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
style={{ animationDelay: '200ms' }}
>
Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
generate natural speech, and compose multi-voice projects — all running locally.
</p>
{/* CTAs */}
<div
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
style={{ animationDelay: '300ms' }}
>
<a
href="#download"
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
>
Download
</a>
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
<Github className="h-4 w-4" />
View on GitHub
</a>
</div>
{/* Version */}
<p
className="fade-in mt-4 text-xs text-muted-foreground/50"
style={{ animationDelay: '400ms' }}
>
Free and open source &middot; macOS, Windows, Linux
</p>
</div>
</Section>
</div>
{/* ── ControlUI mockup ─────────────────────────────────────── */}
<div className="mt-16">
<ControlUI />
</div>
</section>
{/* ── Features ─────────────────────────────────────────────── */}
<Features />
{/* ── Voice Creator ────────────────────────────────────────── */}
<VoiceCreator />
{/* ── Models ─────────────────────────────────────────────────── */}
<section id="about" className="border-t border-border py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="text-center mb-14">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Multi-Engine Architecture
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Choose the right model for every job. All models run locally on your hardware —
download once, use forever.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Qwen3-TTS */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Qwen3-TTS</h3>
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
</div>
<div className="flex gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
1.7B
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
0.6B
</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
High-quality multilingual voice cloning with natural prosody. The only engine with
delivery instructions — control tone, pace, and emotion with natural language.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Globe className="h-3 w-3" />
10 languages
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<MessageSquare className="h-3 w-3" />
Delivery instructions
</span>
</div>
</div>
{/* Chatterbox */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Chatterbox</h3>
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Production-grade voice cloning with the broadest language support. 23 languages with
zero-shot cloning and emotion exaggeration control.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Languages className="h-3 w-3" />
23 languages
</span>
</div>
</div>
{/* Chatterbox Turbo */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Chatterbox Turbo</h3>
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
350M
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
and more directly in your text for expressive, natural speech.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Zap className="h-3 w-3" />
350M params
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<MessageSquare className="h-3 w-3" />
[laugh] [sigh] tags
</span>
</div>
</div>
{/* LuxTTS */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">LuxTTS</h3>
<span className="text-xs text-muted-foreground/60">by ZipVoice</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
~1GB VRAM. The fastest engine for quick iterations.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Zap className="h-3 w-3" />
150x realtime
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
48kHz output
</span>
</div>
</div>
</div>
</div>
</section>
{/* ── Download Section ─────────────────────────────────────── */}
<section id="download" className="border-t border-border py-24">
<div className="mx-auto max-w-4xl px-6">
<div className="text-center mb-12">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Download Voicebox
</h2>
<p className="text-muted-foreground">
Available for macOS, Windows, and Linux. No dependencies required.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
{/* macOS ARM */}
<a
href={downloadLinks.macArm}
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
>
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">macOS</div>
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
</div>
</a>
{/* macOS Intel */}
<a
href={downloadLinks.macIntel}
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
>
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">macOS</div>
<div className="text-xs text-muted-foreground">Intel (x64)</div>
</div>
</a>
{/* Windows */}
<a
href={downloadLinks.windows}
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
>
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">Windows</div>
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
</div>
</a>
{/* Linux */}
<a
href={downloadLinks.linux}
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
>
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">Linux</div>
<div className="text-xs text-muted-foreground">AppImage (x64)</div>
</div>
</a>
</div>
{/* GitHub link */}
<div className="mt-6 text-center">
<a
href={`${GITHUB_REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Github className="h-4 w-4" />
View all releases on GitHub
</a>
</div>
</div>
</section>
{/* ── Footer ───────────────────────────────────────────────── */}
<Footer />
</>
);
}
+889
View File
@@ -0,0 +1,889 @@
'use client';
import { motion } from 'framer-motion';
import {
AudioLines,
Box,
Download,
Mic,
MoreHorizontal,
Pencil,
Server,
Sparkles,
Speaker,
Star,
Trash2,
Volume2,
Wand2,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
// ─── Data ───────────────────────────────────────────────────────────────────
// Edit this section to customise all the content shown in the ControlUI demo.
interface VoiceProfile {
name: string;
description: string;
language: string;
hasEffects: boolean;
}
/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
const PROFILES: VoiceProfile[] = [
{
name: 'Jarvis',
description: 'Dry wit, composed British AI assistant',
language: 'en',
hasEffects: true,
},
{
name: 'Samuel L. Jackson',
description: 'Commanding intensity with sharp, punchy delivery',
language: 'en',
hasEffects: true,
},
{
name: 'Bob Ross',
description: 'Gentle, soothing voice full of quiet encouragement',
language: 'en',
hasEffects: false,
},
{
name: 'Sam Altman',
description: 'Measured, thoughtful Silicon Valley cadence',
language: 'en',
hasEffects: false,
},
{
name: 'Morgan Freeman',
description: 'Rich, warm baritone with gravitas and calm authority',
language: 'en',
hasEffects: false,
},
{
name: 'Linus Tech Tips',
description: 'Enthusiastic, fast-paced tech explainer energy',
language: 'en',
hasEffects: false,
},
{
name: 'Fireship',
description: 'Rapid-fire, deadpan tech humor with zero filler',
language: 'en',
hasEffects: false,
},
{
name: 'Scarlett Johansson',
description: 'Smooth, low alto with understated warmth',
language: 'en',
hasEffects: false,
},
{
name: 'Dario Amodei',
description: 'Calm, precise articulation with academic depth',
language: 'en',
hasEffects: false,
},
{
name: 'David Attenborough',
description: 'Warm, reverent narration with wonder and precision',
language: 'en',
hasEffects: false,
},
{
name: 'Zendaya',
description: 'Relaxed, modern delivery with effortless cool',
language: 'en',
hasEffects: false,
},
{
name: 'Barack Obama',
description: 'Measured cadence with rhythmic pauses and gravitas',
language: 'en',
hasEffects: false,
},
];
/** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */
interface DemoStep {
profileIndex: number;
text: string;
audioUrl: string;
engine: string;
duration: string;
effect?: string;
}
const DEMO_SCRIPT: DemoStep[] = [
{
profileIndex: 0,
text: 'Sir, I have completed the analysis. Your code has twelve critical vulnerabilities, your coffee is cold, and frankly your commit messages could use some work.',
audioUrl: '/audio/jarvis.webm',
engine: 'Qwen 1.7B',
duration: '0:10',
effect: 'Robot',
},
{
profileIndex: 4,
text: "I've narrated penguins, galaxies, and the entire history of mankind. But nothing prepared me for the moment a computer learned to do my job from a five second audio clip.",
audioUrl: '/audio/morganfreeman.webm',
engine: 'Qwen 1.7B',
duration: '0:11',
effect: 'Radio',
},
{
profileIndex: 3,
text: "Open source? [laugh] What's that?",
audioUrl: '/audio/samaltman.webm',
engine: 'Chatterbox',
duration: '0:03',
},
{
profileIndex: 1,
text: "So let me get this straight. You downloaded an app, pressed a button, and now there's two of me? The world was not ready for one",
audioUrl: '/audio/samjackson.webm',
engine: 'Qwen 1.7B',
duration: '0:10',
},
{
profileIndex: 5,
text: "So we got this voice cloning software and honestly it's kind of terrifying. Like, my wife could not tell the difference. Voicebox dot s h, link in the description!",
audioUrl: '/audio/linus.webm',
engine: 'Qwen 1.7B',
duration: '0:11',
},
{
profileIndex: 6,
text: 'This is Voicebox in one hundred seconds. It clones voices locally, it runs on your GPU, and no, OpenAI cannot hear you. Lets go.',
audioUrl: '/audio/fireship.webm',
engine: 'Qwen 0.6B',
duration: '0:09',
},
];
/** History rows pre-populated on first load. Oldest first visually (array index 0 = top row). */
interface Generation {
id: number;
profileName: string;
text: string;
language: string;
engine: string;
duration: string;
timeAgo: string;
favorited: boolean;
versions: number;
}
const INITIAL_GENERATIONS: Generation[] = [
{
id: 1,
profileName: 'Morgan Freeman',
text: 'The neural pathways of human speech contain more complexity than any language model can fully capture, yet we keep pushing the boundaries of what is possible.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:08',
timeAgo: '2 minutes ago',
favorited: true,
versions: 3,
},
{
id: 2,
profileName: 'Samuel L. Jackson',
text: 'In a world increasingly shaped by artificial intelligence, the human voice remains our most powerful tool for connection and storytelling.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:07',
timeAgo: '15 minutes ago',
favorited: false,
versions: 1,
},
{
id: 3,
profileName: 'Jarvis',
text: 'The architecture of modern text-to-speech systems reveals an elegant interplay between transformer models and acoustic feature prediction.',
language: 'en',
engine: 'Qwen 0.6B',
duration: '0:09',
timeAgo: '1 hour ago',
favorited: false,
versions: 2,
},
{
id: 4,
profileName: 'Bob Ross',
text: 'Welcome to the next chapter. Every great story begins with a single voice, and today that voice can be yours.',
language: 'en',
engine: 'Chatterbox',
duration: '0:06',
timeAgo: '3 hours ago',
favorited: true,
versions: 1,
},
{
id: 5,
profileName: 'Linus Tech Tips',
text: 'Local inference gives you complete control over your voice data. No cloud, no subscriptions, no compromises.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:05',
timeAgo: '5 hours ago',
favorited: false,
versions: 1,
},
];
const SIDEBAR_ITEMS = [
{ icon: Volume2, label: 'Generate' },
{ icon: AudioLines, label: 'Stories' },
{ icon: Mic, label: 'Voices' },
{ icon: Wand2, label: 'Effects' },
{ icon: Speaker, label: 'Audio' },
{ icon: Box, label: 'Models' },
{ icon: Server, label: 'Server' },
];
// ─── Phase system ───────────────────────────────────────────────────────────
type Phase = 'idle' | 'selecting' | 'typing' | 'generating' | 'complete' | 'playing';
const PHASE_DURATIONS: Record<Phase, number> = {
idle: 2500,
selecting: 800,
typing: 6000,
generating: 2800,
complete: 1200,
playing: 4000,
};
// ─── Typewriter ─────────────────────────────────────────────────────────────
function TypewriterText({ text, speed }: { text: string; speed?: number }) {
// Default: fill the typing phase duration, leaving 500ms buffer at the end
const resolvedSpeed =
speed ?? Math.max(20, Math.floor((PHASE_DURATIONS.typing - 500) / text.length));
const [displayed, setDisplayed] = useState('');
const indexRef = useRef(0);
useEffect(() => {
indexRef.current = 0;
setDisplayed('');
const interval = setInterval(() => {
indexRef.current += 1;
if (indexRef.current <= text.length) {
setDisplayed(text.slice(0, indexRef.current));
} else {
clearInterval(interval);
}
}, resolvedSpeed);
return () => clearInterval(interval);
}, [text, resolvedSpeed]);
return (
<>
{displayed}
<span className="inline-block h-3.5 w-[2px] animate-pulse bg-foreground/70 ml-[1px] align-middle" />
</>
);
}
// ─── Loading bars (simplified react-loaders replacement) ────────────────────
function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className="flex items-center gap-[2px] h-5">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${i}-${mode}`}
className={`w-[3px] rounded-full ${barColor}`}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: {}
}
/>
))}
</div>
);
}
// ─── Profile Card ───────────────────────────────────────────────────────────
const ProfileCard = ({
profile,
selected,
selecting,
cardRef,
}: {
profile: VoiceProfile;
selected: boolean;
selecting: boolean;
cardRef?: React.Ref<HTMLDivElement>;
}) => {
return (
<motion.div
ref={cardRef}
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col h-[143px] transition-all duration-200 ${
selected ? 'border-accent shadow-md' : 'border-border/50 hover:shadow-sm'
} ${selecting && !selected ? 'opacity-60' : ''}`}
animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}}
transition={{ duration: 0.3 }}
>
<div className="text-[15px] font-bold leading-tight line-clamp-2">{profile.name}</div>
<div className="text-[10px] text-muted-foreground line-clamp-2 leading-relaxed mt-1">
{profile.description}
</div>
<div className="flex items-center gap-1.5 mt-2">
<span className="text-[10px] px-1.5 py-0.5 rounded-md border border-border text-muted-foreground">
{profile.language}
</span>
{profile.hasEffects && <Sparkles className="h-3 w-3 text-accent fill-accent" />}
</div>
<div className="flex items-center gap-1 mt-auto justify-end">
<Download className="h-3.5 w-3.5 text-muted-foreground/40" />
<Pencil className="h-3.5 w-3.5 text-muted-foreground/40" />
<Trash2 className="h-3.5 w-3.5 text-muted-foreground/40" />
</div>
</motion.div>
);
};
// ─── History Row ────────────────────────────────────────────────────────────
function HistoryRow({
gen,
mode,
isNew,
}: {
gen: Generation;
mode: 'idle' | 'generating' | 'playing';
isNew: boolean;
}) {
return (
<motion.div
className={`border rounded-md transition-colors text-left w-full ${
mode === 'playing' ? 'bg-muted/70' : 'bg-card'
}`}
initial={isNew ? { opacity: 0, y: -8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<div className="flex items-stretch gap-3 h-[80px] p-2.5">
{/* Status icon */}
<div className="w-8 flex items-center justify-center shrink-0">
<LoadingBars mode={mode} />
</div>
{/* Meta info */}
<div className="flex flex-col gap-1 w-36 shrink-0 justify-center">
<div className="text-[12px] font-medium truncate">{gen.profileName}</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{gen.language}</span>
<span>{gen.engine}</span>
{mode !== 'generating' && <span>{gen.duration}</span>}
</div>
<div className="text-[10px] text-muted-foreground">
{mode === 'generating' ? (
<span className="text-accent">Generating...</span>
) : (
gen.timeAgo
)}
</div>
</div>
{/* Transcript */}
<div className="flex-1 min-w-0 flex items-center">
<div className="text-[11px] text-muted-foreground line-clamp-3 leading-relaxed">
{gen.text}
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col justify-center items-center gap-0.5 shrink-0">
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<Star
className={`h-2.5 w-2.5 ${
gen.favorited ? 'text-accent fill-accent' : 'text-muted-foreground/50'
}`}
/>
</button>
{gen.versions > 1 && (
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<AudioLines className="h-2.5 w-2.5 text-muted-foreground/50" />
</button>
)}
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<MoreHorizontal className="h-2.5 w-2.5 text-muted-foreground/50" />
</button>
</div>
</div>
</motion.div>
);
}
// ─── Floating Generate Box ──────────────────────────────────────────────────
function FloatingGenerateBox({
phase,
typingText,
selectedProfile,
engine,
effect,
}: {
phase: Phase;
typingText: string;
selectedProfile: VoiceProfile | null;
engine: string;
effect?: string;
}) {
const isFocused = phase === 'typing' || phase === 'generating';
const isGenerating = phase === 'generating';
return (
<motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
animate={{
borderColor: isGenerating
? 'hsl(43 50% 45% / 0.35)'
: isFocused
? 'hsl(43 50% 45% / 0.25)'
: 'hsl(43 50% 45% / 0.15)',
}}
transition={{ duration: 0.3 }}
>
{/* Text area + generate button */}
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<motion.div
className="overflow-hidden"
animate={{ height: isFocused ? 100 : 32 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<div
className="text-[12.5px] text-muted-foreground/60 px-2 py-1 leading-relaxed"
style={{ minHeight: isFocused ? 100 : 32 }}
>
{phase === 'typing' ? (
<span className="text-foreground">
<TypewriterText text={typingText} />
</span>
) : phase === 'generating' ? (
<span className="text-muted-foreground/40">{typingText}</span>
) : (
<span>
{selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'}
</span>
)}
</div>
</motion.div>
</div>
{/* Generate button */}
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
<Sparkles className="h-3.5 w-3.5 text-white fill-white" />
</button>
</div>
{/* Bottom selectors */}
<div className="flex items-center gap-1.5 mt-2">
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
English
</span>
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
{engine}
</span>
<span
className={`text-[10px] px-2 py-1 rounded-full border flex items-center gap-1 ${
effect
? 'border-accent/30 bg-accent/10 text-accent'
: 'border-border bg-card text-muted-foreground'
}`}
>
<Sparkles className={`h-2.5 w-2.5 ${effect ? 'fill-accent' : ''}`} />
{effect || 'Effect'}
</span>
</div>
</motion.div>
);
}
// ─── Main ControlUI ─────────────────────────────────────────────────────────
export function ControlUI() {
const [phase, setPhase] = useState<Phase>('idle');
const [selectedIndex, setSelectedIndex] = useState(DEMO_SCRIPT[0].profileIndex);
const [cycle, setCycle] = useState(0);
const [newGenId, setNewGenId] = useState<number | null>(null);
const [generations, setGenerations] = useState<Generation[]>([...INITIAL_GENERATIONS]);
const [isMuted, setIsMuted] = useState(true);
const [isVisible, setIsVisible] = useState(true);
const [pageHidden, setPageHidden] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef(phase);
const mobileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const desktopCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const profileGridRef = useRef<HTMLDivElement>(null);
const [scrollLeft, setScrollLeft] = useState(0);
phaseRef.current = phase;
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
const selectedProfile = PROFILES[selectedIndex];
// Scroll to selected profile card — accounts for generate box overlay on desktop
useEffect(() => {
const isMobile = window.innerWidth < 768;
if (isMobile) {
const el = mobileCardRefs.current.get(selectedIndex);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
return;
}
// Desktop
const el = desktopCardRefs.current.get(selectedIndex);
const scrollContainer = profileGridRef.current;
if (!el || !scrollContainer) return;
const containerTop = scrollContainer.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const elRelTop = elTop - containerTop + scrollContainer.scrollTop;
const rowHeight = 145;
const generateBoxHeight = 200;
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight;
const elRelBottom = elRelTop + el.offsetHeight;
if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) {
return;
}
const target = elRelTop - rowHeight;
scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}, [selectedIndex]);
// Visibility detection
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
threshold: 0,
});
if (containerRef.current) observer.observe(containerRef.current);
const handleVisibility = () => setPageHidden(document.visibilityState !== 'visible');
document.addEventListener('visibilitychange', handleVisibility);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
const paused = !isVisible || pageHidden;
// Phase cycling — `playing` phase is driven by audio finish, not a timeout
useEffect(() => {
if (paused || phase === 'playing') return;
const duration = PHASE_DURATIONS[phase];
const timer = setTimeout(() => {
console.log(
'[ControlUI] phase transition',
phase,
'→ next, cycle:',
cycle,
'step profile:',
PROFILES[step.profileIndex].name,
);
switch (phase) {
case 'idle': {
setSelectedIndex(step.profileIndex);
setPhase('selecting');
break;
}
case 'selecting':
setPhase('typing');
break;
case 'typing': {
const profile = PROFILES[step.profileIndex];
const newGen: Generation = {
id: Date.now(),
profileName: profile.name,
text: step.text,
language: profile.language,
engine: step.engine,
duration: step.duration,
timeAgo: 'just now',
favorited: false,
versions: 1,
};
setGenerations((prev) => [newGen, ...prev.slice(0, 5)]);
setNewGenId(newGen.id);
setPhase('generating');
break;
}
case 'generating':
setPhase('playing');
break;
}
}, duration);
return () => clearTimeout(timer);
}, [phase, paused, step, cycle]);
const handleAudioFinish = useCallback(() => {
if (phaseRef.current !== 'playing') return;
setPhase('idle');
setCycle((c) => c + 1);
setNewGenId(null);
}, []);
const isGenerating = phase === 'generating';
return (
<div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6">
{/* Unmute button with handwritten hint */}
<div className="flex justify-end mb-3">
<div className="relative">
{/* Handwritten hint — absolutely positioned above the button */}
{isMuted && (
<motion.div
className="absolute select-none pointer-events-none"
style={{ top: -30, right: 100 }}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 0.6, ease: 'easeOut' }}
>
<span
className="text-xl text-accent/80 whitespace-nowrap"
style={{
fontFamily: "'Caveat', 'Segoe Script', 'Comic Sans MS', cursive",
letterSpacing: '0.02em',
}}
>
try me!
</span>
{/* Curved arrow from text down-right toward the button */}
<svg
width="22"
height="11"
viewBox="0 0 80 40"
fill="none"
className="text-accent/70 absolute"
style={{ top: 14, left: 60 }}
aria-hidden="true"
>
<title>Arrow</title>
<path
d="M4 4 C20 4, 40 8, 55 20 C62 26, 66 32, 70 36"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
fill="none"
/>
<path
d="M58 42 L70 36 L64 22"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
transform="rotate(35, 70, 36)"
fill="none"
/>
</svg>
</motion.div>
)}
<button
onClick={() => {
unlockAudioContext();
setIsMuted(!isMuted);
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isMuted ? (
<>
<Volume2 className="h-3.5 w-3.5" />
<span>Unmute</span>
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5 text-accent" />
<span>Mute</span>
</>
)}
</button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px] pointer-events-none select-none">
<div className="flex flex-col md:flex-row h-full">
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
{/* Logo */}
<div className="mb-1">
<div
className="w-9 h-9 rounded-lg overflow-hidden"
style={{
filter:
'drop-shadow(0 0 6px hsl(43 50% 45% / 0.5)) drop-shadow(0 0 14px hsl(43 50% 45% / 0.35))',
}}
>
<img
src="/voicebox-logo-app.webp"
alt=""
className="w-full h-full object-contain"
/>
</div>
</div>
{/* Nav items */}
<div className="flex flex-col gap-2">
{SIDEBAR_ITEMS.map((item, i) => {
const Icon = item.icon;
const active = i === 0;
return (
<div
key={item.label}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
active
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground/60'
}`}
>
<Icon className="h-4 w-4" />
</div>
);
})}
</div>
{/* Version */}
<div className="mt-auto text-[8px] text-muted-foreground/40">v0.2.0</div>
</div>
{/* ── Main content ──────────────────────────────────────── */}
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
{/* Left: Profiles + Generate box */}
<div className="flex flex-col min-w-0 relative md:flex-1 md:overflow-hidden">
{/* Gradient fade overlay — sits between header and scroll content */}
<div className="hidden md:block absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-app-box to-transparent z-[1] pointer-events-none" />
{/* Header — floats above everything */}
<div className="absolute top-0 left-0 right-0 z-10 px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between">
<h2 className="text-base font-bold">Voicebox</h2>
<div className="flex items-center gap-1.5">
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
Import Voice
</button>
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center">
Create Voice
</button>
</div>
</div>
{/* Scrollable profile cards — scrolls behind header + gradient */}
<div
ref={profileGridRef}
className="flex-1 min-h-0 md:overflow-y-auto md:pt-14 pt-12"
>
<div className="px-4">
{/* Mobile: horizontal scroll strip with edge fade */}
<div className="relative md:hidden">
{scrollLeft > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-6 bg-gradient-to-r from-app-box to-transparent z-10" />
)}
<div className="absolute right-0 top-0 bottom-0 w-6 bg-gradient-to-l from-app-box to-transparent z-10" />
<div
className="flex gap-2 overflow-x-auto pb-2"
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
>
{PROFILES.map((profile, i) => (
<div
key={profile.name}
className="shrink-0 w-[140px]"
ref={(el) => {
if (el) mobileCardRefs.current.set(i, el);
}}
>
<ProfileCard
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
/>
</div>
))}
</div>
</div>
{/* Desktop: 3-col grid */}
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-44">
{PROFILES.map((profile, i) => (
<ProfileCard
key={profile.name}
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
cardRef={(el: HTMLDivElement | null) => {
if (el) desktopCardRefs.current.set(i, el);
}}
/>
))}
</div>
</div>
</div>
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-4 md:right-4 md:bottom-[117px] md:z-20 md:pb-0 md:px-0">
<FloatingGenerateBox
phase={phase}
typingText={step.text}
selectedProfile={selectedProfile}
engine={step.engine}
effect={step.effect}
/>
</div>
</div>
{/* Right/Below: History */}
<div className="md:w-[48%] shrink-0 flex flex-col min-w-0 border-t md:border-t-0 border-app-line">
<div className="max-h-[360px] md:max-h-none flex-1 overflow-hidden px-3 pt-3 md:pt-6 pb-3">
<div className="flex flex-col gap-2">
{generations.map((gen) => {
const isThisNew = gen.id === newGenId;
const rowMode: 'idle' | 'generating' | 'playing' =
isThisNew && isGenerating
? 'generating'
: isThisNew && phase === 'playing'
? 'playing'
: 'idle';
return <HistoryRow key={gen.id} gen={gen} mode={rowMode} isNew={isThisNew} />;
})}
</div>
</div>
</div>
{/* Audio player */}
<LandingAudioPlayer
audioUrl={step.audioUrl}
title={selectedProfile.name}
playing={phase === 'playing'}
muted={isMuted}
onFinish={handleAudioFinish}
onClose={() => {}}
/>
</div>
</div>
</div>
</div>
);
}
+855
View File
@@ -0,0 +1,855 @@
'use client';
import { motion } from 'framer-motion';
import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
// ─── Lazy load wrapper ──────────────────────────────────────────────────────
function LazyLoad({
children,
className,
rootMargin = '200px',
}: {
children: React.ReactNode;
className?: string;
rootMargin?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [rootMargin]);
return (
<div ref={ref} className={className}>
{visible ? children : null}
</div>
);
}
// ─── Animation: Voice Cloning ───────────────────────────────────────────────
function VoiceCloningAnimation() {
const [phase, setPhase] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setPhase((p) => (p + 1) % 3);
}, 2400);
return () => clearInterval(interval);
}, []);
const samples = ['Sample 1', 'Sample 2', 'Sample 3'];
const bars = [0.4, 0.7, 0.5, 0.9, 0.3, 0.6, 0.8, 0.4, 0.7, 0.5, 0.3, 0.6];
return (
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
<div className="flex flex-col items-center gap-3 w-full max-w-[200px]">
{/* Sample pills */}
<div className="flex gap-1.5">
{samples.map((s, i) => (
<motion.div
key={s}
className="text-[9px] px-2 py-1 rounded-full border font-medium"
animate={{
borderColor: i === phase ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
backgroundColor: i === phase ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
color: i === phase ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.4)',
}}
transition={{ duration: 0.3 }}
>
{s}
</motion.div>
))}
</div>
{/* Waveform visualization */}
<div className="flex items-center gap-[2px] h-10 w-full justify-center">
{bars.map((h, i) => (
<motion.div
key={i}
className="w-[4px] rounded-full"
animate={{
height: `${h * 100}%`,
backgroundColor: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.15)',
}}
transition={{
height: { duration: 0.6, delay: i * 0.04, ease: 'easeInOut' },
backgroundColor: { duration: 0.3 },
}}
/>
))}
</div>
{/* Result label */}
<motion.div
className="text-[9px] font-mono"
animate={{
opacity: phase === 2 ? 1 : 0.3,
color: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.3)',
}}
transition={{ duration: 0.3 }}
>
voice profile ready
</motion.div>
</div>
</div>
);
}
// ─── Mini waveform for clips ────────────────────────────────────────────────
// Fixed-width dense waveform that overflows — the clip container clips it.
// This way resizing a clip just reveals/hides bars instead of re-rendering.
const WAVEFORM_BAR_COUNT = 60;
function MiniWaveform({ seed, color }: { seed: number; color: string }) {
// Deterministic pseudo-random waveform that looks like real speech audio.
// Uses layered noise at different frequencies for natural envelope + detail.
const bars = useMemo(() => {
// Seeded pseudo-random number generator (deterministic per seed)
let s = seed * 9301 + 49297;
const rand = () => {
s = (s * 16807 + 0) % 2147483647;
return s / 2147483647;
};
// Pre-generate random values
const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
const t = i / WAVEFORM_BAR_COUNT;
// Slow envelope — broad amplitude shape (words / phrases)
const envelope =
0.3 +
0.35 *
Math.sin(t * Math.PI * (2 + (seed % 3))) *
Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
// Medium variation — syllable-level bumps
const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
// High-frequency noise — individual sample jitter
const noise = (r[i] - 0.5) * 0.25;
// Combine and clamp
const raw = envelope + mid + noise;
return Math.max(0.06, Math.min(1, raw));
});
}, [seed]);
return (
<div className="flex items-center h-full overflow-hidden">
{bars.map((h, i) => (
<div
key={`w-${seed}-${i}`}
className="shrink-0 rounded-full opacity-50"
style={{
width: 2,
marginRight: 1,
height: `${h * 100}%`,
backgroundColor: color,
}}
/>
))}
</div>
);
}
// ─── Animation: Stories Editor ───────────────────────────────────────────────
// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
const INITIAL_CLIPS: DemoClip[] = [
{ id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
{ id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
{ id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
{ id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
{ id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
];
// Timeline width the clips live inside
const TL_W = 220;
// Each action returns a new clips array (or modifies in place)
type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
const ACTIONS: Action[] = [
// 0 — move Jarvis clip earlier
{ label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
// 1 — split Morgan's first clip into two with visible gap
{
label: 'Split clip',
apply: (c) => {
// Idempotent: if n1b already exists, the split already happened
if (c.some((cl) => cl.id === 'n1b')) return c;
const clip = c.find((cl) => cl.id === 'n1');
if (!clip) return c;
const leftW = 25;
const gap = 8;
const rightW = clip.w - leftW - gap;
return [
...c.filter((cl) => cl.id !== 'n1'),
{ ...clip, w: leftW, id: 'n1' },
{
id: 'n1b',
profile: clip.profile,
track: clip.track,
x: clip.x + leftW + gap,
w: rightW,
seed: 6,
},
];
},
},
// 2 — trim Scarlett's second clip shorter
{ label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
// 3 — duplicate Jarvis to track 0
{
label: 'Duplicate',
apply: (c) => {
// Idempotent: if b1d already exists, the duplicate already happened
if (c.some((cl) => cl.id === 'b1d')) return c;
const clip = c.find((cl) => cl.id === 'b1');
if (!clip) return c;
return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
},
},
// 4 — reset
{ label: '', apply: () => INITIAL_CLIPS },
];
function StoriesAnimation() {
const [clips, setClips] = useState<DemoClip[]>(INITIAL_CLIPS);
const [actionIndex, setActionIndex] = useState(-1);
const [playheadX, setPlayheadX] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const playheadRef = useRef<ReturnType<typeof requestAnimationFrame>>(0);
// Animate the playhead continuously
useEffect(() => {
let start: number | null = null;
const speed = 12; // px per second
const animate = (ts: number) => {
if (start === null) start = ts;
const elapsed = (ts - start) / 1000;
setPlayheadX((elapsed * speed) % TL_W);
playheadRef.current = requestAnimationFrame(animate);
};
playheadRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(playheadRef.current);
}, []);
// Step through actions
useEffect(() => {
const interval = setInterval(() => {
setActionIndex((prev) => {
const next = (prev + 1) % ACTIONS.length;
setClips((current) => ACTIONS[next].apply(current));
// Highlight the clip being acted on
if (next === 0) setSelectedId('b1');
else if (next === 1) setSelectedId('n1');
else if (next === 2) setSelectedId('a2');
else if (next === 3) setSelectedId('b1');
else setSelectedId(null);
return next;
});
}, 2600);
return () => clearInterval(interval);
}, []);
const trackLabels = ['1', '0', '-1'];
const timeMarkers = [0, 2, 4, 6, 8];
const accentColor = 'hsl(43 50% 45%)';
const accentFg = 'hsl(30 10% 94%)';
return (
<div className="h-40 w-full flex flex-col overflow-hidden rounded-md bg-app-darkerBox/50">
{/* Toolbar */}
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-app-line bg-app-darkBox/60 shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-ink-faint/40" />
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
<div className="border-l-[4px] border-l-ink-faint border-t-[3px] border-t-transparent border-b-[3px] border-b-transparent ml-0.5" />
</div>
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
<div className="w-2 h-2 rounded-sm bg-ink-faint/60" />
</div>
</div>
<span className="text-[8px] text-ink-faint font-mono ml-1 tabular-nums">0:03 / 0:10</span>
<div className="flex-1" />
{actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
<motion.span
key={actionIndex}
className="text-[7px] font-medium px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${accentColor.replace(')', ' / 0.15)')}`,
color: accentColor,
}}
initial={{ opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
>
{ACTIONS[actionIndex].label}
</motion.span>
)}
<div className="flex items-center gap-0.5">
<span className="text-[7px] text-ink-faint">Zoom</span>
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
-
</div>
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
+
</div>
</div>
</div>
{/* Timeline */}
<div className="flex flex-1 min-h-0">
{/* Track labels sidebar */}
<div className="w-7 shrink-0 border-r border-app-line bg-app-darkBox/30 flex flex-col">
<div className="h-5 border-b border-app-line" />
{trackLabels.map((label) => (
<div
key={label}
className="flex-1 flex items-center justify-center border-b border-app-line"
>
<span className="text-[7px] text-ink-faint select-none">{label}</span>
</div>
))}
</div>
{/* Tracks area */}
<div className="flex-1 relative overflow-hidden flex flex-col">
{/* Time ruler */}
<div className="h-5 shrink-0 border-b border-app-line bg-app-darkBox/20 relative">
{timeMarkers.map((t) => (
<div
key={`tm-${t}`}
className="absolute top-0 h-full flex flex-col justify-end pb-0.5"
style={{ left: `${(t / 10) * 100}%` }}
>
<div className="h-1.5 w-px bg-app-line" />
<span className="text-[7px] text-ink-faint ml-0.5 select-none">{`0:0${t}`}</span>
</div>
))}
</div>
{/* Track rows + clips — same parent so percentages match */}
<div className="flex-1 relative min-h-0">
{/* Track rows background */}
{trackLabels.map((label, i) => (
<div
key={`bg-${label}`}
className="border-b border-app-line absolute left-0 right-0"
style={{
height: `${100 / 3}%`,
top: `${(i * 100) / 3}%`,
backgroundColor: i % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)',
}}
/>
))}
{/* Clips */}
{clips.map((clip) => {
const trackIdx = clip.track;
const isSelected = clip.id === selectedId;
const clipTop = `calc(${(trackIdx * 100) / 3}% + 2px)`;
const clipHeight = `calc(${100 / 3}% - 4px)`;
return (
<motion.div
key={clip.id}
className="absolute rounded overflow-hidden"
initial={false}
style={{
height: clipHeight,
left: `${(clip.x / TL_W) * 100}%`,
width: `${(clip.w / TL_W) * 100}%`,
top: clipTop,
}}
animate={{
left: `${(clip.x / TL_W) * 100}%`,
width: `${(clip.w / TL_W) * 100}%`,
top: clipTop,
}}
transition={{ type: 'spring', stiffness: 200, damping: 25 }}
>
<div
className="w-full h-full rounded overflow-hidden flex flex-col"
style={{
backgroundColor: isSelected ? 'hsl(43 50% 45%)' : 'hsl(43 45% 40%)',
boxShadow: isSelected
? 'inset 0 0 0 1px hsl(43 50% 55%), 0 0 0 1px hsl(30 10% 94% / 0.4)'
: 'inset 0 0 0 1px hsl(30 10% 94% / 0.1)',
}}
>
{/* Profile label — scaled to bypass browser min font size */}
<div className="shrink-0 relative" style={{ height: 9 }}>
<span
className="text-[10px] font-medium leading-none absolute top-0 left-0.5 origin-top-left opacity-80 whitespace-nowrap"
style={{ color: accentFg, transform: 'scale(0.75)' }}
>
{clip.profile}
</span>
</div>
{/* Waveform — absolutely positioned so it never affects clip width */}
<div className="absolute left-0 right-0 bottom-0" style={{ top: 9 }}>
<MiniWaveform seed={clip.seed} color={accentFg} />
</div>
</div>
{/* Trim handles on selected */}
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l"
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 rounded-r"
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
/>
</>
)}
</motion.div>
);
})}
{/* Playhead */}
<motion.div
className="absolute top-0 bottom-0 w-[2px] rounded-full z-20 pointer-events-none"
style={{ backgroundColor: accentColor }}
animate={{ left: `${(playheadX / TL_W) * 100}%` }}
transition={{ duration: 0.05, ease: 'linear' }}
>
<div
className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
</motion.div>
</div>
</div>
</div>
</div>
);
}
// ─── Animation: Effects Pipeline ────────────────────────────────────────────
function EffectsAnimation() {
const [activeEffect, setActiveEffect] = useState(0);
const effects = [
{ name: 'Pitch Shift', param: '-3 semitones', color: '#3b82f6' },
{ name: 'Reverb', param: 'Room 0.7', color: '#8b5cf6' },
{ name: 'Compressor', param: '-15 dB', color: '#ec4899' },
{ name: 'Low-Pass', param: '6000 Hz', color: '#14b8a6' },
];
// Waveform bars — original shape
const rawBars = [0.3, 0.6, 0.8, 0.5, 0.9, 0.4, 0.7, 0.3, 0.6, 0.5, 0.8, 0.4, 0.7, 0.9, 0.3];
useEffect(() => {
const interval = setInterval(() => {
setActiveEffect((p) => (p + 1) % effects.length);
}, 2200);
return () => clearInterval(interval);
}, [effects.length]);
return (
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-3">
{/* Effects chain */}
<div className="flex items-center gap-1">
{effects.map((fx, i) => (
<div key={fx.name} className="flex items-center gap-1">
<motion.div
className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
animate={{
borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
backgroundColor: i <= activeEffect ? `${fx.color}15` : 'rgba(255,255,255,0.02)',
color: i <= activeEffect ? fx.color : 'rgba(255,255,255,0.3)',
}}
transition={{ duration: 0.3 }}
>
{fx.name}
</motion.div>
{i < effects.length - 1 && (
<motion.span
className="text-[8px]"
animate={{
color: i < activeEffect ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.08)',
}}
transition={{ duration: 0.3 }}
>
&rarr;
</motion.span>
)}
</div>
))}
</div>
{/* Waveform that morphs as effects are applied */}
<div className="flex items-center gap-[2px] h-10 w-full max-w-[200px] justify-center">
{rawBars.map((h, i) => {
// Each effect stage progressively transforms the shape
const shifted = activeEffect >= 0 ? h * (0.7 + 0.3 * Math.sin(i * 0.8)) : h;
const dampened = activeEffect >= 1 ? shifted * (0.6 + 0.4 * Math.cos(i * 0.3)) : shifted;
const compressed = activeEffect >= 2 ? 0.3 + dampened * 0.5 : dampened;
const filtered = activeEffect >= 3 ? compressed * (1 - i * 0.03) : compressed;
const finalH = Math.max(0.08, Math.min(1, filtered));
return (
<motion.div
key={`bar-${i}`}
className="w-[3px] rounded-full"
animate={{
height: `${finalH * 100}%`,
backgroundColor: effects[activeEffect].color,
}}
transition={{
height: { duration: 0.5, delay: i * 0.02, ease: 'easeInOut' },
backgroundColor: { duration: 0.4 },
}}
/>
);
})}
</div>
{/* Active effect detail */}
<motion.div
className="text-[9px] font-mono text-ink-faint"
key={activeEffect}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{effects[activeEffect].name}: {effects[activeEffect].param}
</motion.div>
</div>
);
}
// ─── Animation: Local or Remote ─────────────────────────────────────────────
function LocalRemoteAnimation() {
const [mode, setMode] = useState(0);
const modes = ['Local GPU', 'Remote Server'];
useEffect(() => {
const interval = setInterval(() => {
setMode((p) => (p + 1) % 2);
}, 2800);
return () => clearInterval(interval);
}, []);
return (
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
<div className="flex flex-col items-center gap-4 w-full max-w-[180px]">
{/* Toggle */}
<div className="flex gap-1 p-0.5 rounded-full border border-app-line bg-app-darkerBox">
{modes.map((m, i) => (
<motion.div
key={m}
className="text-[9px] px-3 py-1 rounded-full font-medium"
animate={{
backgroundColor: i === mode ? 'hsl(43 50% 45%)' : 'transparent',
color: i === mode ? 'hsl(30 10% 94%)' : 'rgba(255,255,255,0.35)',
}}
transition={{ duration: 0.25 }}
>
{m}
</motion.div>
))}
</div>
{/* Status */}
<div className="flex flex-col items-center gap-2">
<motion.div
className="w-2 h-2 rounded-full"
animate={{
backgroundColor: mode === 0 ? '#4ade80' : '#3b82f6',
boxShadow: mode === 0 ? '0 0 8px #4ade80' : '0 0 8px #3b82f6',
}}
transition={{ duration: 0.3 }}
/>
<span className="text-[9px] text-ink-faint font-mono">
{mode === 0 ? 'Metal acceleration active' : 'Connected to 192.168.1.50'}
</span>
<span className="text-[8px] text-ink-faint/60 font-mono">
{mode === 0 ? 'VRAM: 8.2 / 16.0 GB' : 'Latency: 12ms | CUDA'}
</span>
</div>
</div>
</div>
);
}
// ─── Animation: Transcription ───────────────────────────────────────────────
function TranscriptionAnimation() {
const [charIndex, setCharIndex] = useState(0);
const text = 'The quick brown fox jumps over the lazy dog near the riverbank.';
useEffect(() => {
const interval = setInterval(() => {
setCharIndex((p) => {
if (p >= text.length) return 0;
return p + 1;
});
}, 80);
return () => clearInterval(interval);
}, [text.length]);
return (
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-3">
{/* Fake waveform */}
<div className="flex items-center gap-[1px] h-6 w-full max-w-[180px] justify-center">
{Array.from({ length: 30 }, (_, i) => {
const h = 0.2 + 0.8 * Math.abs(Math.sin(i * 0.5 + charIndex * 0.1));
const active = i < (charIndex / text.length) * 30;
return (
<div
key={i}
className={`w-[3px] rounded-full transition-colors duration-100 ${
active ? 'bg-accent' : 'bg-app-line'
}`}
style={{ height: `${h * 100}%` }}
/>
);
})}
</div>
{/* Transcribed text */}
<div className="text-[10px] text-ink-dull font-mono max-w-[200px] text-center leading-relaxed min-h-[32px]">
{text.slice(0, charIndex)}
{charIndex < text.length && (
<span className="inline-block w-[2px] h-3 bg-accent animate-pulse ml-[1px] align-middle" />
)}
</div>
</div>
);
}
// ─── Animation: Unlimited Length ─────────────────────────────────────────────
function UnlimitedLengthAnimation() {
const [phase, setPhase] = useState(0);
const chunks = [
'The morning sun crept over the mountains, casting long shadows across the valley below.',
'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
];
useEffect(() => {
const interval = setInterval(() => {
setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2.5">
{/* Chunk pills */}
<div className="flex flex-col gap-1 w-full max-w-[220px]">
{chunks.map((chunk, i) => (
<motion.div
key={`chunk-${i}`}
className="flex items-center gap-1.5 px-2 py-1 rounded border text-[8px]"
animate={{
borderColor:
phase === 3
? 'hsl(43 50% 45% / 0.3)'
: i === phase
? 'hsl(43 50% 45% / 0.5)'
: i < phase
? 'rgba(255,255,255,0.12)'
: 'rgba(255,255,255,0.06)',
backgroundColor:
phase === 3
? 'hsl(43 50% 45% / 0.04)'
: i === phase
? 'hsl(43 50% 45% / 0.08)'
: i < phase
? 'rgba(255,255,255,0.04)'
: 'rgba(255,255,255,0.02)',
}}
transition={{ duration: 0.4 }}
>
{/* Status indicator */}
<motion.div
className="w-1.5 h-1.5 rounded-full shrink-0"
animate={{
backgroundColor:
phase === 3
? 'hsl(43 50% 50%)'
: i === phase
? 'hsl(43 50% 50%)'
: i < phase
? 'rgba(255,255,255,0.3)'
: 'rgba(255,255,255,0.1)',
boxShadow:
i === phase && phase < 3 ? '0 0 6px hsl(43 50% 50%)' : '0 0 0px transparent',
}}
transition={{ duration: 0.3 }}
/>
<span
className={`truncate font-mono ${
phase === 3 || i <= phase ? 'text-ink-dull' : 'text-ink-faint/50'
}`}
>
{chunk}
</span>
</motion.div>
))}
</div>
{/* Crossfade / result bar */}
<div className="flex items-center gap-1 w-full max-w-[220px]">
{chunks.map((_, i) => (
<motion.div
key={`seg-${i}`}
className="h-1.5 flex-1 rounded-full"
animate={{
backgroundColor:
phase === 3
? 'hsl(43 50% 45%)'
: i < phase
? 'rgba(255,255,255,0.2)'
: i === phase
? 'hsl(43 50% 45% / 0.5)'
: 'rgba(255,255,255,0.06)',
}}
transition={{ duration: 0.4 }}
/>
))}
</div>
{/* Status text */}
<motion.div
className="text-[9px] font-mono"
key={phase}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<span className={phase === 3 ? 'text-accent' : 'text-ink-faint'}>
{phase < 3
? `generating chunk ${phase + 1} of ${chunks.length}...`
: 'crossfaded & ready'}
</span>
</motion.div>
</div>
);
}
// ─── Feature data ───────────────────────────────────────────────────────────
const FEATURES = [
{
title: 'Near-Perfect Voice Cloning',
description:
'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
icon: Mic,
animation: VoiceCloningAnimation,
},
{
title: 'Stories Editor',
description:
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations between characters.',
icon: AudioLines,
animation: StoriesAnimation,
},
{
title: 'Audio Effects Pipeline',
description:
'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
icon: Sparkles,
animation: EffectsAnimation,
},
{
title: 'Local or Remote',
description:
'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
icon: Cloud,
animation: LocalRemoteAnimation,
},
{
title: 'Audio Transcription',
description:
'Powered by Whisper for accurate speech-to-text. Automatically extract reference text from voice samples.',
icon: MessageSquareText,
animation: TranscriptionAnimation,
},
{
title: 'Unlimited Generation Length',
description:
'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
icon: TextCursorInput,
animation: UnlimitedLengthAnimation,
},
];
// ─── Feature Card ───────────────────────────────────────────────────────────
function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
const Icon = feature.icon;
const Animation = feature.animation;
return (
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
<LazyLoad>
<div className="pointer-events-none select-none">
<Animation />
</div>
</LazyLoad>
<div className="p-5">
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-accent" />
<h3 className="text-[15px] font-medium text-foreground">{feature.title}</h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
</div>
</div>
);
}
// ─── Features Section ───────────────────────────────────────────────────────
export function Features() {
return (
<section id="features" className="border-t border-border py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="mb-16 text-center">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Professional voice tools, zero compromise
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Everything you need to clone voices, generate speech, and produce multi-voice content —
running entirely on your machine.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((feature) => (
<FeatureCard key={feature.title} feature={feature} />
))}
</div>
</div>
</section>
);
}
+61 -24
View File
@@ -1,22 +1,33 @@
import Image from 'next/image';
import Link from 'next/link';
import { Separator } from '@/components/ui/separator';
import { GITHUB_REPO } from '@/lib/constants';
export function Footer() {
return (
<footer className="border-t border-border pt-8 sm:pt-12 pb-6 sm:pb-8 mt-12 sm:mt-16 md:mt-20">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 mb-6 sm:mb-8">
<div>
<h3 className="font-bold text-lg mb-4">voicebox</h3>
<p className="text-muted-foreground text-sm">
Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
Linux.
<footer className="border-t border-border py-12">
<div className="mx-auto max-w-7xl px-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-10">
{/* Brand */}
<div className="md:col-span-1">
<div className="flex items-center gap-2.5 mb-4">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={24}
height={24}
className="h-6 w-6"
/>
<span className="text-sm font-semibold">Voicebox</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Open source voice cloning studio. Local-first, free forever.
</p>
</div>
{/* Product */}
<div>
<h4 className="font-semibold mb-3">Product</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<h4 className="text-sm font-semibold mb-3">Product</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<a href="#features" className="hover:text-foreground transition-colors">
Features
@@ -28,20 +39,17 @@ export function Footer() {
</a>
</li>
<li>
<Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</Link>
<a href="#about" className="hover:text-foreground transition-colors">
About
</a>
</li>
</ul>
</div>
{/* Resources */}
<div>
<h4 className="font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<h4 className="text-sm font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href={GITHUB_REPO}
@@ -74,10 +82,39 @@ export function Footer() {
</li>
</ul>
</div>
{/* Also by */}
<div>
<h4 className="text-sm font-semibold mb-3">Also By</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<a
href="https://spacebot.sh"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Spacebot
</a>
</li>
<li>
<a
href="https://spacedrive.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Spacedrive
</a>
</li>
</ul>
</div>
</div>
<Separator className="my-8" />
<div className="text-center text-muted-foreground text-sm space-y-2">
<p>© 2026 voicebox. All rights reserved.</p>
<div className="border-t border-border pt-6">
<p className="text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Voicebox. Open source under MIT license.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,296 @@
'use client';
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
// Must call .play() on WaveSurfer's actual media element during a user gesture
let sharedWaveSurfer: WaveSurfer | null = null;
let audioUnlocked = false;
export function unlockAudioContext() {
if (audioUnlocked) return;
audioUnlocked = true;
// Unlock WaveSurfer's internal audio element
if (sharedWaveSurfer) {
const media = sharedWaveSurfer.getMediaElement();
if (media) {
media.muted = true;
media
.play()
.then(() => {
media.pause();
media.muted = false;
media.currentTime = 0;
})
.catch(() => {});
}
}
// Also unlock a standalone AudioContext as fallback
try {
const ctx = new (
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
)();
const buffer = ctx.createBuffer(1, 1, 22050);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0);
} catch {
// Silently fail
}
}
interface LandingAudioPlayerProps {
audioUrl: string;
title: string;
playing: boolean;
muted: boolean;
onFinish: () => void;
onClose: () => void;
}
export function LandingAudioPlayer({
audioUrl,
title,
playing,
muted,
onFinish,
onClose,
}: LandingAudioPlayerProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.75);
const [isLooping, setIsLooping] = useState(false);
const [isReady, setIsReady] = useState(false);
const onFinishRef = useRef(onFinish);
onFinishRef.current = onFinish;
const playingRef = useRef(playing);
playingRef.current = playing;
const mutedRef = useRef(muted);
mutedRef.current = muted;
// Initialize WaveSurfer
useEffect(() => {
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
setTimeout(initWaveSurfer, 50);
return;
}
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
setTimeout(initWaveSurfer, 50);
return;
}
// Clean up existing instance
if (wavesurferRef.current) {
wavesurferRef.current.destroy();
wavesurferRef.current = null;
}
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const ws = WaveSurfer.create({
container,
waveColor: getCSSVar('--muted'),
progressColor: getCSSVar('--accent'),
cursorColor: getCSSVar('--accent'),
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
interact: true,
mediaControls: false,
});
ws.on('ready', () => {
setDuration(ws.getDuration());
ws.setVolume(mutedRef.current ? 0 : volume);
setIsReady(true);
});
ws.on('play', () => {
console.log('[Player] play event');
setIsPlaying(true);
});
ws.on('pause', () => {
console.log('[Player] pause event');
setIsPlaying(false);
});
ws.on('timeupdate', (time: number) => {
setCurrentTime(Math.min(time, ws.getDuration()));
});
let didFinish = false;
ws.on('finish', () => {
if (didFinish) return;
didFinish = true;
console.log(
'[Player] finish event, currentTime:',
ws.getCurrentTime(),
'duration:',
ws.getDuration(),
);
setIsPlaying(false);
onFinishRef.current();
});
ws.load(audioUrl);
wavesurferRef.current = ws;
sharedWaveSurfer = ws;
};
setIsReady(false);
setCurrentTime(0);
setDuration(0);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(initWaveSurfer, 10);
});
});
return () => {
if (wavesurferRef.current) {
wavesurferRef.current.destroy();
wavesurferRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioUrl]);
// Respond to external play/stop signals
useEffect(() => {
const ws = wavesurferRef.current;
console.log('[Player] effect', { playing, isReady, hasWs: !!ws });
if (!ws || !isReady) return;
if (playing) {
// Resume the AudioContext first (required for iOS Safari after unlock)
const backend = ws.getMediaElement();
if (backend && 'context' in backend) {
const ctx = (backend as unknown as { context: AudioContext }).context;
if (ctx?.state === 'suspended') ctx.resume();
}
ws.play()
.then(() => {
console.log('[Player] play succeeded');
})
.catch((e: Error) => {
if (e.name === 'NotAllowedError') {
console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
} else {
console.error('[Player] play failed', e);
}
});
} else {
ws.pause();
}
}, [playing, isReady]);
// Sync volume and muted state
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(muted ? 0 : volume);
}
}, [volume, muted]);
const handlePlayPause = useCallback(() => {
if (!wavesurferRef.current) return;
wavesurferRef.current.playPause();
}, []);
return (
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30">
<div className="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
{/* Waveform — full width row on mobile, inline on desktop */}
<div className="min-w-0 min-h-[60px] md:min-h-[80px] md:flex-1 md:order-2">
<div ref={waveformRef} className="w-full h-full min-h-[60px] md:min-h-[80px]" />
</div>
{/* Controls row */}
<div className="flex items-center gap-3 md:contents">
{/* Play/Pause */}
<button
onClick={handlePlayPause}
disabled={!isReady}
className="h-10 w-10 rounded-full bg-accent flex items-center justify-center shrink-0 disabled:opacity-50 md:order-1 shadow-lg"
>
{isPlaying ? (
<Pause className="h-5 w-5 text-accent-foreground fill-accent-foreground" />
) : (
<Play className="h-5 w-5 ml-0.5 text-accent-foreground fill-accent-foreground" />
)}
</button>
{/* Time */}
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 md:order-3">
<span className="font-mono text-xs">{formatDuration(currentTime)}</span>
<span className="text-xs">/</span>
<span className="font-mono text-xs">{formatDuration(duration)}</span>
</div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
{title}
</div>
)}
{/* Loop */}
<button
onClick={() => setIsLooping(!isLooping)}
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
isLooping ? 'text-foreground' : 'text-muted-foreground'
}`}
>
<Repeat className="h-4 w-4" />
</button>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0 w-[140px] md:order-6 mr-3">
<button
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
>
{volume > 0 ? (
<Volume2 className="h-4 w-4 text-muted-foreground" />
) : (
<VolumeX className="h-4 w-4 text-muted-foreground" />
)}
</button>
<input
type="range"
min={0}
max={100}
value={volume * 100}
onChange={(e) => setVolume(Number(e.target.value) / 100)}
className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
/>
</div>
</div>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { Github } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { GITHUB_REPO } from '@/lib/constants';
function formatStarCount(count: number): string {
if (count >= 1000) {
const k = count / 1000;
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
}
return count.toString();
}
export function Navbar() {
const [starCount, setStarCount] = useState<number | null>(null);
useEffect(() => {
fetch('/api/stars')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch stars');
return res.json();
})
.then((data) => {
if (typeof data.count === 'number') setStarCount(data.count);
})
.catch((error) => {
console.error('Failed to fetch star count:', error);
});
}, []);
return (
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3">
{/* Logo + wordmark */}
<a href="/" className="flex items-center gap-2.5">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={28}
height={28}
className="h-7 w-7"
/>
<span className="text-[15px] font-semibold text-foreground">Voicebox</span>
</a>
{/* Nav links */}
<div className="hidden sm:flex items-center gap-1">
<a
href="#features"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Features
</a>
<a
href="#about"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
About
</a>
<a
href="#download"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Download
</a>
</div>
{/* GitHub star button */}
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
<Github className="h-4 w-4" />
<span className="text-[13px] font-medium">Star</span>
{starCount !== null && (
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
{formatStarCount(starCount)}
</span>
)}
</a>
</div>
</nav>
);
}
+479
View File
@@ -0,0 +1,479 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { Mic, Monitor, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
// ─── Waveform bars generator ────────────────────────────────────────────────
function generateWaveformBars(count: number, seed: number): number[] {
const bars: number[] = [];
for (let i = 0; i < count; i++) {
const x = i / count;
// Speech-like envelope: ramp up, sustain, taper
const envelope = Math.sin(x * Math.PI) * 0.8 + 0.2;
// Layered pseudo-random noise
const n1 = Math.sin(seed * 127.1 + i * 43.7) * 0.5 + 0.5;
const n2 = Math.sin(seed * 269.5 + i * 17.3) * 0.3 + 0.5;
const n3 = Math.sin(seed * 53.9 + i * 97.1) * 0.2 + 0.5;
const noise = (n1 + n2 + n3) / 3;
bars.push(envelope * noise);
}
return bars;
}
// ─── Animated waveform background ───────────────────────────────────────────
function WaveformBackground({ active }: { active: boolean }) {
const bars = useMemo(() => generateWaveformBars(60, 42), []);
return (
<div className="absolute inset-0 pointer-events-none flex items-end justify-center overflow-hidden">
<div className="flex items-end gap-[2px] w-full h-full px-4 pb-4">
{bars.map((h, i) => {
const maxH = 120; // max bar height in px
const baseH = 4;
const activeH = baseH + h * maxH;
const idleH = baseH + h * maxH * 0.25;
return (
<motion.div
key={i}
className="flex-1 rounded-full bg-accent"
animate={{
opacity: active ? 0.35 : 0.1,
height: active ? [idleH, activeH, idleH * 1.5, activeH * 0.7, idleH] : idleH,
}}
transition={
active
? {
duration: 1.0 + (i % 5) * 0.12,
repeat: Infinity,
repeatType: 'mirror',
delay: (i % 7) * 0.04,
ease: 'easeInOut',
}
: { duration: 0.6 }
}
/>
);
})}
</div>
</div>
);
}
// ─── Tab content panels ─────────────────────────────────────────────────────
function UploadPanel() {
const [hasFile, setHasFile] = useState(false);
useEffect(() => {
// Simulate file drop after 2s
const t1 = setTimeout(() => setHasFile(true), 2000);
const t2 = setTimeout(() => setHasFile(false), 5000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, []);
return (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] transition-colors duration-300 ${
hasFile ? 'border-accent bg-accent/5' : 'border-dashed border-muted-foreground/25'
}`}
>
<AnimatePresence mode="wait">
{!hasFile ? (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Upload className="h-4 w-4" />
Choose File
</div>
<p className="text-xs text-muted-foreground text-center">
Drag and drop an audio file, or click to browse.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
) : (
<motion.div
key="file"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">sample-voice-clip.wav</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function RecordPanel() {
const [state, setState] = useState<'idle' | 'recording' | 'done'>('idle');
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const t1 = setTimeout(() => setState('recording'), 1500);
const t2 = setTimeout(() => setState('done'), 5500);
const t3 = setTimeout(() => {
setState('idle');
setElapsed(0);
}, 8000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, []);
// Timer
useEffect(() => {
if (state !== 'recording') return;
setElapsed(0);
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(interval);
}, [state]);
const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
return (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
state === 'recording'
? 'border-accent bg-accent/5'
: state === 'done'
? 'border-accent bg-accent/5'
: 'border-dashed border-muted-foreground/25'
}`}
>
<WaveformBackground active={state === 'recording'} />
<AnimatePresence mode="wait">
{state === 'idle' && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Mic className="h-4 w-4" />
Start Recording
</div>
<p className="text-xs text-muted-foreground text-center">
Click to record from your microphone.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
)}
{state === 'recording' && (
<motion.div
key="recording"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
</div>
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
Stop Recording
</div>
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
</motion.div>
)}
{state === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Mic className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Recording complete</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function SystemPanel() {
const [state, setState] = useState<'idle' | 'capturing' | 'done'>('idle');
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const t1 = setTimeout(() => setState('capturing'), 1500);
const t2 = setTimeout(() => setState('done'), 5500);
const t3 = setTimeout(() => {
setState('idle');
setElapsed(0);
}, 8000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, []);
useEffect(() => {
if (state !== 'capturing') return;
setElapsed(0);
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(interval);
}, [state]);
const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
return (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
state === 'capturing'
? 'border-accent bg-accent/5'
: state === 'done'
? 'border-accent bg-accent/5'
: 'border-dashed border-muted-foreground/25'
}`}
>
<WaveformBackground active={state === 'capturing'} />
<AnimatePresence mode="wait">
{state === 'idle' && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Monitor className="h-4 w-4" />
Start Capture
</div>
<p className="text-xs text-muted-foreground text-center">
Capture audio playing on your system.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
)}
{state === 'capturing' && (
<motion.div
key="capturing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
</div>
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
Stop Capture
</div>
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
</motion.div>
)}
{state === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Capture complete</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── Tab selector ───────────────────────────────────────────────────────────
const TABS = [
{ id: 'upload' as const, label: 'Upload', icon: Upload },
{ id: 'record' as const, label: 'Microphone', icon: Mic },
{ id: 'system' as const, label: 'System Audio', icon: Monitor },
];
type TabId = (typeof TABS)[number]['id'];
// ─── Main section ───────────────────────────────────────────────────────────
export function VoiceCreator() {
const [activeTab, setActiveTab] = useState<TabId>('record');
const [cycleKey, setCycleKey] = useState(0);
// Auto-cycle tabs
useEffect(() => {
const tabOrder: TabId[] = ['record', 'upload', 'system'];
let idx = tabOrder.indexOf(activeTab);
const interval = setInterval(() => {
idx = (idx + 1) % tabOrder.length;
setActiveTab(tabOrder[idx]);
setCycleKey((k) => k + 1);
}, 9000);
return () => clearInterval(interval);
}, [activeTab]);
return (
<section className="border-t border-border py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-center">
{/* Left: Copy */}
<div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Clone any voice in seconds
</h2>
<p className="text-muted-foreground mb-6">
Three ways to capture a voice sample. Upload a clip, record from your microphone, or
capture audio playing on your system. Voicebox clones the voice from as little as 3
seconds of audio.
</p>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Upload className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">Upload a clip</div>
<div className="text-xs text-muted-foreground">
Drag and drop any audio file — WAV, MP3, FLAC, or WebM.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Mic className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">Record from microphone</div>
<div className="text-xs text-muted-foreground">
Live waveform preview while you record. Up to 30 seconds.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Monitor className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">System audio capture</div>
<div className="text-xs text-muted-foreground">
Clone a voice from a YouTube video, podcast, or any app playing audio.
</div>
</div>
</div>
</div>
</div>
{/* Right: Animated UI mock */}
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden pointer-events-none select-none">
<div className="p-5">
{/* Tab bar */}
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setCycleKey((k) => k + 1);
}}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{tab.label}</span>
</button>
);
})}
</div>
{/* Panel */}
<AnimatePresence mode="wait">
<motion.div
key={`${activeTab}-${cycleKey}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.2 }}
>
{activeTab === 'upload' && <UploadPanel />}
{activeTab === 'record' && <RecordPanel />}
{activeTab === 'system' && <SystemPanel />}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
);
}
+39
View File
@@ -19,6 +19,10 @@ let cachedReleaseInfo: ReleaseInfo | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
// Cache for star count
let cachedStarCount: number | null = null;
let starCacheTimestamp: number = 0;
/**
* Fetches the latest release from GitHub and extracts download links
*/
@@ -92,3 +96,38 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
throw error;
}
}
/**
* Fetches the star count for the repo from GitHub
*/
export async function getStarCount(): Promise<number> {
const now = Date.now();
if (cachedStarCount !== null && now - starCacheTimestamp < CACHE_DURATION) {
return cachedStarCount;
}
try {
const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}`, {
next: { revalidate: 600 },
headers: {
Accept: 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const repo = await response.json();
const count = repo.stargazers_count ?? 0;
cachedStarCount = count;
starCacheTimestamp = now;
return count;
} catch (error) {
console.error('Failed to fetch star count:', error);
if (cachedStarCount !== null) return cachedStarCount;
throw error;
}
}
+27
View File
@@ -8,6 +8,9 @@ module.exports = {
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
@@ -33,6 +36,9 @@ module.exports = {
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
faint: 'hsl(var(--accent-faint))',
deep: 'hsl(var(--accent-deep))',
glow: 'hsl(var(--accent-glow))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
@@ -42,6 +48,27 @@ module.exports = {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
// App surface tokens
app: {
DEFAULT: 'hsl(var(--app))',
box: 'hsl(var(--app-box))',
darkBox: 'hsl(var(--app-dark-box))',
darkerBox: 'hsl(var(--app-darker-box))',
lightBox: 'hsl(var(--app-light-box))',
line: 'hsl(var(--app-line))',
button: 'hsl(var(--app-button))',
hover: 'hsl(var(--app-hover))',
selected: 'hsl(var(--app-selected))',
},
ink: {
DEFAULT: 'hsl(var(--ink))',
dull: 'hsl(var(--ink-dull))',
faint: 'hsl(var(--ink-faint))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar))',
line: 'hsl(var(--sidebar-line))',
},
},
borderRadius: {
lg: 'var(--radius)',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voicebox",
"version": "0.1.13",
"version": "0.2.1",
"private": true,
"workspaces": [
"app",
+3 -1
View File
@@ -9,12 +9,14 @@ PLATFORM=$(rustc --print host-tuple 2>/dev/null || echo "unknown")
echo "Building voicebox-server for platform: $PLATFORM"
# Build Python binary
# Resolve PATH to absolute paths before changing directory
export PATH="$(cd "$(dirname "$0")/.." && pwd)/backend/venv/bin:$PATH"
cd backend
# Check if PyInstaller is installed
if ! python -c "import PyInstaller" 2>/dev/null; then
echo "Installing PyInstaller..."
pip install pyinstaller
python -m pip install pyinstaller
fi
# Build binary
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/tauri",
"private": true,
"version": "0.1.13",
"version": "0.2.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voicebox"
version = "0.1.13"
version = "0.2.1"
dependencies = [
"base64 0.22.1",
"core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voicebox"
version = "0.1.13"
version = "0.2.1"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"]
license = ""
Binary file not shown.
Binary file not shown.
+54 -214
View File
@@ -282,6 +282,7 @@ async fn start_server(
.ok_or_else(|| "Invalid data dir path".to_string())?
.to_string();
let port_str = SERVER_PORT.to_string();
let parent_pid_str = std::process::id().to_string();
let is_remote = remote.unwrap_or(false);
// Resolve the custom models directory from the parameter or stored state
@@ -294,7 +295,7 @@ async fn start_server(
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
println!("Launching CUDA backend: {:?}", cuda_path);
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str]);
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote {
cmd = cmd.args(["--host", "0.0.0.0"]);
}
@@ -304,7 +305,7 @@ async fn start_server(
cmd.spawn()
} else {
// Use the bundled CPU sidecar
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str]);
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote {
sidecar = sidecar.args(["--host", "0.0.0.0"]);
}
@@ -490,67 +491,13 @@ async fn start_server(
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
}
/// Check if a Windows process is still running
#[cfg(windows)]
fn is_process_running(pid: u32) -> bool {
use std::process::Command;
if let Ok(output) = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
.output()
{
// If process exists, tasklist returns it in output
let output_str = String::from_utf8_lossy(&output.stdout);
return !output_str.trim().is_empty() && output_str.contains(&pid.to_string());
}
false
}
/// Kill entire Windows process tree by enumerating children
#[cfg(windows)]
fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> {
use std::process::Command;
// Find all child processes using WMIC
let output = Command::new("wmic")
.args([
"process",
"where",
&format!("ParentProcessId={}", parent_pid),
"get",
"ProcessId"
])
.output();
if let Ok(output) = output {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines().skip(1) { // Skip header
if let Ok(child_pid) = line.trim().parse::<u32>() {
println!("Found child process: {}", child_pid);
// Recursively kill child's children
let _ = kill_windows_process_tree(child_pid);
// Kill the child
let _ = Command::new("taskkill")
.args(["/PID", &child_pid.to_string(), "/F"])
.output();
}
}
}
// Kill the parent process
let _ = Command::new("taskkill")
.args(["/PID", &parent_pid.to_string(), "/F"])
.output();
Ok(())
}
#[command]
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let pid = state.server_pid.lock().unwrap().take();
let _child = state.child.lock().unwrap().take();
if let Some(pid) = pid {
println!("stop_server: Killing server process group with PID: {}", pid);
println!("stop_server: Stopping server with PID: {}", pid);
#[cfg(unix)]
{
@@ -569,62 +516,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let _ = Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
println!("stop_server: Process group kill completed");
}
#[cfg(windows)]
{
// Layer 1: Try graceful HTTP shutdown first
println!("Attempting graceful shutdown via HTTP...");
// Send graceful shutdown via HTTP — the server's parent-pid watchdog
// will also handle cleanup if this app process exits.
println!("Sending graceful shutdown via HTTP...");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
let shutdown_result = client
let _ = client
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
.send();
if shutdown_result.is_ok() {
println!("HTTP shutdown sent, waiting for graceful exit...");
// Wait up to 3 seconds for graceful shutdown
for i in 0..30 {
std::thread::sleep(std::time::Duration::from_millis(100));
if !is_process_running(pid) {
println!("Process exited gracefully after {}ms", i * 100);
return Ok(());
}
}
println!("Graceful shutdown timed out, forcing kill...");
} else {
println!("HTTP shutdown failed, forcing kill...");
}
// Layer 2: Kill process tree with enumeration
println!("Killing process tree for wrapper PID {}...", pid);
kill_windows_process_tree(pid)?;
// Layer 3: Verify and kill by name if still running
std::thread::sleep(std::time::Duration::from_millis(200));
if is_process_running(pid) {
println!("Process tree kill failed, killing by name...");
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
.output();
}
// Layer 4: Final verification
std::thread::sleep(std::time::Duration::from_millis(200));
if is_process_running(pid) {
eprintln!("WARNING: Failed to kill server after all attempts");
} else {
println!("Server killed successfully");
}
}
#[cfg(unix)]
{
println!("stop_server: Process group kill completed");
println!("Shutdown request sent (server watchdog will handle cleanup)");
}
}
@@ -662,6 +572,7 @@ async fn restart_server(
#[command]
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
println!("set_keep_server_running called with: {}", keep_running);
*state.keep_running_on_close.lock().unwrap() = keep_running;
}
@@ -798,9 +709,17 @@ pub fn run() {
play_audio_to_devices,
stop_audio_playback
])
.on_window_event(|window, event| {
.on_window_event({
let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
move |window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
// Prevent automatic close
// If we're already in the close flow, let it proceed
if closing.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
closing.store(true, std::sync::atomic::Ordering::SeqCst);
// Prevent automatic close so frontend can clean up
api.prevent_close();
// Emit event to frontend to check setting and stop server if needed
@@ -808,162 +727,83 @@ pub fn run() {
if let Err(e) = app_handle.emit("window-close-requested", ()) {
eprintln!("Failed to emit window-close-requested event: {}", e);
// If event emission fails, allow close anyway
window.close().ok();
return;
}
// Set up listener for frontend response
let window_for_close = window.clone();
let closing_for_timeout = closing.clone();
let (tx, mut rx) = mpsc::unbounded_channel::<()>();
// Listen for response from frontend using window's listen method
let listener_id = window.listen("window-close-allowed", move |_| {
// Frontend has checked setting and stopped server if needed
// Signal that we can close
let _ = tx.send(());
});
// Wait for frontend response or timeout
// Use tauri::async_runtime::spawn instead of tokio::spawn to avoid
// panics when the Tokio runtime is being dropped during app shutdown
tauri::async_runtime::spawn(async move {
tokio::select! {
_ = rx.recv() => {
// Frontend responded, close window
window_for_close.close().ok();
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
// Timeout - close anyway
eprintln!("Window close timeout, closing anyway");
window_for_close.close().ok();
}
}
// Clean up listener
window_for_close.unlisten(listener_id);
closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
});
}
})
}})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
let _ = &app; // used on unix
match &event {
RunEvent::Exit => {
println!("=================================================================");
println!("RunEvent::Exit received - checking server cleanup");
let state = app.state::<ServerState>();
let keep_running = *state.keep_running_on_close.lock().unwrap();
println!("keep_running_on_close = {}", keep_running);
if !keep_running {
// Get the stored PID for process group killing
let pid = state.server_pid.lock().unwrap().take();
// Also take the child to clean up
let _child = state.child.lock().unwrap().take();
if let Some(pid) = pid {
println!("Killing server process group with PID: {}", pid);
// Kill the entire process group on Unix systems
// Using negative PID sends signal to all processes in the group
#[cfg(unix)]
{
let has_pid = state.server_pid.lock().unwrap().is_some();
println!("RunEvent::Exit — keep_running={}, has_pid={}", keep_running, has_pid);
if keep_running {
// Tell the server to disable its watchdog so it survives
// after this process exits.
println!("Keep server running: disabling watchdog...");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
match client
.post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
.send()
{
Ok(resp) => println!("Watchdog disable response: {}", resp.status()),
Err(e) => eprintln!("Failed to disable watchdog: {}", e),
}
} else {
// Server will self-terminate via parent-pid watchdog when
// this process exits. On Unix, also send SIGTERM for
// immediate cleanup.
println!("RunEvent::Exit - server will self-terminate via watchdog");
#[cfg(unix)]
{
if let Some(pid) = state.server_pid.lock().unwrap().take() {
use std::process::Command;
// First try SIGTERM to the process group
let pgid_kill = Command::new("kill")
let _ = Command::new("kill")
.args(["-TERM", "--", &format!("-{}", pid)])
.output();
match pgid_kill {
Ok(output) => {
if output.status.success() {
println!("SIGTERM sent to process group -{}", pid);
} else {
// Process group kill failed, try direct kill
println!("Process group kill failed, trying direct kill");
let _ = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.output();
}
}
Err(e) => {
eprintln!("Failed to execute kill command: {}", e);
}
}
// Give it a moment, then force kill if needed
std::thread::sleep(std::time::Duration::from_millis(100));
// Force kill with SIGKILL
let _ = Command::new("kill")
.args(["-9", "--", &format!("-{}", pid)])
.output();
let _ = Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
println!("Server process group kill completed");
}
#[cfg(windows)]
{
// Layer 1: Try graceful HTTP shutdown first
println!("Attempting graceful shutdown via HTTP...");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
let shutdown_result = client
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
.send();
if shutdown_result.is_ok() {
println!("HTTP shutdown sent, waiting for graceful exit...");
// Wait up to 3 seconds for graceful shutdown
for i in 0..30 {
std::thread::sleep(std::time::Duration::from_millis(100));
if !is_process_running(pid) {
println!("Process exited gracefully after {}ms", i * 100);
println!("Server process tree kill completed");
return;
}
}
println!("Graceful shutdown timed out, forcing kill...");
} else {
println!("HTTP shutdown failed, forcing kill...");
}
// Layer 2: Kill process tree with enumeration
println!("Killing process tree for wrapper PID {}...", pid);
let _ = kill_windows_process_tree(pid);
// Layer 3: Verify and kill by name if still running
std::thread::sleep(std::time::Duration::from_millis(200));
if is_process_running(pid) {
println!("Process tree kill failed, killing by name...");
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
.output();
}
// Layer 4: Final verification
std::thread::sleep(std::time::Duration::from_millis(200));
if is_process_running(pid) {
eprintln!("WARNING: Failed to kill server after all attempts");
} else {
println!("Server killed successfully");
}
println!("Server process tree kill completed");
}
} else {
println!("No server PID found (already stopped or never started)");
}
} else {
println!("Keeping server running per user setting");
}
println!("=================================================================");
}
RunEvent::ExitRequested { api, .. } => {
println!("RunEvent::ExitRequested received");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox",
"version": "0.1.13",
"version": "0.2.1",
"identifier": "sh.voicebox.app",
"build": {
"beforeDevCommand": "bun run dev",
+16 -4
View File
@@ -1,4 +1,4 @@
import type { PlatformFilesystem, FileFilter } from '@/platform/types';
import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const tauriFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
@@ -12,9 +12,8 @@ export const tauriFilesystem: PlatformFilesystem = {
if (!filePath) return; // User cancelled the dialog
const resolvedPath = typeof filePath === 'string'
? filePath
: (filePath as { path: string }).path;
const resolvedPath =
typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
if (!resolvedPath) {
throw new Error('Failed to resolve save path from dialog');
@@ -23,4 +22,17 @@ export const tauriFilesystem: PlatformFilesystem = {
const arrayBuffer = await blob.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;
},
};
+6
View File
@@ -64,6 +64,12 @@ class TauriLifecycle implements PlatformLifecycle {
// @ts-expect-error - accessing module-level variable from another module
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
console.log(
'[lifecycle] window-close-requested: keepRunning=%s, serverStartedByApp=%s',
keepRunning,
serverStartedByApp,
);
if (!keepRunning && serverStartedByApp) {
// Stop server before closing (only if we started it)
try {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/web",
"private": true,
"version": "0.1.13",
"version": "0.2.1",
"type": "module",
"scripts": {
"dev": "vite",
+9 -1
View File
@@ -1,4 +1,4 @@
import type { PlatformFilesystem, FileFilter } from '@/platform/types';
import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const webFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
@@ -12,4 +12,12 @@ export const webFilesystem: PlatformFilesystem = {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
async openPath(_path: string) {
// No filesystem access in browser
},
async pickDirectory(_title: string) {
return null;
},
};