diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 37ef7924..151a8ac0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.12 +current_version = 0.1.13 commit = True tag = True tag_name = v{new_version} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1f1ea4be --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ + +# Build outputs +build/ +*.egg-info/ +.eggs/ +target/ + +# Keep web/dist for the Docker image +!web/dist + +# Development +.git/ +.github/ +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Data and logs +data/ +*.log +*.sqlite +*.db + +# OS +.DS_Store +Thumbs.db + +# Documentation +docs/ +landing/ +mlx-test/ + +# Test files +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx + +# Keep these out +.env +.env.local +*.pem +*.key +credentials.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e65f520..d102274c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,151 @@ on: tags: - "v*" +env: + PROVIDER_VERSION: "1.0.0" + jobs: + # ============================================ + # Build TTS Providers (uploaded to R2, not GitHub) + # ============================================ + build-providers: + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + # PyTorch CPU provider (Windows) + - platform: "windows-latest" + provider: "pytorch-cpu" + python-version: "3.12" + # PyTorch CUDA provider (Windows) - large binary, uploaded to R2 + - platform: "windows-latest" + provider: "pytorch-cuda" + python-version: "3.12" + # PyTorch CPU provider (Linux) + - platform: "ubuntu-22.04" + provider: "pytorch-cpu" + python-version: "3.12" + # PyTorch CUDA provider (Linux) - large binary, uploaded to R2 + - platform: "ubuntu-22.04" + provider: "pytorch-cuda" + python-version: "3.12" + # PyTorch CPU provider (macOS Apple Silicon) + - platform: "macos-latest" + provider: "pytorch-cpu" + python-version: "3.12" + # PyTorch CPU provider (macOS Intel) + - platform: "macos-15-intel" + provider: "pytorch-cpu" + python-version: "3.12" + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y llvm-dev + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install CPU-only torch (Linux) + if: matrix.provider == 'pytorch-cpu' && matrix.platform == 'ubuntu-22.04' + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu + pip install -r providers/pytorch-cpu/requirements.txt + pip install -r backend/requirements.txt + + - name: Install Python dependencies (CPU - non-Linux) + if: matrix.provider == 'pytorch-cpu' && matrix.platform != 'ubuntu-22.04' + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -r providers/pytorch-cpu/requirements.txt + pip install -r backend/requirements.txt + + - name: Install Python dependencies (CUDA) + if: matrix.provider == 'pytorch-cuda' + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 + pip install -r providers/pytorch-cuda/requirements.txt + pip install -r backend/requirements.txt + + - name: Build provider binary + shell: bash + run: | + cd providers/${{ matrix.provider }} + python build.py + + - name: Package provider for distribution + shell: bash + run: | + cd providers/${{ matrix.provider }}/dist + + # Add platform suffix for archive name + if [ "${{ matrix.platform }}" == "windows-latest" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip" + # On Windows, zip the directory + powershell Compress-Archive -Path "tts-provider-${{ matrix.provider }}/*" -DestinationPath "$ARCHIVE_NAME" + elif [ "${{ matrix.platform }}" == "macos-latest" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz" + tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/ + elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz" + tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/ + else + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz" + tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/ + fi + + echo "Created archive: $ARCHIVE_NAME" + ls -lh "$ARCHIVE_NAME" + + - name: Upload provider to R2 + shell: bash + env: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + # Install AWS CLI (compatible with R2) + pip install awscli + + # Configure AWS CLI for R2 + aws configure set aws_access_key_id $R2_ACCESS_KEY_ID + aws configure set aws_secret_access_key $R2_SECRET_ACCESS_KEY + aws configure set region auto + + # Determine archive name based on platform + if [ "${{ matrix.platform }}" == "windows-latest" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip" + elif [ "${{ matrix.platform }}" == "macos-latest" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz" + elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz" + else + ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz" + fi + + # Upload to R2 (bucket: voicebox) + aws s3 cp "providers/${{ matrix.provider }}/dist/$ARCHIVE_NAME" \ + "s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$ARCHIVE_NAME" \ + --endpoint-url "$R2_ENDPOINT" + + echo "Uploaded $ARCHIVE_NAME to R2" + + # ============================================ + # Build Main App (without bundled TTS on Win/Linux) + # ============================================ release: permissions: contents: write @@ -14,18 +158,22 @@ jobs: fail-fast: false matrix: include: + # macOS Apple Silicon - MLX bundled (works out of the box) - platform: "macos-latest" args: "--target aarch64-apple-darwin" python-version: "3.12" backend: "mlx" + # macOS Intel - PyTorch bundled (smaller user base, keep simple) - platform: "macos-15-intel" args: "--target x86_64-apple-darwin" python-version: "3.12" backend: "pytorch" - # - platform: 'ubuntu-22.04' - # args: '' - # python-version: '3.12' - # backend: 'pytorch' + # Linux - No TTS bundled, providers downloaded separately + - platform: "ubuntu-22.04" + args: "" + python-version: "3.12" + backend: "none" + # Windows - PyTorch CPU bundled (works out of the box) - platform: "windows-latest" args: "" python-version: "3.12" @@ -40,7 +188,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' 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,23 +203,27 @@ jobs: python-version: ${{ matrix.python-version }} cache: "pip" - - name: Install Python dependencies + - name: Install Python dependencies (with TTS) + if: matrix.backend != 'none' run: | python -m pip install --upgrade pip pip install pyinstaller pip install -r backend/requirements.txt + - name: Install Python dependencies (without TTS) + if: matrix.backend == 'none' + run: | + python -m pip install --upgrade pip + pip install pyinstaller + # Install base requirements without PyTorch/Qwen-TTS + pip install fastapi uvicorn sqlalchemy librosa soundfile numpy httpx + pip install huggingface_hub # For Whisper downloads + - name: Install MLX dependencies (Apple Silicon only) if: matrix.backend == 'mlx' 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: | @@ -148,13 +300,84 @@ jobs: See the assets below to download and install this version. ### Installation - - **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference + - **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference (works out of the box) - **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch - - **Windows**: Download the `.msi` installer - - **Linux**: Download the `.AppImage` or `.deb` package + - **Windows**: Download the `.msi` installer - requires downloading a TTS provider on first use + - **Linux**: Download the `.AppImage` or `.deb` package - requires downloading a TTS provider on first use + + ### TTS Providers + Windows and Linux users will be prompted to download a TTS provider on first launch: + - **Windows**: PyTorch CPU (~300MB) or PyTorch CUDA (~2.4GB for NVIDIA GPUs) + - **Linux**: PyTorch CUDA (~2.4GB) - requires NVIDIA GPU The app includes automatic updates - future updates will be installed automatically. releaseDraft: true prerelease: false args: ${{ matrix.args }} includeUpdaterJson: true + + # ============================================ + # Build and Push Docker Images + # ============================================ + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies and build web UI + run: | + bun install + cd web + bun run build + + - name: Extract version from tag + id: version + run: | + if [[ $GITHUB_REF == refs/tags/v* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION="dev" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build and push CPU image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/jamiepine/voicebox:latest + ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push CUDA image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.cuda + platforms: linux/amd64 + push: true + tags: | + ghcr.io/jamiepine/voicebox:latest-cuda + ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}-cuda + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 05f7ef0d..4fef6ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ build/ *.egg-info/ *.egg +*.spec target/ *.app *.dmg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 765da827..45d898e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,16 +14,19 @@ Thank you for your interest in contributing to Voicebox! This document provides ### Prerequisites - **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager + ```bash curl -fsSL https://bun.sh/install | bash ``` - **[Python 3.11+](https://python.org)** - For backend development + ```bash python --version # Should be 3.11 or higher ``` - **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI) + ```bash rustc --version # Check if installed ``` @@ -37,41 +40,46 @@ Thank you for your interest in contributing to Voicebox! This document provides **Manual setup (required for Windows):** 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 ``` @@ -81,19 +89,24 @@ Thank you for your interest in contributing to Voicebox! This document provides 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 @@ -104,26 +117,133 @@ Thank you for your interest in contributing to Voicebox! This document provides > 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` ### Model Downloads Models are automatically downloaded from HuggingFace Hub on first use: + - **Whisper** (transcription): Auto-downloads on first transcription - **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB) First-time usage will be slower due to model downloads, but subsequent runs will use cached models. +### TTS Provider Development + +Voicebox uses a modular provider system to support different inference backends. Understanding this architecture is important when working on TTS features. + +#### Provider Types + +**Bundled Providers** — Included with the app binary: + +- `apple-mlx` — Bundled with macOS Apple Silicon builds (`.dmg` for aarch64) + - Uses MLX for native Metal acceleration + - Configured in `.github/workflows/release.yml` with `backend: "mlx"` + +**Hybrid Provider:** + +- `pytorch-cpu` — Can be bundled OR downloaded depending on platform + - **Bundled** with Windows and macOS Intel builds + - macOS Intel: `.dmg` for x64 with `backend: "pytorch"` + - Windows: `.exe` installer with PyTorch CPU included + - **Downloaded** on first use for Linux builds (~300MB) + - Falls back to bundled version if external binary not found + +**External-Only Providers:** + +- `pytorch-cuda` — NVIDIA GPU-accelerated provider (~2.4GB) + - Windows/Linux only (no NVIDIA GPUs on macOS) + - Downloaded on demand, not bundled + - Optional for users with CUDA-capable GPUs + +#### Provider Architecture + +``` +backend/providers/ +├── __init__.py # ProviderManager - lifecycle management +├── base.py # TTSProvider protocol +├── bundled.py # BundledProvider - wraps built-in backends +├── local.py # LocalProvider - wraps external subprocess +├── installer.py # Download and install external providers +└── types.py # Provider type definitions + +providers/ +├── pytorch-cpu/ # External PyTorch CPU provider +│ ├── main.py # FastAPI server +│ ├── build.py # PyInstaller build script +│ └── build_and_install.py # Build and install locally +└── pytorch-cuda/ # External PyTorch CUDA provider + ├── main.py + ├── build.py + └── build_and_install.py +``` + +**How it works:** + +1. **Bundled providers** run in-process within the main backend +2. **External providers** run as separate subprocess servers +3. **LocalProvider** communicates with external providers via HTTP +4. **ProviderManager** handles starting/stopping and health checks + +#### Building Providers Locally + +When developing provider features, you'll need to build and test external providers: + +**Build a single provider:** + +```bash +cd providers/pytorch-cpu +python build_and_install.py +``` + +**Build all providers:** + +```bash +bun run build:providers +``` + +This script: + +- Builds the provider binary with PyInstaller +- Detects your platform (Windows/macOS/Linux) +- Copies to the correct location: + - macOS: `~/Library/Application Support/voicebox/providers/` + - Windows: `%APPDATA%\voicebox\providers\` + - Linux: `~/.local/share/voicebox/providers/` +- Sets executable permissions on Unix + +**Testing provider changes:** + +1. Make changes to `providers/pytorch-cpu/main.py` +2. Run `bun run build:providers` +3. Restart the Voicebox app +4. Select the provider in Settings → TTS Provider + +#### Provider Binary Distribution + +For production releases, provider binaries are: + +1. Built by GitHub Actions for all platforms +2. Uploaded to Cloudflare R2 at `downloads.voicebox.sh/providers/v{VERSION}/` +3. Downloaded on-demand by users based on their platform and GPU + +See `.github/workflows/release.yml` for the build matrix. + ### Building **Build everything (recommended):** + ```bash bun run build ``` + This automatically: + 1. Builds the Python server binary (`./scripts/build-server.sh`) 2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`) @@ -132,13 +252,23 @@ Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src **Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others). **Build server binary only:** + ```bash bun run build:server # or ./scripts/build-server.sh ``` + Creates platform-specific binary in `tauri/src-tauri/binaries/` +**Build provider binaries (for development):** + +```bash +bun run build:providers +``` + +Builds all external provider binaries and installs them to the system provider directory. See [TTS Provider Development](#tts-provider-development) for details. + **Building with local Qwen3-TTS development version:** If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone: @@ -151,34 +281,41 @@ bun run 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/` ### Generate OpenAPI Client After starting the backend server: + ```bash ./scripts/generate-api.sh ``` + This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/` ### Convert Assets to Web Formats To optimize images and videos for the web, run: + ```bash bun run convert:assets ``` This script: + - Converts PNG → WebP (better compression, same quality) - Converts MOV → WebM (VP9 codec, smaller file size) - Processes files in `landing/public/` and `docs/public/` - **Deletes original files** after successful conversion **Requirements:** Install `webp` and `ffmpeg`: + ```bash brew install webp ffmpeg ``` @@ -225,6 +362,7 @@ git push origin feature/your-feature-name ``` Then create a pull request on GitHub with: + - Clear description of changes - Screenshots (for UI changes) - Reference to related issues @@ -370,21 +508,23 @@ Currently, testing is primarily manual. When adding tests: Releases are managed by maintainers: 1. **Bump version using bumpversion:** + ```bash # Install bumpversion (if not already installed) pip install bumpversion - + # Bump patch version (0.1.0 -> 0.1.1) bumpversion patch - + # Or bump minor version (0.1.0 -> 0.2.0) bumpversion minor - + # Or bump major version (0.1.0 -> 1.0.0) bumpversion major ``` - + This automatically: + - Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`) - Creates a git commit with the version bump - Creates a git tag (e.g., `v0.1.1`, `v0.2.0`) @@ -392,6 +532,7 @@ Releases are managed by maintainers: 2. **Update CHANGELOG.md** with release notes 3. **Push commits and tags:** + ```bash git push git push --tags diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ca243bc5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Base Dockerfile for Voicebox (CPU-only) +# For GPU support, use Dockerfile.cuda + +FROM python:3.12-slim + +# Prevent interactive prompts during build +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + curl \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Copy backend +COPY backend/ /app/backend/ +COPY providers/ /app/providers/ + +# Copy pre-built web UI +COPY web/dist/ /app/web/dist/ + +# Install Python dependencies (without PyTorch - will be downloaded via provider system) +RUN python -m pip install --upgrade pip && \ + pip install --no-cache-dir \ + fastapi uvicorn[standard] pydantic sqlalchemy alembic \ + librosa soundfile numpy python-multipart Pillow \ + huggingface_hub transformers accelerate + +# Create data directory for profiles/generations +RUN mkdir -p /app/data + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD curl -f http://localhost:8000/health || exit 1 + +EXPOSE 8000 + +# Run server with web UI +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.cuda b/Dockerfile.cuda new file mode 100644 index 00000000..b7d7ca96 --- /dev/null +++ b/Dockerfile.cuda @@ -0,0 +1,58 @@ +# Dockerfile for Voicebox with NVIDIA GPU support (CUDA) + +FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04 + +# Prevent interactive prompts during build +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +WORKDIR /app + +# Install Python 3.12 +RUN apt-get update && apt-get install -y \ + software-properties-common \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update && apt-get install -y \ + python3.12 \ + python3.12-dev \ + python3.12-venv \ + ffmpeg \ + curl \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Set Python 3.12 as default and bootstrap pip +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \ + update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \ + python3.12 -m ensurepip --upgrade && \ + python3.12 -m pip install --upgrade pip + +# Copy backend +COPY backend/ /app/backend/ +COPY providers/ /app/providers/ + +# Copy pre-built web UI +COPY web/dist/ /app/web/dist/ + +# Install PyTorch with CUDA support first +RUN pip install --no-cache-dir \ + torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 + +# Install remaining dependencies +RUN pip install --no-cache-dir \ + fastapi uvicorn[standard] pydantic sqlalchemy alembic \ + transformers accelerate huggingface_hub \ + librosa soundfile numpy python-multipart Pillow \ + qwen-tts + +# Create data directory +RUN mkdir -p /app/data + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD curl -f http://localhost:8000/health || exit 1 + +EXPOSE 8000 + +# Run server with web UI +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 575918cf..9d0d39dd 100644 --- a/README.md +++ b/README.md @@ -76,16 +76,39 @@ Download a voice model, clone any voice from a few seconds of audio, and compose ## Download -Voicebox is available now for macOS and Windows. +### Desktop App -| Platform | Download | -|----------|----------| -| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) | -| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) | -| Windows (MSI) | [voicebox_0.1.0_x64_en-US.msi](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64_en-US.msi) | -| Windows (Setup) | [voicebox_0.1.0_x64-setup.exe](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_0.1.0_x64-setup.exe) | +| Platform | Download | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| macOS (Apple Silicon) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | +| macOS (Intel) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | +| Windows (MSI) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | +| Windows (Setup) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | +| Linux (AppImage) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | +| Linux (Deb) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) | -> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations. +### Docker + +Run Voicebox with the web UI in Docker - perfect for servers and headless deployments: + +```bash +# CPU-only (supports amd64 and arm64) +docker run -p 8000:8000 -v voicebox-data:/app/data \ + ghcr.io/jamiepine/voicebox:latest + +# NVIDIA GPU (recommended for performance) +docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \ + ghcr.io/jamiepine/voicebox:latest-cuda +``` + +Or use Docker Compose: +```bash +docker compose up -d +``` + +Open http://localhost:8000 to access the web UI. + +See [Docker Deployment Guide](docs/overview/docker.mdx) for cloud deployments, GPU setup, and more. --- @@ -137,9 +160,10 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based ### Flexible Deployment -- **Local mode** — Everything runs on your machine -- **Remote mode** — Connect to a GPU server on your network -- **One-click server** — Turn any machine into a Voicebox server +- **Desktop app** — Native apps for macOS, Windows, and Linux +- **Docker** — Deploy to servers with the web UI included +- **Remote mode** — Connect desktop app to a remote GPU server +- **Cloud ready** — Deploy to AWS, GCP, DigitalOcean, or any cloud provider --- @@ -176,17 +200,17 @@ Full API documentation available at `http://localhost:8000/docs` when running. ## Tech Stack -| Layer | Technology | -|-------|------------| -| Desktop App | Tauri (Rust) | -| Frontend | React, TypeScript, Tailwind CSS | -| State | Zustand, React Query | -| Backend | FastAPI (Python) | -| Voice Model | Qwen3-TTS (PyTorch or MLX) | -| Transcription | Whisper (PyTorch or MLX) | +| Layer | Technology | +| ---------------- | --------------------------------------------------- | +| Desktop App | Tauri (Rust) | +| Frontend | React, TypeScript, Tailwind CSS | +| State | Zustand, React Query | +| Backend | FastAPI (Python) | +| Voice Model | Qwen3-TTS (PyTorch or MLX) | +| Transcription | Whisper (PyTorch or MLX) | | Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) | -| Database | SQLite | -| Audio | WaveSurfer.js, librosa | +| Database | SQLite | +| Audio | WaveSurfer.js, librosa | **Why this stack?** @@ -194,6 +218,26 @@ Full API documentation available at `http://localhost:8000/docs` when running. - **FastAPI** — Async Python with automatic OpenAPI schema generation - **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec +### TTS Provider Architecture + +Voicebox uses a modular provider system to support different inference backends: + +- **`apple-mlx`** — Bundled with macOS Apple Silicon builds + + - Uses MLX with native Metal acceleration (4-5x faster) + - Works out of the box, no download required + +- **`pytorch-cpu`** — Universal CPU provider (bundled or downloaded) + + - Bundled with Windows and macOS Intel builds + - Downloaded on first use for Linux (~300MB) + +- **`pytorch-cuda`** — Optional NVIDIA GPU-accelerated provider + - Windows/Linux only (~2.4GB) + - 4-5x faster inference on CUDA-capable GPUs + +macOS and Windows builds work out of the box with bundled providers. Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery. + --- ## Roadmap @@ -202,13 +246,13 @@ Voicebox is the beginning of something bigger. Here's what's coming: ### Coming Soon -| Feature | Description | -|---------|-------------| -| **Real-time Synthesis** | Stream audio as it generates, word by word | -| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking | -| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects | -| **Timeline Editor** | Audio studio with word-level precision editing | -| **More Models** | XTTS, Bark, and other open-source voice models | +| Feature | Description | +| ----------------------- | -------------------------------------------------- | +| **Real-time Synthesis** | Stream audio as it generates, word by word | +| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking | +| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects | +| **Timeline Editor** | Audio studio with word-level precision editing | +| **More Models** | XTTS, Bark, and other open-source voice models | ### Future Vision @@ -260,9 +304,10 @@ cd backend && pip install -r requirements.txt && cd .. bun run dev ``` -**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org). +**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org). + +**Performance:** -**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) diff --git a/app/package.json b/app/package.json index 905dea23..b9ef942a 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@voicebox/app", - "version": "0.1.12", + "version": "0.1.13", "private": true, "type": "module", "scripts": { @@ -17,6 +17,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.0", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@iconify-json/svg-spinners": "^1.2.4", + "@iconify/react": "^6.0.2", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -24,6 +28,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", @@ -43,7 +48,6 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "framer-motion": "^12.29.0", - "lucide-react": "^0.454.0", "motion": "^12.29.0", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index fbe29118..e54bc9dd 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -40,7 +40,7 @@ function App() { const serverStartingRef = useRef(false); // Automatically check for app updates on startup and show toast notifications - useAutoUpdater({ checkOnMount: true, showToast: true }); + useAutoUpdater(true); // Sync stored setting to Rust on startup useEffect(() => { @@ -82,8 +82,7 @@ function App() { console.log('Dev mode: Skipping auto-start of server (run it separately)'); setServerReady(true); // Mark as ready so UI doesn't show loading screen // Mark that server was not started by app (so we don't try to stop it on close) - // @ts-expect-error - adding property to window - window.__voiceboxServerStartedByApp = false; + (window as any).__voiceboxServerStartedByApp = false; return; } @@ -103,14 +102,12 @@ function App() { useServerStore.getState().setServerUrl(serverUrl); setServerReady(true); // Mark that we started the server (so we know to stop it on close) - // @ts-expect-error - adding property to window - window.__voiceboxServerStartedByApp = true; + (window as any).__voiceboxServerStartedByApp = true; }) .catch((error) => { console.error('Failed to auto-start server:', error); serverStartingRef.current = false; - // @ts-expect-error - adding property to window - window.__voiceboxServerStartedByApp = false; + (window as any).__voiceboxServerStartedByApp = false; }); // Cleanup: stop server on actual unmount (not StrictMode remount) diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 48dd9e78..6656c9d3 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { PauseIcon, PlayIcon, RepeatIcon, VolumeHighIcon, VolumeMuteIcon, Cancel01Icon } from '@hugeicons/core-free-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; @@ -459,7 +460,7 @@ export function AudioPlayer() { // Use double requestAnimationFrame to ensure DOM is fully rendered let rafId1: number; let rafId2: number; - let timeoutId: number | null = null; + let timeoutId: ReturnType | null = null; rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { @@ -832,7 +833,7 @@ export function AudioPlayer() { className="shrink-0" title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} > - {isPlaying ? : } + {isPlaying ? : } {/* Waveform */} @@ -873,7 +874,7 @@ export function AudioPlayer() { className={isLooping ? 'text-primary' : ''} title="Toggle loop" > - + {/* Volume Control */} @@ -884,7 +885,7 @@ export function AudioPlayer() { onClick={() => setVolume(volume > 0 ? 0 : 1)} className="h-8 w-8" > - {volume > 0 ? : } + {volume > 0 ? : } - + diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx index f76e99d7..150e7660 100644 --- a/app/src/components/AudioTab/AudioTab.tsx +++ b/app/src/components/AudioTab/AudioTab.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { CheckmarkCircle01Icon, CheckmarkCircle02Icon, Edit01Icon, Add01Icon, SpeakerIcon, Delete01Icon } from '@hugeicons/core-free-icons'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -135,7 +136,7 @@ export function AudioTab() {

Audio Channels

@@ -150,13 +151,13 @@ export function AudioTab() { > {allChannels.length === 0 ? (
- +

No audio channels yet. Create your first channel to route voices to specific devices.

@@ -178,7 +179,7 @@ export function AudioTab() {
- +

{channel.name}

@@ -235,7 +236,7 @@ export function AudioTab() { setEditingChannel(channel.id); }} > - +
)} @@ -325,10 +326,10 @@ export function AudioTab() { isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30', )} > - {isConnected && } + {isConnected && }
) : device.is_default ? ( - + ) : null} {device.name} @@ -339,7 +340,7 @@ export function AudioTab() {
) : (
- +

{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}

@@ -494,7 +495,7 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh setSelectedDevices(selectedDevices.filter((id) => id !== deviceId)) } > - +
); @@ -602,7 +603,7 @@ function EditChannelDialog({ setSelectedDevices(selectedDevices.filter((id) => id !== deviceId)) } > - + ); @@ -648,7 +649,7 @@ function EditChannelDialog({ setSelectedVoices(selectedVoices.filter((id) => id !== profileId)) } > - + ); diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index a8d556a6..d8d493bd 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -1,6 +1,8 @@ +import { SparklesIcon, TextSquareIcon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; 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 { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; @@ -298,13 +300,13 @@ export function FloatingGenerateBox({ @@ -337,7 +339,7 @@ export function FloatingGenerateBox({ : 'bg-card border border-border hover:bg-background/50', )} > - + Fine tune instructions diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index 31b100f8..d652a1d7 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -1,4 +1,6 @@ -import { Loader2, Mic } from 'lucide-react'; +import { Mic01Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -46,7 +48,11 @@ export function GenerationForm() { Voice Profile {selectedProfile ? (
- + {selectedProfile.name} {selectedProfile.language}
@@ -170,14 +176,10 @@ export function GenerationForm() { /> - handlePlay(gen.id, gen.text, gen.profile_id)} > - + Play handleDownloadAudio(gen.id, gen.text)} disabled={exportGenerationAudio.isPending} > - + Export Audio handleExportPackage(gen.id, gen.text)} disabled={exportGeneration.isPending} > - + Export Package handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} - className="text-destructive focus:text-destructive" > - + Delete @@ -352,7 +361,12 @@ export function HistoryTable() { {/* Load more trigger element */} {hasMore && (
- {isFetching && } + {isFetching && ( + + )}
)} @@ -371,7 +385,8 @@ export function HistoryTable() { Delete Generation - Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone. + Are you sure you want to delete this generation from "{generationToDelete?.name}"? + This action cannot be undone. diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index 9d597b1e..17125893 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -1,4 +1,5 @@ -import { Sparkles, Upload } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { SparklesIcon, Upload01Icon } from '@hugeicons/core-free-icons'; import { useRef, useState } from 'react'; import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; import { HistoryTable } from '@/components/History/HistoryTable'; @@ -89,7 +90,7 @@ export function MainEditor() {

Voicebox

diff --git a/app/src/components/ServerSettings/DataFolders.tsx b/app/src/components/ServerSettings/DataFolders.tsx new file mode 100644 index 00000000..9338da09 --- /dev/null +++ b/app/src/components/ServerSettings/DataFolders.tsx @@ -0,0 +1,112 @@ +import { Folder01Icon, FolderOpenIcon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { useSystemFolders } from '@/lib/hooks/useSystemFolders'; +import { usePlatform } from '@/platform/PlatformContext'; + +interface FolderRowProps { + label: string; + description: string; + path: string | undefined; + isLoading: boolean; + canOpen: boolean; + onOpen: () => void; +} + +function FolderRow({ label, description, path, isLoading, canOpen, onOpen }: FolderRowProps) { + return ( +
+
+
+
{label}
+
{description}
+
+ {canOpen && path && ( + + )} +
+ +
+ ); +} + +export function DataFolders() { + const { data: folders, isLoading, error } = useSystemFolders(); + const platform = usePlatform(); + const isTauri = platform.metadata.isTauri; + + const handleOpenFolder = async (path: string | undefined) => { + if (!path) return; + const success = await platform.filesystem.openFolder(path); + if (!success && isTauri) { + console.error('Failed to open folder:', path); + } + }; + + return ( + + + + + Data Folders + + + {isTauri + ? 'Click "Open" to view folders in your file explorer, or copy the paths below.' + : 'These are the server-side folder paths where your data is stored.'} + + + + {error ? ( +
+ + Failed to load folder paths: {error.message} +
+ ) : ( + <> + handleOpenFolder(folders?.data_dir)} + /> + handleOpenFolder(folders?.models_dir)} + /> + handleOpenFolder(folders?.providers_dir)} + /> + + )} +
+
+ ); +} diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 4a5fd439..b1c7c123 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -1,5 +1,7 @@ +import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Download, Loader2, Trash2 } from 'lucide-react'; import { useCallback, useState } from 'react'; import { AlertDialog, @@ -67,11 +69,11 @@ export function ModelManagement() { const handleDownload = async (modelName: string) => { console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString()); - + // Find display name const model = modelStatus?.models.find((m) => m.model_name === modelName); const displayName = model?.display_name || modelName; - + try { // IMPORTANT: Call the API FIRST before setting state // Setting state enables the SSE EventSource in useModelDownloadToast, @@ -79,11 +81,11 @@ export function ModelManagement() { console.log('[Download] Calling download API for:', modelName); const result = await apiClient.triggerModelDownload(modelName); console.log('[Download] Download API responded:', result); - + // NOW set state to enable SSE tracking (after download has started on backend) setDownloadingModel(modelName); setDownloadingDisplayName(displayName); - + // Download initiated successfully - state will be cleared when SSE reports completion // or by the polling interval detecting the model is downloaded queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); @@ -117,7 +119,7 @@ export function ModelManagement() { // Invalidate AND explicitly refetch to ensure UI updates // Using refetchType: 'all' ensures we refetch even if the query is stale console.log('[Delete] Invalidating modelStatus query'); - await queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all', }); @@ -153,7 +155,10 @@ export function ModelManagement() { {isLoading ? (
- +
) : modelStatus ? (
@@ -212,7 +217,6 @@ export function ModelManagement() { ))}
- ) : null}
@@ -246,7 +250,7 @@ export function ModelManagement() { > {deleteMutation.isPending ? ( <> - + Deleting... ) : ( @@ -265,20 +269,20 @@ interface ModelItemProps { model_name: string; display_name: string; downloaded: boolean; - downloading?: boolean; // From server - true if download in progress + downloading?: boolean; // From server - true if download in progress size_mb?: number; loaded: boolean; }; onDownload: () => void; onDelete: () => void; - isDownloading: boolean; // Local state - true if user just clicked download + isDownloading: boolean; // Local state - true if user just clicked download formatSize: (sizeMb?: number) => string; } function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) { // Use server's downloading state OR local state (for immediate feedback before server updates) const showDownloading = model.downloading || isDownloading; - + return (
@@ -315,17 +319,17 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M disabled={model.loaded} title={model.loaded ? 'Unload model before deleting' : 'Delete model'} > - +
) : showDownloading ? ( ) : ( )} diff --git a/app/src/components/ServerSettings/ModelProgress.tsx b/app/src/components/ServerSettings/ModelProgress.tsx index 76aa99f1..efd35427 100644 --- a/app/src/components/ServerSettings/ModelProgress.tsx +++ b/app/src/components/ServerSettings/ModelProgress.tsx @@ -1,4 +1,6 @@ -import { Loader2, XCircle } from 'lucide-react'; +import { CancelCircleIcon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; @@ -12,7 +14,11 @@ interface ModelProgressProps { isDownloading?: boolean; } -export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) { +export function ModelProgress({ + modelName, + displayName, + isDownloading = false, +}: ModelProgressProps) { const [progress, setProgress] = useState(null); const serverUrl = useServerStore((state) => state.serverUrl); @@ -74,10 +80,12 @@ export function ModelProgress({ modelName, displayName, isDownloading = false }: const getStatusIcon = () => { switch (progress.status) { case 'error': - return ; + return ( + + ); case 'downloading': case 'extracting': - return ; + return ; default: return null; } diff --git a/app/src/components/ServerSettings/ProviderSettings.tsx b/app/src/components/ServerSettings/ProviderSettings.tsx new file mode 100644 index 00000000..7cd68270 --- /dev/null +++ b/app/src/components/ServerSettings/ProviderSettings.tsx @@ -0,0 +1,400 @@ +import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useToast } from '@/components/ui/use-toast'; +import { apiClient } from '@/lib/api/client'; +import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; + +const isMacOS = () => navigator.platform.toLowerCase().includes('mac'); +const isWindows = () => navigator.platform.toLowerCase().includes('win'); +const getPlatformName = () => { + if (isMacOS()) return 'macOS'; + if (isWindows()) return 'Windows'; + return 'Linux'; +}; + +type ProviderType = + | 'auto' + | 'apple-mlx' + | 'bundled-pytorch' + | 'pytorch-cpu' + | 'pytorch-cuda' + | 'remote' + | 'openai'; + +export function ProviderSettings() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [downloadingProvider, setDownloadingProvider] = useState(null); + + const { data: providersData, isLoading } = useQuery({ + queryKey: ['providers'], + queryFn: async () => { + return await apiClient.listProviders(); + }, + refetchInterval: 5000, + }); + + const { data: activeProvider } = useQuery({ + queryKey: ['activeProvider'], + queryFn: async () => { + return await apiClient.getActiveProvider(); + }, + refetchInterval: 5000, + }); + + // Callbacks for download completion + const handleDownloadComplete = useCallback(() => { + setDownloadingProvider(null); + queryClient.invalidateQueries({ queryKey: ['providers'] }); + }, [queryClient]); + + const handleDownloadError = useCallback(() => { + setDownloadingProvider(null); + }, []); + + // Use progress toast hook for the downloading provider + useModelDownloadToast({ + modelName: downloadingProvider || '', + displayName: downloadingProvider || '', + enabled: !!downloadingProvider, + onComplete: handleDownloadComplete, + onError: handleDownloadError, + }); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [providerToDelete, setProviderToDelete] = useState(null); + + const downloadMutation = useMutation({ + mutationFn: async (providerType: string) => { + return await apiClient.downloadProvider(providerType); + }, + onSuccess: (_, providerType) => { + setDownloadingProvider(providerType); + queryClient.invalidateQueries({ queryKey: ['providers'] }); + }, + onError: (error: Error) => { + toast({ + title: 'Download failed', + description: error.message, + variant: 'destructive', + }); + }, + }); + + const startMutation = useMutation({ + mutationFn: async (providerType: string) => { + return await apiClient.startProvider(providerType); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['activeProvider'] }); + toast({ + title: 'Provider started', + description: 'The provider has been started successfully', + }); + }, + onError: (error: Error) => { + toast({ + title: 'Failed to start provider', + description: error.message, + variant: 'destructive', + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (providerType: string) => { + return await apiClient.deleteProvider(providerType); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['providers'] }); + toast({ + title: 'Provider deleted', + description: 'The provider has been deleted successfully', + }); + }, + onError: (error: Error) => { + toast({ + title: 'Failed to delete provider', + description: error.message, + variant: 'destructive', + }); + }, + }); + + const handleDownload = async (providerType: string) => { + downloadMutation.mutate(providerType); + }; + + const handleStart = async (providerType: string) => { + startMutation.mutate(providerType); + }; + + const handleDelete = (providerType: string) => { + setProviderToDelete(providerType); + setDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (providerToDelete) { + deleteMutation.mutate(providerToDelete); + setDeleteDialogOpen(false); + setProviderToDelete(null); + } + }; + + if (isLoading) { + return ( + + + TTS Provider + Choose how Voicebox generates speech + + +
+ +
+
+
+ ); + } + + const installedProviders = providersData?.installed || []; + + // Determine current active provider + const currentProvider = activeProvider?.provider; + console.log('currentProvider', currentProvider); + const selectedProvider = currentProvider as ProviderType; + + const isStarting = startMutation.isPending; + + return ( + <> + + + TTS Provider + Choose how Voicebox generates speech. + + + {isStarting && ( +
+
+ + Starting provider... +
+
+ )} + handleStart(value)} + disabled={isStarting} + > + {/* PyTorch CUDA */} +
+
+ + +
+
+ {isMacOS() && ( + <> + 2.4GB + + + )} + {!isMacOS() && !installedProviders.includes('pytorch-cuda') && ( + <> + 2.4GB + + + )} + {installedProviders.includes('pytorch-cuda') && ( + + )} +
+
+ + {/* PyTorch CPU */} +
+
+ + +
+
+ {!installedProviders.includes('pytorch-cpu') && ( + <> + 242MB + + + )} + {installedProviders.includes('pytorch-cpu') && ( + + )} +
+
+ + {/* MLX bundled (macOS Apple Silicon only) */} +
+
+ + +
+ {!isMacOS() && ( + + )} +
+ + {/* Remote */} +
+
+ + +
+ +
+ + {/* OpenAI */} +
+
+ + +
+ +
+
+

+ Note: PyTorch and MLX use different versions of the same model. When switching between + them, you will need to redownload the model. +

+
+
+ + + + + Delete Provider + + Are you sure you want to delete {providerToDelete}? This will remove the provider + binary from your system. You can download it again later if needed. + + + + Cancel + + Delete + + + + + + ); +} diff --git a/app/src/components/ServerSettings/ServerStatus.tsx b/app/src/components/ServerSettings/ServerStatus.tsx index 02a94ec2..8437552e 100644 --- a/app/src/components/ServerSettings/ServerStatus.tsx +++ b/app/src/components/ServerSettings/ServerStatus.tsx @@ -1,4 +1,6 @@ -import { Loader2, XCircle } from 'lucide-react'; +import { CancelCircleIcon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useServerHealth } from '@/lib/hooks/useServer'; @@ -32,12 +34,12 @@ export function ServerStatus() { {isLoading ? (
- + Checking connection...
) : error ? (
- + Connection failed: {error.message}
) : health ? ( diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx index a3d832aa..5cab320e 100644 --- a/app/src/components/ServerSettings/UpdateStatus.tsx +++ b/app/src/components/ServerSettings/UpdateStatus.tsx @@ -1,4 +1,5 @@ -import { AlertCircle, Download, RefreshCw } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { AlertCircleIcon, Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons'; import { useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -36,21 +37,21 @@ export function UpdateStatus() { variant="outline" size="sm" > - + Check for Updates
{status.checking && (
- + Checking for updates...
)} {status.error && (
- + {status.error}
)} @@ -65,7 +66,7 @@ export function UpdateStatus() { New @@ -75,7 +76,7 @@ export function UpdateStatus() {
- + Downloading update...
{status.downloadProgress !== undefined && ( @@ -109,7 +110,7 @@ export function UpdateStatus() { your convenience.
diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx index abf91ac2..69fc7117 100644 --- a/app/src/components/ServerTab/ServerTab.tsx +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -1,4 +1,6 @@ import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; +import { DataFolders } from '@/components/ServerSettings/DataFolders'; +import { ProviderSettings } from '@/components/ServerSettings/ProviderSettings'; import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import { usePlatform } from '@/platform/PlatformContext'; @@ -11,6 +13,8 @@ export function ServerTab() { + + {platform.metadata.isTauri && }
Created by{' '} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index a849344f..39314788 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,5 +1,14 @@ +import { + Book01Icon, + Mic01Icon, + PackageIcon, + ServerStack01Icon, + SpeakerIcon, + VolumeHighIcon, +} from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Icon } from '@iconify/react'; import { Link, useMatchRoute } from '@tanstack/react-router'; -import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; @@ -10,12 +19,12 @@ interface SidebarProps { } const tabs = [ - { id: 'main', path: '/', icon: Volume2, label: 'Generate' }, - { id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' }, - { id: 'voices', path: '/voices', icon: Mic, label: 'Voices' }, - { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, - { id: 'models', path: '/models', icon: Box, label: 'Models' }, - { id: 'server', path: '/server', icon: Server, label: 'Server' }, + { id: 'main', path: '/', icon: VolumeHighIcon, label: 'Generate' }, + { id: 'stories', path: '/stories', icon: Book01Icon, label: 'Stories' }, + { id: 'voices', path: '/voices', icon: Mic01Icon, label: 'Voices' }, + { id: 'audio', path: '/audio', icon: SpeakerIcon, label: 'Audio' }, + { id: 'models', path: '/models', icon: PackageIcon, label: 'Models' }, + { id: 'server', path: '/server', icon: ServerStack01Icon, label: 'Server' }, ]; export function Sidebar({ isMacOS }: SidebarProps) { @@ -42,9 +51,7 @@ export function Sidebar({ isMacOS }: SidebarProps) { 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 }); + tab.path === '/' ? matchRoute({ to: '/' }) : matchRoute({ to: tab.path }); return ( - + ); })} @@ -75,7 +82,7 @@ export function Sidebar({ isMacOS }: SidebarProps) { isPlayerVisible ? 'mb-[120px]' : 'mb-0', )} > - +
)} diff --git a/app/src/components/StoriesTab/StoryChatItem.tsx b/app/src/components/StoriesTab/StoryChatItem.tsx index 19fa2249..ac16352a 100644 --- a/app/src/components/StoriesTab/StoryChatItem.tsx +++ b/app/src/components/StoriesTab/StoryChatItem.tsx @@ -1,7 +1,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { DragDropVerticalIcon, MoreHorizontalIcon, PlayIcon, Delete01Icon } from '@hugeicons/core-free-icons'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -10,10 +10,10 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; +import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar'; import type { StoryItemDetail } from '@/lib/api/types'; import { cn } from '@/lib/utils/cn'; import { useStoryStore } from '@/stores/storyStore'; -import { useServerStore } from '@/stores/serverStore'; interface StoryChatItemProps { item: StoryItemDetail; @@ -35,10 +35,6 @@ export function StoryChatItem({ isDragging, }: StoryChatItemProps) { const seek = useStoryStore((state) => state.seek); - const serverUrl = useServerStore((state) => state.serverUrl); - const [avatarError, setAvatarError] = useState(false); - - const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`; // Check if this item is currently playing based on timecode const itemStartMs = item.start_time_ms; @@ -74,27 +70,18 @@ export function StoryChatItem({ className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors" {...dragHandleProps} > - + )} {/* Voice Avatar */}
-
- {!avatarError ? ( - {`${item.profile_name} setAvatarError(true)} - /> - ) : ( - - )} -
+
{/* Content */} @@ -119,16 +106,16 @@ export function StoryChatItem({ - + Play from here - + Remove from Story diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index 483e6657..518c42a0 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -13,7 +13,8 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; -import { Download, Plus } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Download01Icon, Add01Icon } from '@hugeicons/core-free-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -271,7 +272,7 @@ export function StoryContent() { @@ -316,7 +317,7 @@ export function StoryContent() { onClick={handleExportAudio} disabled={exportAudio.isPending} > - + Export Audio )} diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index ebbd6616..dd78d245 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -1,5 +1,6 @@ -import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Add01Icon, Book01Icon, MoreHorizontalIcon, PencilIcon, Delete01Icon } from '@hugeicons/core-free-icons'; +import { useState, useMemo } from 'react'; import { AlertDialog, AlertDialogAction, @@ -29,7 +30,7 @@ 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 { useStories, useCreateStory, useUpdateStory, useDeleteStory, useStory } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { formatDate } from '@/lib/utils/format'; import { useStoryStore } from '@/stores/storyStore'; @@ -38,6 +39,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: currentStory } = useStory(selectedStoryId); const createStory = useCreateStory(); const updateStory = useUpdateStory(); const deleteStory = useDeleteStory(); @@ -54,6 +57,16 @@ export function StoryList() { const [newStoryDescription, setNewStoryDescription] = useState(''); const { toast } = useToast(); + // Calculate bottom padding to account for FloatingGenerateBox and StoryTrackEditor + const hasTrackEditor = currentStory && currentStory.items.length > 0; + const bottomPadding = useMemo(() => { + // FloatingGenerateBox height (~100px) + gap (24px) + const generateBoxHeight = 124; + // Track editor height when visible + const editorHeight = hasTrackEditor ? trackEditorHeight + 24 : 0; + return generateBoxHeight + editorHeight; + }, [hasTrackEditor, trackEditorHeight]); + const handleCreateStory = () => { if (!newStoryName.trim()) { toast({ @@ -177,16 +190,19 @@ export function StoryList() {

Stories

{/* Story List */} -
+
{storyList.length === 0 ? (
- +

No stories yet

Create your first story to get started

@@ -227,19 +243,19 @@ export function StoryList() { className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()} > - + handleEditClick(story)}> - + Edit handleDeleteClick(story.id)} className="text-destructive focus:text-destructive" > - + Delete diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 74dbde25..95a10296 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -1,14 +1,15 @@ +import { HugeiconsIcon } from '@hugeicons/react'; import { - Copy, - GripHorizontal, - Minus, - Pause, - Play, - Plus, - Scissors, - Square, - Trash2, -} from 'lucide-react'; + Copy01Icon, + DragDropHorizontalIcon, + RemoveIcon, + PauseIcon, + PlayIcon, + Add01Icon, + Scissor01Icon, + SquareIcon, + Delete01Icon, +} from '@hugeicons/core-free-icons'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; @@ -723,7 +724,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { onMouseDown={handleResizeStart} aria-label="Resize track editor" > - + {/* Toolbar */} @@ -737,7 +738,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { onClick={handlePlayPause} title="Play/Pause (Space)" > - {isCurrentlyPlaying ? : } + {isCurrentlyPlaying ? : } {formatTime(currentTimeMs)} / {formatTime(totalDurationMs)} @@ -763,7 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { onClick={handleSplit} title="Split at playhead (S)" > - +
)} @@ -790,10 +791,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
Zoom:
@@ -837,7 +838,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { type="button" className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left" style={{ width: `${timelineWidth}px` }} - onClick={handleTimelineClick} + onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent)} aria-label="Seek timeline" > {timeMarkers.map((ms) => ( @@ -878,7 +879,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {

@@ -122,7 +123,7 @@ export function AudioSampleRecording({ onClick={onStop} className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90" > - + Stop Recording

@@ -134,13 +135,13 @@ export function AudioSampleRecording({ {file && !isRecording && (

- + Recording complete

File: {file.name}

@@ -60,7 +61,7 @@ export function AudioSampleSystem({ variant="destructive" className="flex items-center gap-2" > - + Stop Capture

@@ -72,13 +73,13 @@ export function AudioSampleSystem({ {file && !isRecording && (

- + Capture complete

File: {file.name}

@@ -99,7 +100,7 @@ export function AudioSampleUpload({ ) : ( <>

- + File uploaded

File: {file.name}

@@ -111,7 +112,7 @@ export function AudioSampleUpload({ onClick={onPlayPause} disabled={isValidating} > - {isPlaying ? : } + {isPlaying ? : }
} onClick={handleExport} disabled={exportProfile.isPending} aria-label="Export profile" /> } onClick={(e) => { e.stopPropagation(); handleEdit(); @@ -115,7 +104,7 @@ export function ProfileCard({ profile }: ProfileCardProps) { aria-label="Edit profile" /> } onClick={handleDeleteClick} disabled={deleteProfile.isPending} aria-label="Delete profile" diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index f4fc5711..3fcd987e 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Edit02Icon, Mic01Icon, DeskIcon, Upload01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -635,7 +636,7 @@ export function ProfileForm() { setSampleMode('record'); }} > - + Discard
@@ -668,16 +669,16 @@ export function ProfileForm() { className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`} > - + Upload - + Record {platform.metadata.isTauri && isSystemAudioSupported && ( - + System Audio )} @@ -798,7 +799,7 @@ export function ProfileForm() { className="h-full w-full object-cover" /> ) : ( - + )}
{(avatarPreview || editingProfile?.avatar_path) && ( )}
diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 8dcb06a4..4e57bfdb 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -1,4 +1,5 @@ -import { Mic, Sparkles } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Mic01Icon, SparklesIcon } from '@hugeicons/core-free-icons'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useProfiles } from '@/lib/hooks/useProfiles'; @@ -30,12 +31,12 @@ export function ProfileList() { {allProfiles.length === 0 ? ( - +

No voice profiles yet. Create your first profile to get started.

diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index 19aa1ca8..17664afd 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -1,4 +1,5 @@ -import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { CheckmarkCircle01Icon, Edit01Icon, PauseIcon, PlayIcon, Add01Icon, Delete01Icon, VolumeHighIcon, Cancel01Icon } from '@hugeicons/core-free-icons'; import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { CircleButton } from '@/components/ui/circle-button'; @@ -103,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { onClick={handlePlayPause} disabled={isLoading} > - {isPlaying ? : } + {isPlaying ? : }
@@ -129,7 +130,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { onClick={handleStop} title="Stop" > - +
@@ -209,7 +210,7 @@ export function SampleList({ profileId }: SampleListProps) {
{samples && samples.length === 0 ? (
- +

No samples yet

Add your first audio sample to get started @@ -232,7 +233,7 @@ export function SampleList({ profileId }: SampleListProps) { /* Edit Mode */

- + Editing transcription