Compare commits

..
Author SHA1 Message Date
James PineandClaude Opus 4.7 4ffbc03d15 fix(audio): raise trim threshold, cap pad at net-neutral
Review feedback on the preprocessor:

1. ``trim_top_db=30`` was labelled "conservative" in the docstring but is
   actually *more* aggressive than librosa's default of 60. Normal
   speech dynamic range sits around 30 dB, so 30 dB would eat quiet
   trailing syllables and soft consonants. Raise the default to 40 dB —
   below normal speech dynamic range but still catching obvious edge
   silence — and fix the docstring.

2. Unconditional 100 ms edge padding ran even when ``librosa.effects.trim``
   removed nothing. For a well-recorded 29.9 s upload that path would
   push the waveform past the 30 s ceiling and trigger a spurious "too
   long" rejection. Only pad when trimming actually shortened the
   audio, and cap the pad so the output never exceeds the input length.

Adds a regression test for the net-neutral length behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 18:25:52 -07:00
James PineandClaude Opus 4.7 a49cc6afbb fix(audio): preprocess reference samples instead of rejecting them
Uploaded/recorded voice samples were rejected outright whenever the peak
exceeded 0.99 ("Audio is clipping (reduce input gain)"). That wasn't
actionable: a recording in the app has no pre-gain control, and an
already-captured file can't be re-taken by the user. The Settings
"Normalize audio" toggle only affects generated TTS output, so users who
enabled it expecting it to help with sample uploads were still blocked.

Replace the hard reject with a small, always-on preprocess step that
runs right after load:
  - DC-offset removal
  - Conservative edge-silence trim (top_db=30) with 100 ms padding kept
  - Peak cap at 0.95 if the input peak exceeds that

Duration and RMS checks now run on the preprocessed waveform, so
samples that were previously rejected for being "hot" are accepted and
stored with safe headroom. True in-waveform clipping artifacts still
can't be repaired — peak scaling only prevents downstream re-clipping
during multi-sample combination and TTS inference.

Adds a unit-test file (previously none existed for audio.py).

Fixes #456.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 16:41:10 -07:00
298 changed files with 8048 additions and 34994 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.5.0
current_version = 0.4.1
commit = True
tag = True
tag_name = v{new_version}
+1 -2
View File
@@ -8,8 +8,7 @@ tauri/
landing/
docs/
mlx-test/
scripts/*
!scripts/rocm-entrypoint.sh
scripts/
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
-3
View File
@@ -29,14 +29,11 @@ jobs:
run: |
cd backend
python build_binary.py
python build_binary.py --shim
PLATFORM=$(rustc --print host-tuple)
mkdir -p ../tauri/src-tauri/binaries
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
echo "Built voicebox-mcp-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
+2 -154
View File
@@ -32,28 +32,6 @@ jobs:
steps:
- uses: actions/checkout@v4
# Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can
# peak well above that during the build. Reclaim ~25 GB by pruning
# preinstalled toolchains we don't use. This is what likely tripped
# the March 2026 Linux release attempts (see commit 103e98b
# "github runners suck") — not a code issue, a disk-pressure one.
- name: Free up disk space (ubuntu)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
# Pinned to v1.3.1 (SHA) — this job runs with contents: write and
# handles signing secrets later, so we don't want a floating ref.
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
tool-cache: false
android: true
dotnet: true
haskell: true
# large-packages: true would `apt-get remove '^llvm-.*'`, which
# cascade-removes reverse deps that won't be pulled back in by the
# `llvm-dev` install below. The other flags already free ~20 GB,
# enough for the Python + torch + PyInstaller build.
large-packages: false
swap-storage: true
- name: Install dependencies (ubuntu only)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
@@ -95,10 +73,8 @@ jobs:
# fine on transformers 4.57.x in practice (verified in dev), so install
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
# miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt,
# not transitively pulled by anything else — see issue #505); the
# rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are
# pulled in by other engines.
# the rest (sounddevice, miniaudio, protobuf, sentencepiece, pyyaml,
# jinja2) are pulled in by other engines.
pip install --no-deps mlx-lm==0.31.1
pip install --no-deps mlx-audio==0.4.1
@@ -114,7 +90,6 @@ jobs:
run: |
cd backend
python build_binary.py
python build_binary.py --shim
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
@@ -124,9 +99,7 @@ jobs:
# Copy with platform suffix
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
echo "Built voicebox-mcp-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -160,21 +133,6 @@ jobs:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Disk / environment snapshot (pre-bundle debug)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
echo "=== df -h ==="
df -h
echo "=== free -h ==="
free -h
echo "=== Rust / Cargo ==="
rustc --version
cargo --version
echo "=== Bun ==="
bun --version
echo "=== Tauri CLI ==="
cd tauri && bun run tauri --version
- name: Extract release notes from CHANGELOG.md
id: changelog
shell: bash
@@ -198,13 +156,7 @@ jobs:
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Linux hang watchdog: previous releases silently wedged inside tauri
# bundling (possibly linuxdeploy/AppImage download, possibly cargo link).
# Cap the step at 30 min so we get logs instead of waiting out the 6hr
# job timeout. Other platforms historically complete in ~25 min, so 45
# is comfortable.
- uses: tauri-apps/[email protected]
timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -216,9 +168,6 @@ jobs:
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# Stream subprocess stdout/stderr so the hang is visible in logs.
CARGO_TERM_VERBOSE: "true"
RUST_BACKTRACE: "1"
with:
projectPath: tauri
tagName: v__VERSION__
@@ -229,46 +178,6 @@ jobs:
args: ${{ matrix.args }}
includeUpdaterJson: true
# Tauri's bundler signs the .app and notarizes it, but the .dmg wrapper
# ships unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (caught
# by Homebrew Cask CI) and causes "app isn't signed" dialogs on older
# Intel Macs when Apple's notarization servers are slow (see issue #509).
# Submit the .dmg to notarytool, staple the ticket, and overwrite the
# release asset uploaded by tauri-action.
- name: Notarize and staple DMG (macOS)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8"
TARGET=$(echo "${{ matrix.args }}" | sed -n 's/.*--target \([a-z0-9_-]*\).*/\1/p')
DMG_DIR="tauri/src-tauri/target/${TARGET}/release/bundle/dmg"
# Match the release tag tauri-action resolved from tauri.conf.json's
# version field; GITHUB_REF_NAME is a branch name under workflow_dispatch.
RELEASE_TAG="v$(jq -r '.version' tauri/src-tauri/tauri.conf.json)"
shopt -s nullglob
dmgs=("${DMG_DIR}"/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "::error::No DMGs found in ${DMG_DIR} — tauri bundler output path may have changed"
exit 1
fi
for dmg in "${dmgs[@]}"; do
echo "::group::Notarize $(basename "$dmg")"
xcrun notarytool submit "$dmg" \
--key "$KEY_PATH" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER" \
--wait --timeout 20m
xcrun stapler staple "$dmg"
spctl -a -t open --context context:primary-signature -vv "$dmg"
gh release upload "${RELEASE_TAG}" "$dmg" --clobber \
--repo "${GITHUB_REPOSITORY}"
echo "::endgroup::"
done
build-cuda-windows:
runs-on: windows-latest
permissions:
@@ -340,64 +249,3 @@ jobs:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
build-rocm-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
# ROCm wheels are cp312-cp312-specific — build_binary.py --rocm enforces this.
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
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Build ROCm server binary (onedir)
shell: bash
working-directory: backend
# build_binary.py --rocm pulls the official AMD Radeon torch + rocm_sdk
# wheels (rocm-rel-7.2.1) itself when ROCm torch is not already present,
# then restores the dev torch afterwards.
run: python build_binary.py --rocm
- name: Package into server core + ROCm libs archives
shell: bash
run: |
python scripts/package_rocm.py \
backend/dist/voicebox-server-rocm/ \
--output release-assets/ \
--rocm-libs-version rocm7.2-v1 \
--torch-compat ">=2.9.0,<2.10.0"
- name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
release-assets/voicebox-server-rocm.tar.gz
release-assets/voicebox-server-rocm.tar.gz.sha256
release-assets/rocm-libs-rocm7.2-v1.tar.gz
release-assets/rocm-libs-rocm7.2-v1.tar.gz.sha256
release-assets/rocm-libs.json
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload onedir as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-rocm-windows
path: backend/dist/voicebox-server-rocm/
retention-days: 7
BIN
View File
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"voicebox": {
"type": "http",
"url": "http://127.0.0.1:17493/mcp",
"headers": {
"X-Voicebox-Client-Id": "claude-code"
}
}
}
}
+1 -161
View File
@@ -7,162 +7,6 @@
## [Unreleased]
### Linux
- **ROCm setup works on Linux AMD systems.** Docker ROCm builds now keep PyTorch
on the ROCm wheel index during dependency installation, so later installs do
not replace it with CUDA wheels. The ROCm compose overlay no longer assumes
Ubuntu render/video group IDs; the container joins the groups that own the GPU
device nodes at startup. Native Linux setup now picks ROCm wheels for AMD GPUs
and CUDA wheels for NVIDIA GPUs before installing backend dependencies.
## [0.5.0] - 2026-04-22
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
### Dictation — speak anywhere, paste anywhere
- **Global hotkey capture.** Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill walks through recording → transcribing → refining → done with a live elapsed timer. The transcript lands as clean text.
- **Push-to-talk and toggle modes, each with its own chord.** The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording.
- **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Works across Dvorak / AZERTY layouts. Your clipboard is saved before and restored after.
- **Chord picker UI.** Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant.
- **Defaults stay out of your way.** macOS defaults avoid left-hand Cmd+Option chords so the system shortcuts they collide with stay yours. Windows defaults route around AltGr collisions on German / French / Spanish layouts.
- **Accessibility permission is scoped.** If macOS Accessibility isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. The permission prompt lives inline next to the auto-paste toggle, not as a global banner.
### Personality — voice profiles that speak for themselves
Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a new Qwen3 LLM running entirely locally:
- **Compose** — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking.
- **Speak in character** — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice.
The same LLM doubles as the refinement model, so there's one local LLM in the app, not two.
**API surface.** `POST /generate`, `POST /speak`, and the MCP `voicebox.speak` tool accept `personality: bool`. `POST /profiles/{id}/compose` powers the shuffle button. MCP client bindings carry a `default_personality: bool` that applies when `personality` isn't passed explicitly.
### Agents — any MCP-aware agent gets a voice
Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names:
- **`voicebox.speak`** — speak text in any voice profile, with optional `personality: true` to run through the profile's personality LLM first
- **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path. Path mode is restricted to loopback callers so a Voicebox bound on `0.0.0.0` doesn't double as an unauthenticated arbitrary-local-file read primitive.
- **`voicebox.list_captures`** — recent captures with their transcripts
- **`voicebox.list_profiles`** — available voice profiles (cloned + preset)
- **Streamable HTTP as primary transport.** Cursor / Windsurf / VS Code / Claude Code all support it out of the box — drop a `mcpServers` block with the URL and an `X-Voicebox-Client-Id` header.
- **Stdio shim for clients that don't speak HTTP MCP.** A `voicebox-mcp` binary ships inside the app bundle as a Tauri sidecar. The Settings page renders the install snippet with the right absolute path pre-filled.
- **Per-client voice binding.** Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the `X-Voicebox-Client-Id` header resolves to a bound voice whenever `speak` is called without an explicit `profile`. Managed in **Settings → MCP**.
- **Profile resolution precedence.** Explicit `profile` arg (name or id, case-insensitive) → per-client binding → global default from `capture_settings.default_playback_voice_id` → error with a pointer to Settings.
- **Speaking pill.** Agent-initiated speech surfaces the same on-screen pill as dictation, in a `speaking` state with the profile name and an elapsed timer. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine.
- **`POST /speak` REST wrapper.** Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native.
**Claude Code one-liner:**
```
claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --header "X-Voicebox-Client-Id: claude-code"
```
### Refinement
A clean transcript needs more than Whisper. Each capture flows through a small Qwen3 LLM that strips fillers, fixes punctuation, and optionally rewrites self-corrections — all on-device.
- **Loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold.
- **Per-capture flag snapshot.** `smart_cleanup`, `self_correction`, and `preserve_technical` are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript.
- **Model picker** — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers.
### Captures tab + settings
Settings → Captures is now the home for the whole dictation flow:
- **Dictation**: global shortcut toggle, push-to-talk chord picker, toggle chord picker, live pill preview, auto-paste into focused field (with inline accessibility prompt).
- **Transcription**: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock.
- **Refinement**: auto-refine toggle, model picker, smart cleanup, remove self-corrections, preserve technical terms.
- **Playback**: default voice for the Captures tab's "Play as" action — picking a voice from the split-button persists the choice across tab switches and restarts.
- **Storage**: captures folder quick-open.
### Stories — timeline editor
The Stories tab graduates from a TTS sequencer into a real timeline editor. Same generation-row backing, but clips now compose with imported audio, per-clip levels, and a flexible track stack.
- **Import external audio.** Drag a music file onto the story content area or pick one from the new "Import audio" entry in the add-clip popover. Accepted formats: wav / mp3 / flac / ogg / m4a / aac / webm, capped at 200 MB. Imported clips show their filename instead of a profile name and skip the regenerate / version-picker controls — there's nothing to regenerate.
- **Per-clip volume.** A `Volume2` icon in the clip-edit toolbar opens a 0–200% slider. Adjustments apply live and to exports. Split and duplicate carry the volume forward into the new clips.
- **Regenerate** from both the clip's chat-list dropdown and the track-editor toolbar. Re-runs the underlying generation through the same path the History tab uses, with completion tracked in the global pending set.
- **Add empty tracks above or below the timeline** via tiny `+` strips at the top of the topmost label cell and the bottom of the bottommost. Sticky in the label column so they follow horizontal scroll.
- **Zoom bar tracks the project.** Min scope is 10 seconds visible (zoomed in cap), max is the entire project (zoomed out cap), default lands on 60 s. Both the +/− buttons and the scrollbar edge-drag handles clamp to those dynamic bounds.
### Interface
- **Theme selector.** Light / dark / system in **Settings → General**, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart.
- **Scrubbable waveform player on captures.** The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label.
- **Capture pill light mode.** The on-screen pill gets a dedicated light palette so it stays legible against bright windows.
- **Readiness checklist in the Captures settings sidebar.** The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux.
### Windows parity
Same dictation flow on Windows. Right-hand default chord (Ctrl+Shift) avoids AltGr collisions on layouts where Ctrl+Alt is the compose key. Focus is captured at chord-start so paste lands in the original field even if focus drifts during transcribe/refine.
## [0.4.5] - 2026-04-22
Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix ([#526](https://github.com/jamiepine/voicebox/issues/526)). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.
### Reliability
- **Load no longer fails with "offline mode is enabled"** ([#530](https://github.com/jamiepine/voicebox/pull/530), fixes [#526](https://github.com/jamiepine/voicebox/issues/526)). transformers 4.57.x added an unconditional `huggingface_hub.model_info()` call inside `AutoTokenizer.from_pretrained` (via `_patch_mistral_regex`) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time `HF_HUB_OFFLINE` guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps `_patch_mistral_regex` so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at `backend.backends` import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time `force_offline_if_cached` guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
- **No more 30s pause when generating without a network.** The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.
## [0.4.4] - 2026-04-21
Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.
### Reliability
- **Inference no longer fails with "offline mode is enabled" while online** ([#524](https://github.com/jamiepine/voicebox/pull/524), reverts the inference-path guards from [#503](https://github.com/jamiepine/voicebox/pull/503)). 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with a process-wide `HF_HUB_OFFLINE` flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference ([#462](https://github.com/jamiepine/voicebox/issues/462)). That flag also blocks legitimate metadata calls (e.g. `HfApi().model_info` for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.
**Known caveat**: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.
## [0.4.3] - 2026-04-20
A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks `brew install voicebox` on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.
### macOS
- **DMGs are now notarized and stapled** ([#523](https://github.com/jamiepine/voicebox/pull/523)). Tauri's bundler notarizes the `.app` inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable ([#509](https://github.com/jamiepine/voicebox/issues/509)). The release workflow now submits each DMG to `notarytool`, staples the ticket, verifies with `spctl`, and overwrites the draft-release asset `tauri-action` uploaded. Adds ~5-10 min per macOS job.
### Backend
- **Kokoro Japanese voices no longer crash on fresh installs** ([#521](https://github.com/jamiepine/voicebox/pull/521), fixes [#514](https://github.com/jamiepine/voicebox/issues/514)). `misaki[ja]` pulls in `fugashi`, which needs a MeCab dictionary on disk. The `unidic` package that was being installed ships no data and expects a ~526MB runtime download that `just setup` doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to `unidic-lite`, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in `build_binary.py` so frozen builds pick up `unidic_lite/dicdir/`.
## [0.4.2] - 2026-04-20
This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.
### Internationalization ([#508](https://github.com/jamiepine/voicebox/pull/508))
- **i18next foundation** with an in-app language switcher that re-renders the tree on change — lazy-loaded components were holding stale strings without an explicit key-bump on the React root.
- **Four locales** at full coverage: English, Simplified Chinese, Traditional Chinese, Japanese. No partial/English-fallback surfaces.
- **Every user-visible surface translated**: Stories (list, content editor, dialogs, toasts), Effects (list, detail, chain editor, built-in preset names), Voices (table, search, inspector, Create/Edit modal, audio sample panels), Audio Channels (list, dialogs, device picker), history + story dropdown menus, ProfileCard / ProfileList / HistoryTable, and the unsupported-model note.
- **Relative dates** localize via `date-fns` locale objects (`3 days ago` → `3 天前` / `3 日前`) — `Intl.RelativeTimeFormat` doesn't produce the phrasing we use in the history table.
- **Dev-build version suffix** (`v0.4.2 (dev)` / `(开发版)` / `(開發版)` / `(開発版)`) is now locale-aware.
- **559 translation keys** across all four locales.
### Reliability
- **`HF_HUB_OFFLINE` now guards every inference path** ([#503](https://github.com/jamiepine/voicebox/pull/503)) — some engines were still attempting a HuggingFace metadata roundtrip on first load when offline mode was enabled, causing hangs on airgapped or flaky networks.
- **Chatterbox reference samples are preprocessed instead of rejected** ([#502](https://github.com/jamiepine/voicebox/pull/502)) — samples outside the expected sample rate or channel layout are resampled to match, rather than failing with an opaque error.
- **MLX Qwen 0.6B repo path fixed** ([#501](https://github.com/jamiepine/voicebox/pull/501)) — now points at the published `mlx-community` repo so the model actually downloads on Apple Silicon.
- **macOS system audio survives backgrounding** ([#486](https://github.com/jamiepine/voicebox/pull/486), closes [#41](https://github.com/jamiepine/voicebox/issues/41)) — WKWebView was tearing down the audio session when the app lost focus, silently killing system-audio capture.
- **MLX backend `miniaudio` dependency pinned** ([#506](https://github.com/jamiepine/voicebox/pull/506)) — `mlx_audio.stt` needs it at runtime and nothing else transitively pulled it in, so `--no-deps` installs were breaking on first use.
### Landing / Docs
- **New `/download` page** ([#487](https://github.com/jamiepine/voicebox/pull/487)) — no more dumping first-time visitors onto the GitHub releases list. The API example snippet on the landing page also got an accuracy pass.
- **Download redirects work behind reverse proxies** ([#498](https://github.com/jamiepine/voicebox/pull/498)) — uses the public origin instead of `localhost` when resolving platform-specific installer URLs.
- **MDX docs audited against the multi-engine backend** ([#484](https://github.com/jamiepine/voicebox/pull/484)) — stale single-engine assumptions removed.
- **Three more tutorials + mobile navbar / hero CTA fixes** ([#483](https://github.com/jamiepine/voicebox/pull/483)).
### Linux
- **Still not shipping.** The re-enable attempt ([#488](https://github.com/jamiepine/voicebox/pull/488)) landed on `main` but CI still hangs in the `tauri-action` bundler step on `ubuntu-22.04` — no output for 25+ minutes after `rpm` bundling, even with `createUpdaterArtifacts: false` and `--bundles deb,rpm`. The matrix entry is disabled again for 0.4.2; the ubuntu-specific setup steps stay in the workflow so re-enabling is a one-line change once we identify the hang. Next release will take another pass.
### New Contributors
- [@shekharyv](https://github.com/shekharyv) — download redirects behind reverse proxies ([#498](https://github.com/jamiepine/voicebox/pull/498))
## [0.4.1] - 2026-04-18
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
@@ -751,11 +595,7 @@ The first public release of Voicebox — an open-source voice synthesis studio p
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
[0.5.0]: https://github.com/jamiepine/voicebox/compare/v0.4.5...v0.5.0
[0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/jamiepine/voicebox/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/jamiepine/voicebox/compare/v0.4.1...v0.4.2
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
+2 -2
View File
@@ -91,7 +91,7 @@ On Windows, to build with CUDA support for local testing:
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%/sh.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
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/`.
@@ -133,7 +133,7 @@ bun run convert:assets
This script:
- Converts PNG → WebP (better compression, same quality)
- Converts MOV → WebM (VP9 codec, smaller file size)
- Processes files in `docs/public/`
- Processes files in `landing/public/` and `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
+8 -31
View File
@@ -1,15 +1,8 @@
# ============================================================
# Voicebox — Local TTS Server with Web UI
# Voicebox — Local TTS Server with Web UI (CPU)
# 3-stage build: Frontend → Python deps → Runtime
#
# Build variants:
# CPU (default): docker compose up --build
# ROCm (AMD GPU): docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
# ============================================================
# Top-level ARG so it is visible to all stages.
ARG PYTORCH_VARIANT=cpu
# === Stage 1: Build frontend ===
FROM oven/bun:1 AS frontend
@@ -21,7 +14,7 @@ COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d' package.json && \
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
@@ -31,9 +24,6 @@ RUN cd web && bunx --bun vite build
# === Stage 2: Build Python dependencies ===
FROM python:3.11-slim AS backend-builder
# Re-declare ARG inside the stage (Docker scoping requirement).
ARG PYTORCH_VARIANT=cpu
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -44,19 +34,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN pip install --no-cache-dir --upgrade pip
COPY backend/requirements.txt .
# ROCm wheel index. Default 6.3 (RDNA1/2/3); set ROCM_VERSION=7.2 for RDNA4.
ARG ROCM_VERSION=6.3
# For ROCm, make the PyTorch ROCm index primary so every install below resolves
# torch to ROCm wheels instead of the default CUDA build.
RUN if [ "$PYTORCH_VARIANT" = "rocm" ]; then \
pip install --no-cache-dir --prefix=/install \
--index-url "https://download.pytorch.org/whl/rocm${ROCM_VERSION}" \
torch torchaudio && \
printf '[global]\nindex-url = https://download.pytorch.org/whl/rocm%s\nextra-index-url = https://pypi.org/simple\n' "$ROCM_VERSION" > /etc/pip.conf; \
fi
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
@@ -67,17 +44,16 @@ RUN pip install --no-cache-dir --prefix=/install \
# === Stage 3: Runtime ===
FROM python:3.11-slim
# Create non-root user; the entrypoint joins GPU device groups at runtime.
# Create non-root user for security
RUN groupadd -r voicebox && \
useradd -r -g voicebox -m -s /bin/bash voicebox
WORKDIR /app
# Install only runtime system dependencies (gosu drops root in the entrypoint)
# Install only runtime system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from builder stage
@@ -93,6 +69,9 @@ COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
&& chown -R voicebox:voicebox /app/data
# Switch to non-root user
USER voicebox
# Expose the API port
EXPOSE 17493
@@ -100,7 +79,5 @@ EXPOSE 17493
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Entrypoint joins GPU groups then drops to the voicebox user
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Start the FastAPI server
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+45 -179
View File
@@ -5,9 +5,9 @@
<h1 align="center">Voicebox</h1>
<p align="center">
<strong>The open-source AI voice studio.</strong><br/>
Clone any voice. Generate speech. Dictate into any app. Talk to agents in voices you own.<br/>
The full voice I/O stack, running locally on your machine.
<strong>The open-source voice synthesis studio.</strong><br/>
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
All running locally on your machine.
</p>
<p align="center">
@@ -28,10 +28,6 @@
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/21213" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21213" alt="jamiepine%2Fvoicebox | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a> •
<a href="https://docs.voicebox.sh">Docs</a> •
@@ -45,7 +41,7 @@
<p align="center">
<a href="https://voicebox.sh">
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
@@ -56,33 +52,28 @@
<br/>
<p align="center">
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
## What is Voicebox?
Voicebox is a **local-first AI voice studio** — a free and open-source alternative to **ElevenLabs** and **WisprFlow** in one app. Clone voices from a few seconds of audio, generate speech in 23 languages across 7 TTS engines, dictate into any text field with a global hotkey, and give any MCP-aware AI agent a voice of your choosing.
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio or pick from 50+ preset voices, generate speech in 23 languages across 7 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
The two cloud incumbents sit on opposite halves of the voice I/O loop — ElevenLabs on output, WisprFlow on input. Voicebox does both, bridges them with a bundled local LLM for refinement and per-profile personas, and runs the whole thing on your machine.
- **Complete privacy** — models, voice data, and captures never leave your machine
- **Complete privacy** — models and voice data stay on your machine
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
- **Voice cloning and preset voices** — zero-shot cloning from a reference sample, or 50+ curated preset voices via Kokoro and Qwen CustomVoice
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
- **Voice input** — global dictation hotkey with push-to-talk and toggle modes, accessibility-verified auto-paste on macOS, in-app mic on every text field, Whisper-based STT
- **Agent voice output** — one tool call (`voicebox.speak`) and any MCP-aware agent (Claude Code, Cursor, Cline) speaks to you in a voice you've cloned
- **Voice personalities** — attach a free-form persona to any voice profile, then Compose, Rewrite, or Respond via a bundled local LLM — agents can invoke the same modes over MCP
- **API-first** — REST API plus a built-in MCP server for integrating voice I/O into your own apps and agents
- **API-first** — REST API for integrating voice synthesis into your own projects
- **Native performance** — built with Tauri (Rust), not Electron
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
@@ -194,69 +185,12 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
- Auto-playback with synchronized playhead
- Version pinning per track clip
### Global Dictation & Voice Input
### Recording & Transcription
The other half of the voice I/O loop. Hold a hotkey anywhere on your system, speak, release — on macOS the transcript pastes straight into the focused text field. Or hit the mic on any Voicebox text input and dictate directly into the app.
- **Configurable chord bindings** — hold-to-speak and tap-to-toggle chords, each rebindable in the in-app chord picker. Holding push-to-talk and tapping `Space` mid-hold upgrades into a toggle session without a gap in audio
- **Target-aware paste (macOS)** — accessibility-verified injection into the focused text field, with atomic clipboard save/restore so your clipboard isn't clobbered
- **First-run permissions UX** — in-app gates walk you through the macOS Accessibility and Input Monitoring grants with deep-links to System Settings
- **In-app mic button** on every Voicebox text field — generation form, profile descriptions, story titles, anywhere you'd type
- **LLM refinement** — optional cleanup of ums, stutters, and false starts before paste
- **On-screen pill** — floating overlay surfacing `recording`, `transcribing`, `refining`, and `speaking` states. Same pill agents use when they speak to you, so there's one mental model for both directions of the loop
### Speech-to-Text
Voicebox runs OpenAI Whisper for transcription — the same model that backs dictation, the Captures tab, and the `/transcribe` API. Running on MLX (Apple Silicon) or PyTorch (CUDA / ROCm / DirectML / CPU) depending on your platform.
| Size | Notes |
| ----------------------------- | -------------------------------------------------- |
| Base / Small / Medium / Large | Standard Whisper quality ladder |
| Turbo | ~8x faster than Whisper Large, minimal quality loss |
More engines (Parakeet v3, Qwen3-ASR) are planned — see [Roadmap](#roadmap).
### Captures
Every dictation, in-app recording, and uploaded audio file lands in the Captures tab — original audio paired with transcript, always preserved.
- **Replay, re-transcribe, refine** — rerun STT with any Whisper size, or re-run the raw transcript through the local LLM with different flags (filler cleanup, self-correction removal, technical-term preservation)
- **Edit inline** — tweak the transcript and save on blur
- **Play as voice profile** — turn any capture into speech with a cloned voice, one click
- **Promote to voice sample** — use a capture's audio + transcript as a reference sample on any voice profile
- **Local capture storage** — original audio and transcript stay in your Voicebox data directory, with a folder shortcut in Settings
### Agent Voice Output
Every agent gets a voice. One tool call and any MCP-aware agent can speak to you in a voice you've cloned — task completions, questions, notifications. The same pill that surfaces during dictation surfaces during agent speech, so you always see what's coming out of your machine.
```ts
// In any MCP-aware agent:
await voicebox.speak({
text: "Deploy complete.",
profile: "Morgan",
});
```
Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, shell scripts, custom harnesses.
- **Bidirectional pill** — `recording`, `transcribing`, `refining`, and `speaking` are all states of the same OS-level overlay, so dictation and agent speech share one surface
- **Per-agent voice binding** — in **Settings → MCP**, pin Claude Code to Morgan and Cursor to Scarlett so you can tell which agent is talking without looking. Each client's `last_seen_at` timestamp confirms the install actually took
- **Always visible** — no silent background TTS; every agent-initiated speak surfaces the pill with the voice profile name for the full duration
- **HTTP + stdio transports** — install as a URL in Claude Code / Cursor / Windsurf / VS Code MCP, or point stdio-only clients at the bundled `voicebox-mcp` binary
### Voice Personalities
Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Two actions appear on the generate box when a personality is set, powered by a bundled Qwen3 LLM running entirely locally.
- **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take
- **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS
Agents can reach the same rewrite path over MCP by passing `personality: true` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint.
**Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
Use cases: agent dev loops (dictate a question, hear the answer in a cloned voice), interactive characters for games and narrative tools, speech assistance for people who can't speak in their original voice.
- In-app recording with waveform visualization
- System audio capture (macOS and Windows)
- Automatic transcription powered by Whisper (including Whisper Turbo)
- Export recordings in multiple formats
### Model Management
@@ -270,8 +204,7 @@ Use cases: agent dev loops (dictate a question, hear the answer in a cloned voic
| Platform | Backend | Notes |
| ------------------------ | -------------- | ---------------------------------------------- |
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
@@ -281,121 +214,55 @@ Use cases: agent dev loops (dictate a question, hear the answer in a cloned voic
## API
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
```bash
# Generate speech
curl -X POST http://127.0.0.1:17493/generate \
curl -X POST http://localhost:17493/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# Agent voice output — any app or script can speak in a cloned voice
curl -X POST http://127.0.0.1:17493/speak \
-H "Content-Type: application/json" \
-H "X-Voicebox-Client-Id: my-script" \
-d '{"text": "Deploy complete.", "profile": "Morgan"}'
# Transcribe an audio file
curl -X POST http://127.0.0.1:17493/transcribe \
-F "[email protected]" \
-F "model=whisper-turbo"
# List voice profiles
curl http://127.0.0.1:17493/profiles
curl http://localhost:17493/profiles
# Create a profile
curl -X POST http://localhost:17493/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
```
`POST /speak` accepts `profile` as a name (case-insensitive) or id, and resolves via the same precedence as the MCP tool: explicit arg → per-client binding → `capture_settings.default_playback_voice_id`.
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
### MCP server
Voicebox ships a built-in **Model Context Protocol** server so any MCP-aware agent (Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions) can speak, transcribe, and browse captures and profiles.
**Claude Code one-liner:**
```
claude mcp add voicebox \
--transport http \
--url http://127.0.0.1:17493/mcp \
--header "X-Voicebox-Client-Id: claude-code"
```
**Any HTTP MCP client** (Cursor, Windsurf, VS Code, etc.):
```json
{
"mcpServers": {
"voicebox": {
"url": "http://127.0.0.1:17493/mcp",
"headers": { "X-Voicebox-Client-Id": "cursor" }
}
}
}
```
**Stdio fallback** for clients that don't speak HTTP MCP — point at the bundled `voicebox-mcp` binary inside the app:
```json
{
"mcpServers": {
"voicebox": {
"command": "/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp",
"env": { "VOICEBOX_CLIENT_ID": "claude-desktop" }
}
}
}
```
Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_captures`, `voicebox.list_profiles`. Per-client voice bindings are managed in **Voicebox → Settings → MCP**. See the [full MCP guide](docs/content/docs/overview/mcp-server.mdx) for tool signatures, resolution precedence, the speaking-pill contract, and security notes.
```ts
// In any MCP-aware agent:
await voicebox.speak({
text: "Tests passing. Ready to merge.",
profile: "Morgan", // optional — falls back to the per-client binding
personality: true, // optional — rewrites text through the profile's personality LLM first
});
```
**Use cases:** agent dev loops (voice in, voice out), game dialogue, podcast production, accessibility tools, voice assistants, content automation.
Full API documentation available at `http://127.0.0.1:17493/docs`.
Full API documentation available at `http://localhost:17493/docs`.
---
## Tech Stack
| Layer | Technology |
| ------------- | ------------------------------------------------------------------------------- |
| Desktop App | Tauri (Rust) |
| Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query |
| Backend | FastAPI (Python) |
| Layer | Technology |
| ------------- | ------------------------------------------------- |
| Desktop App | Tauri (Rust) |
| Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query |
| Backend | FastAPI (Python) |
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
| STT | Whisper / Whisper Turbo (PyTorch or MLX) |
| Local LLM | Qwen3 (0.6B / 1.7B / 4B), shared runtime with TTS / STT |
| MCP Server | FastMCP mounted at `/mcp` (Streamable HTTP) + bundled stdio shim binary |
| Native Shim | Rust (inside Tauri) for global hotkey, paste injection, focus introspection |
| Effects | Pedalboard (Spotify) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
| Database | SQLite |
| Audio | WaveSurfer.js, librosa |
| Effects | Pedalboard (Spotify) |
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
| Database | SQLite |
| Audio | WaveSurfer.js, librosa |
---
## Roadmap
| Feature | Description |
| ---------------------------------- | ------------------------------------------------------------------------ |
| **Windows / Linux auto-paste** | Dictation paste parity — `SendInput` on Windows, `uinput` / AT-SPI on Linux |
| **STT engine expansion** | Parakeet v3 and Qwen3-ASR joining Whisper — 50+ languages, better non-English quality |
| **Pipeline routing** | Configurable source → transform → sink chains with webhook + MCP sinks and a preset editor |
| **Streaming transcription** | WebSocket `/transcribe/stream` for partial transcripts as you speak |
| **End-to-end speech LLMs** | Moshi, GLM-4-Voice, Qwen2.5 Omni — real voice-to-voice, no text between |
| **Voice Design** | Create new voices from text descriptions |
| **Long-form capture** | Dual-stream recorder (mic + system audio) with summary LLM transform |
| **Platform sinks** | Apple Notes, Obsidian, and other opt-in integrations |
| **Plugin architecture** | Extend with custom models, transforms, and sinks |
| **Mobile companion** | Control Voicebox from your phone |
| Feature | Description |
| ----------------------- | ---------------------------------------------- |
| **Real-time Streaming** | Stream audio as it generates, word by word |
| **Voice Design** | Create new voices from text descriptions |
| **More Models** | XTTS, Bark, and other open-source voice models |
| **Plugin Architecture** | Extend with custom models and effects |
| **Mobile Companion** | Control Voicebox from your phone |
For the **full engineering status, open-issue triage, and prioritized work queue**, see [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) — a living document that tracks what's shipped, what's in-flight, candidate TTS engines under evaluation, and why we've accepted or backlogged specific integrations.
@@ -419,8 +286,6 @@ Install [just](https://github.com/casey/just): `brew install just` or `cargo ins
**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.
The repo ships a pre-wired `.mcp.json` at the root — running Claude Code inside this checkout picks up the Voicebox MCP tools automatically once the dev app is running.
### Building Locally
```bash
@@ -442,6 +307,7 @@ voicebox/
├── tauri/ # Desktop app (Tauri + Rust)
├── web/ # Web deployment
├── backend/ # Python FastAPI server
├── landing/ # Marketing website
└── scripts/ # Build & release scripts
```
-27
View File
@@ -1,27 +0,0 @@
# Responsible Use
Voicebox is a local-first AI voice studio. It can clone voices from short audio samples, generate speech, and make AI agents speak through voice profiles. That capability is useful for accessibility, creative production, prototyping, game development, and personal tools, but it can also be misused.
Voicebox does not and cannot independently verify who owns a voice sample. You are responsible for making sure you have the right to use every voice you clone, import, or generate with.
## Allowed Uses
- Cloning your own voice.
- Cloning a voice with explicit permission from the speaker.
- Using licensed, public-domain, or otherwise legally authorized voice material.
- Building accessibility tools, creative projects, games, podcasts, prototypes, and local workflows where the speaker's rights are respected.
## Prohibited Uses
- Impersonating someone without permission.
- Fraud, scams, phishing, social engineering, or bypassing voice authentication.
- Harassment, threats, intimidation, or non-consensual sexual content.
- Misleading political, legal, financial, medical, or emergency communications.
- Commercial use of a person's voice without the legal right to do so.
- Removing or bypassing responsible-use acknowledgements in order to misuse the software.
## Disclosure And Compliance
If you publish or distribute synthetic audio, disclose that it is AI-generated where required by law, platform policy, or audience expectations. Developers building products on top of Voicebox should treat consent records, disclosure, and jurisdiction-specific requirements as part of their own application design.
Voicebox runs locally to protect user privacy. That privacy model does not remove your responsibility to respect other people's voices.
+1 -17
View File
@@ -1,26 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voicebox</title>
<script>
(function () {
try {
var theme = 'system';
var raw = localStorage.getItem('voicebox-ui');
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
}
var resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
if (resolved === 'dark') document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head>
<body>
<div id="root"></div>
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.5.0",
"version": "0.4.1",
"private": true,
"type": "module",
"scripts": {
@@ -44,14 +44,11 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.454.0",
"motion": "^12.29.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
+1 -35
View File
@@ -1,29 +1,17 @@
import { RouterProvider } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useThemeSync } from '@/hooks/useThemeSync';
import { apiClient } from '@/lib/api/client';
import type { HealthResponse } from '@/lib/api/types';
import { useChordSync } from '@/lib/hooks/useChordSync';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import {
getDefaultServerUrl,
isLoopbackVoiceboxServerUrl,
useServerStore,
} from '@/stores/serverStore';
function isDictateView(): boolean {
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).get('view') === 'dictate';
}
import { useServerStore } from '@/stores/serverStore';
/**
* Validate that a health response has the expected Voicebox-specific shape.
@@ -76,19 +64,6 @@ const LOADING_MESSAGES = [
];
function App() {
useThemeSync();
// The dictate window runs in a separate Tauri webview that must skip
// server bootstrap (the main window owns that lifecycle) and render only
// the floating recording surface. Split into a sibling component so the
// main app's hooks are not called on the dictate path.
if (isDictateView()) {
return <DictateWindow />;
}
return <MainApp />;
}
function MainApp() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
@@ -98,10 +73,6 @@ function MainApp() {
// Automatically check for app updates on startup and show toast notifications
useAutoUpdater({ checkOnMount: true, showToast: true });
// Replay the saved chord into the Rust hotkey listener every time
// capture_settings resolves or the user edits the chord.
useChordSync();
// Sync stored setting to Rust on startup
useEffect(() => {
if (platform.metadata.isTauri) {
@@ -134,11 +105,6 @@ function MainApp() {
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
if (!platform.metadata.isTauri) {
const serverUrl = getDefaultServerUrl();
const currentServerUrl = useServerStore.getState().serverUrl;
if (currentServerUrl !== serverUrl && isLoopbackVoiceboxServerUrl(currentServerUrl)) {
useServerStore.getState().setServerUrl(serverUrl);
}
setServerReady(true); // Web assumes server is running
return;
}
-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,126 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Accessibility permission state. Without this permission the
* global chord can still record, but the synthetic-⌘V paste silently drops —
* so callers can surface an inline prompt instead of relying on the
* system-level permission dialog (which only fires once, the first time the
* app tries to post a keystroke).
*
* Triggered on three signals:
* - app mount in Tauri
* - `system:accessibility-missing` event from the dictate window's paste
* failure handler
* - window focus (cheap way to re-check after the user flips the toggle in
* System Settings and alt-tabs back)
*/
export function useAccessibilityPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_accessibility_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[accessibility] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
let unlisten: UnlistenFn | null = null;
listen('system:accessibility-missing', () => {
setNeedsPermission(true);
})
.then((fn) => {
unlisten = fn;
})
.catch(() => {});
return () => {
if (unlisten) unlisten();
};
}, [platform.metadata.isTauri]);
const openSettings = useCallback(async () => {
try {
await invoke('open_accessibility_settings');
} catch (err) {
console.warn('[accessibility] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered next to the auto-paste setting when macOS
* Accessibility permission is missing. Returns null when the permission is
* already granted.
*/
export function AccessibilityNotice() {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{t('captures.permissions.accessibility.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.permissions.accessibility.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
{t('captures.permissions.accessibility.stillMissing')}
</p>
)}
</div>
</div>
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils/cn';
export type AudioBarsMode = 'idle' | 'generating' | 'playing';
interface AudioBarsProps {
mode: AudioBarsMode;
className?: string;
barClassName?: string;
}
export function AudioBars({ mode, className, barClassName }: AudioBarsProps) {
const activeColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className={cn('flex items-center gap-[2px] h-5', className)}>
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', activeColor, barClassName)}
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' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
+39 -39
View File
@@ -1,7 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -34,7 +33,6 @@ interface AudioDevice {
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
@@ -121,14 +119,14 @@ export function AudioTab() {
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm(t('audioChannels.confirmDelete'))) {
if (await confirm('Delete this channel?')) {
deleteChannel.mutate(channelId);
}
};
@@ -142,10 +140,10 @@ export function AudioTab() {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<h2 className="text-2xl font-bold">Audio Channels</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.newChannel')}
New Channel
</Button>
</div>
@@ -160,10 +158,13 @@ export function AudioTab() {
{allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<p className="text-muted-foreground mb-4">
No audio channels yet. Create your first channel to route voices to specific
devices.
</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.empty.action')}
Create Channel
</Button>
</div>
) : (
@@ -194,7 +195,7 @@ export function AudioTab() {
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.outputDevices')}
Output Devices
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
@@ -223,7 +224,7 @@ export function AudioTab() {
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.assignedVoices')}
Assigned Voices
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
@@ -269,13 +270,13 @@ export function AudioTab() {
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<h3 className="text-lg font-semibold">Available Devices</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
? 'Default channel uses system default device'
: 'Click devices to add or remove them from the selected channel'
: 'Select a channel to assign devices'}
</p>
</div>
{allDevices.length > 0 ? (
@@ -343,8 +344,8 @@ export function AudioTab() {
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
? 'No audio devices found'
: 'Audio device selection requires Tauri'}
</p>
</div>
)}
@@ -393,7 +394,6 @@ export function AudioTab() {
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
<span className="text-sm text-muted-foreground">No voices assigned</span>
)}
</div>
);
@@ -430,7 +430,6 @@ interface CreateChannelDialogProps {
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
@@ -446,21 +445,23 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
<DialogTitle>Create Audio Channel</DialogTitle>
<DialogDescription>
Create a new audio channel (bus) to route voices to specific output devices.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('audioChannels.fields.namePlaceholder')}
placeholder="e.g., Virtual Cable, Broadcast"
/>
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Label>Output Devices</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
@@ -470,12 +471,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.selectDevice')} />
<SelectValue placeholder="Select device" />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
{device.name} {device.is_default && '(default)'}
</SelectItem>
))}
</SelectContent>
@@ -508,10 +509,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('audioChannels.createDialog.action')}
Create
</Button>
</DialogFooter>
</DialogContent>
@@ -544,7 +545,6 @@ function EditChannelDialog({
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
@@ -560,16 +560,16 @@ function EditChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
<DialogTitle>Edit Channel</DialogTitle>
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
<Label htmlFor="edit-channel-name">Channel Name</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Label>Output Devices</Label>
<Select
value=""
onValueChange={(value) => {
@@ -579,12 +579,12 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addDevice')} />
<SelectValue placeholder="Add device" />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
{device.name} {device.is_default && '(default)'}
</SelectItem>
))}
</SelectContent>
@@ -615,7 +615,7 @@ function EditChannelDialog({
)}
</div>
<div>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Label>Assigned Voices</Label>
<Select
value=""
onValueChange={(value) => {
@@ -625,7 +625,7 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addVoice')} />
<SelectValue placeholder="Add voice" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
@@ -663,10 +663,10 @@ function EditChannelDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('common.save')}
Save
</Button>
</DialogFooter>
</DialogContent>
@@ -1,198 +0,0 @@
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils/cn';
/**
* Pill state machine shared between the settings preview and the live
* recording pill in the Captures tab.
*/
export type PillState =
| 'recording'
| 'transcribing'
| 'refining'
| 'speaking'
| 'completed'
| 'rest'
| 'error';
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'captures.pill.recording',
transcribing: 'captures.pill.transcribing',
refining: 'captures.pill.refining',
speaking: 'captures.pill.speaking',
completed: 'captures.pill.completed',
};
function barModeFor(
state: Exclude<PillState, 'error'>,
): 'generating' | 'playing' | 'idle' {
if (state === 'recording' || state === 'speaking') return 'playing';
if (state === 'completed' || state === 'rest') return 'idle';
return 'generating';
}
export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) {
return (
<div className="flex items-center gap-[2px] h-5 shrink-0">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', mode === 'idle' ? 'bg-accent/30' : 'bg-accent')}
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' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
/**
* Floating pill shown during capture. `state` drives the label, dot animation,
* and bar motion; `elapsedMs` freezes at whatever the caller last passed in
* (recording advances the timer, transcribing/refining hold the final value).
* The ``error`` state renders a destructive variant — a clickable pill that
* copies its message to the clipboard on press and calls ``onDismiss``.
*/
export function CapturePill({
state,
elapsedMs,
onStop,
errorMessage,
onDismiss,
className,
}: {
state: PillState;
elapsedMs: number;
onStop?: () => void;
errorMessage?: string | null;
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (state === 'error') {
return (
<ErrorPill
message={errorMessage ?? t('captures.pill.errorFallback')}
onDismiss={onDismiss}
className={className}
/>
);
}
const visible = state !== 'rest';
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
const barMode = barModeFor(state);
const dot = (
<span className="relative flex h-2 w-2 shrink-0">
{state === 'recording' && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
)}
<span className="relative rounded-full h-2 w-2 bg-accent" />
</span>
);
const stopButton = onStop && state === 'recording' ? (
<button
type="button"
onClick={onStop}
aria-label={t('captures.pill.stopAria')}
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{dot}
</button>
) : dot;
// Completed gets an inset accent stroke (via box-shadow, not Tailwind's
// ring — ring utility doesn't compose with arbitrary shadow-[…]) to mark
// the success moment without changing the pill's dimensions.
const completedStroke =
state === 'completed'
? 'shadow-[inset_0_0_0_2px_hsl(var(--accent)/0.6)]'
: null;
return (
<div
className={cn(
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
completedStroke,
'transition-opacity duration-300 ease-out',
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
className,
)}
>
{stopButton}
<span className="text-sm font-medium shrink-0" style={{ minWidth: '104px' }}>
{labelText}
</span>
<PillAudioBars mode={barMode} />
<span className="text-xs tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
{formatElapsed(elapsedMs)}
</span>
</div>
);
}
function ErrorPill({
message,
onDismiss,
className,
}: {
message: string;
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const handleClick = async () => {
try {
await navigator.clipboard.writeText(message);
} catch {
// Clipboard access can be denied in rare webview configs — ignore,
// we still want the dismiss to land.
}
onDismiss?.();
};
return (
<button
type="button"
onClick={handleClick}
title={t('captures.pill.errorCopyTooltip')}
className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
'max-w-[380px] transition-colors',
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
className,
)}
>
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm font-medium truncate">{message}</span>
</button>
);
}
@@ -1,156 +0,0 @@
import { Loader2, Pause, Play } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { debug } from '@/lib/utils/debug';
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function CaptureInlinePlayer({
audioUrl,
fallbackDurationMs,
className,
}: {
audioUrl: string;
fallbackDurationMs?: number | null;
className?: string;
}) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const container = waveformRef.current;
if (!container) return;
const root = document.documentElement;
const cssHsla = (varName: string, alpha: number) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
if (!value) return '';
const [h, s, l] = value.split(/\s+/);
if (!h || !s || !l) return '';
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
};
const ws = WaveSurfer.create({
container,
waveColor: cssHsla('--muted-foreground', 1),
progressColor: cssHsla('--accent', 1),
cursorColor: 'transparent',
barWidth: 2,
barRadius: 2,
barGap: 2,
height: 40,
normalize: true,
interact: true,
dragToSeek: { debounceTime: 0 },
mediaControls: false,
backend: 'WebAudio',
});
ws.on('ready', () => {
setDuration(ws.getDuration());
setIsLoading(false);
setError(null);
});
ws.on('play', () => setIsPlaying(true));
ws.on('pause', () => setIsPlaying(false));
ws.on('finish', () => {
setIsPlaying(false);
setCurrentTime(ws.getDuration());
});
ws.on('timeupdate', (t) => setCurrentTime(t));
ws.on('seeking', (t) => setCurrentTime(t));
ws.on('error', (err) => {
debug.error('Inline waveform error', err);
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
});
wavesurferRef.current = ws;
return () => {
try {
ws.destroy();
} catch (err) {
debug.error('Failed to destroy inline waveform', err);
}
wavesurferRef.current = null;
};
}, []);
useEffect(() => {
const ws = wavesurferRef.current;
if (!ws) return;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
try {
if (ws.isPlaying()) ws.pause();
ws.seekTo(0);
} catch (err) {
debug.error('Failed to reset inline waveform before load', err);
}
ws.load(audioUrl).catch((err) => {
debug.error('Inline waveform load failed', err);
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
});
}, [audioUrl]);
const handlePlayPause = () => {
const ws = wavesurferRef.current;
if (!ws || isLoading) return;
if (ws.isPlaying()) {
ws.pause();
} else {
ws.play().catch((err) => {
debug.error('Inline play failed', err);
setError(err instanceof Error ? err.message : String(err));
});
}
};
const displayMs =
duration > 0
? Math.round((isPlaying || currentTime > 0 ? currentTime : duration) * 1000)
: (fallbackDurationMs ?? 0);
return (
<div className={cn('flex items-center gap-4', className)}>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full shrink-0"
onClick={handlePlayPause}
disabled={isLoading || !!error}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPlaying ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 ml-0.5 fill-current" />
)}
</Button>
<div ref={waveformRef} className="flex-1 min-w-0 h-10 select-none" />
<span className="text-xs tabular-nums text-muted-foreground font-medium shrink-0">
{error ? '—' : formatDuration(displayMs)}
</span>
</div>
);
}
@@ -1,909 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
ChevronDown,
CircleDot,
Copy,
Download,
FileAudio,
FileText,
Loader2,
Mic,
Settings2,
Sparkles,
Square,
Trash2,
Upload,
Volume2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
VoiceProfileResponse,
} from '@/lib/api/types';
import type { LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null;
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
function SourceBadge({ source }: { source: CaptureSource }) {
const { t } = useTranslation();
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label =
source === 'dictation'
? t('captures.source.dictation')
: source === 'recording'
? t('captures.source.recording')
: t('captures.source.file');
return (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
>
<Icon className="h-2.5 w-2.5" />
{label}
</Badge>
);
}
type PlaybackState = 'idle' | 'generating' | 'playing';
export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const snippetOf = (capture: CaptureResponse): string => {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || t('captures.snippetEmpty');
};
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
const [launchedPlayAsId, setLaunchedPlayAsId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const audioUrl = usePlayerStore((s) => s.audioUrl);
const playerAudioId = usePlayerStore((s) => s.audioId);
const playerIsPlaying = usePlayerStore((s) => s.isPlaying);
const isPlayerVisible = !!audioUrl;
const setIsPlaying = usePlayerStore((s) => s.setIsPlaying);
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
const pendingGenerationIds = useGenerationStore((s) => s.pendingGenerationIds);
const { settings: captureSettings, update: updateCaptureSettings } = useCaptureSettings();
const sttModel = captureSettings?.stt_model ?? 'turbo';
const llmModel = captureSettings?.llm_model ?? '0.6B';
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
const readiness = useDictationReadiness();
const session = useCaptureRecordingSession({
onCaptureCreated: (capture) => setSelectedId(capture.id),
});
const { data: capturesData, isLoading: capturesLoading } = useQuery({
queryKey: ['captures'],
queryFn: () => apiClient.listCaptures(200, 0),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const captures = capturesData?.items ?? [];
// Keep a selection. If the current selection disappears (e.g. deletion),
// fall through to the first capture, then to null.
useEffect(() => {
if (!captures.length) {
if (selectedId !== null) setSelectedId(null);
return;
}
if (!selectedId || !captures.find((c) => c.id === selectedId)) {
setSelectedId(captures[0].id);
}
}, [captures, selectedId]);
// Live sync from sibling Tauri webviews (the floating dictate window).
// ``capture:created`` carries the full row so we can seed the cache before
// the refetch lands and focus the new capture in one shot — without the
// seed, the selection-guard effect would snap back to ``captures[0]`` in
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
const capture = event.payload?.capture;
if (capture) {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
});
setSelectedId(capture.id);
}
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
unlistens.push(
listen('capture:updated', () => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return captures;
return captures.filter((c) => {
const raw = (c.transcript_raw || '').toLowerCase();
const refined = (c.transcript_refined || '').toLowerCase();
return raw.includes(q) || refined.includes(q);
});
}, [search, captures]);
const selected = captures.find((c) => c.id === selectedId) ?? null;
// Source of truth is capture_settings.default_playback_voice_id, shared
// with Settings → Captures and the MCP global default. Stale ids (e.g.
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
profiles?.[0] ||
null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
onSuccess: () => {
setDeleteDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| undefined;
return apiClient.generateSpeech({
profile_id: voice.id,
text,
language,
engine,
});
},
onSuccess: (result) => {
// /generate is queue-based — it returns a generating row with an empty
// audio_path. Hand the id to the global SSE handler which polls
// /generation/{id}/status and triggers autoplay on completion.
setLaunchedPlayAsId(result.id);
addPendingGeneration(result.id);
},
onError: (err: Error) => {
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
},
});
const playbackState: PlaybackState = playAsMutation.isPending
? 'generating'
: launchedPlayAsId && pendingGenerationIds.has(launchedPlayAsId)
? 'generating'
: launchedPlayAsId && playerAudioId === launchedPlayAsId && playerIsPlaying
? 'playing'
: 'idle';
const handleUploadClick = () => uploadInputRef.current?.click();
const handleUploadFile = (e: React.ChangeEvent<HTMLInputElement>, source: CaptureSource) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
session.uploadFile(file, source);
};
const handleCopy = async () => {
if (!selected) return;
const text = showRefined
? selected.transcript_refined || selected.transcript_raw
: selected.transcript_raw;
try {
await navigator.clipboard.writeText(text || '');
toast({ title: t('captures.toast.transcriptCopied') });
} catch {
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
}
};
const exportToastSuccess = (path: string) => {
const name = path.split(/[\\/]/).pop() ?? path;
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
};
const exportToastError = (err: unknown) => {
toast({
title: t('captures.toast.exportFailed'),
description: err instanceof Error ? err.message : String(err),
variant: 'destructive',
});
};
const handleExportAudio = async () => {
if (!selected) return;
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handleExportTranscript = async () => {
if (!selected) return;
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!text) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (!dest) return;
await writeTextFile(dest, text);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
const lines: string[] = [];
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
lines.push('');
if (capture.transcript_refined?.trim()) {
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
}
if (capture.transcript_raw?.trim()) {
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
}
return lines.join('\n');
};
const handleExportMarkdown = async () => {
if (!selected) return;
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!hasContent) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!dest) return;
await writeTextFile(dest, buildCaptureMarkdown(selected));
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handlePlayAs = (voice?: VoiceProfileResponse) => {
if (!selected) return;
// Stop the current playback when the button is in its 'playing' state
// and the user clicked the main button without picking a new voice.
if (!voice && playbackState === 'playing') {
setIsPlaying(false);
return;
}
const target = voice ?? playAsVoice;
if (!target) {
toast({
title: t('captures.toast.noVoice'),
description: t('captures.toast.noVoiceDescription'),
variant: 'destructive',
});
return;
}
if (voice && voice.id !== playAsVoiceId) {
updateCaptureSettings({ default_playback_voice_id: voice.id });
}
playAsMutation.mutate({ capture: selected, voice: target });
};
return (
<div className="h-full flex gap-0 overflow-hidden -mx-8">
<input
ref={uploadInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
<input
ref={fileInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
{/* Left: capture list */}
<div className="w-[340px] shrink-0">
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('captures.searchPlaceholder')}
/>
</ListPaneHeader>
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</ListPaneScroll>
</ListPane>
</div>
{/* Right: capture detail */}
<div className="flex-1 flex flex-col relative overflow-hidden min-w-0">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Top action bar */}
<div className="absolute top-0 left-0 right-0 z-20 px-8">
<div className="flex items-center gap-3 py-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span>
{t('captures.header.modelSummary', {
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
llm: llmModel,
})}
</span>
</div>
<div className="flex-1" />
{session.pillState !== 'hidden' && (
<CapturePill
state={session.pillState}
elapsedMs={session.pillElapsedMs}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
)}
{session.pillState === 'hidden' && (
<>
<Button variant="outline" asChild>
<Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" />
{t('captures.actions.configure')}
</Link>
</Button>
{readiness.canRecord && (
<Button
variant="outline"
onClick={handleUploadClick}
disabled={session.isUploading}
>
{session.isUploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
</Button>
)}
</>
)}
{/* Hide Dictate when recording readiness fails so the user can't kick off
a capture that has nowhere to land. Stop stays visible if a
recording is somehow already in flight (e.g. a model was
uninstalled mid-record) so the user can always cancel. */}
{(readiness.canRecord || session.isRecording) && (
<Button
onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording}
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
>
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
{t('captures.actions.stop')}
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
{t('captures.actions.dictate')}
</>
)}
</Button>
)}
</div>
</div>
{selected ? (
<div
className={cn(
'flex-1 overflow-y-auto pt-20 px-8 pb-8',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatAbsoluteDate(selected.created_at)}</span>
{selected.language && (
<>
<span className="text-muted-foreground/40">·</span>
<span>{selected.language.toUpperCase()}</span>
</>
)}
<span className="text-muted-foreground/40">·</span>
<SourceBadge source={selected.source} />
</div>
{/* Audio player card */}
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
<CaptureInlinePlayer
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
fallbackDurationMs={selected.duration_ms}
/>
</div>
{/* Transcript header */}
<div className="flex items-center gap-3 mb-3">
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
<button
type="button"
onClick={() => setShowRefined(true)}
disabled={!selected.transcript_refined}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
showRefined && selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground disabled:opacity-40',
)}
>
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
{t('captures.transcript.refined')}
</button>
<button
type="button"
onClick={() => setShowRefined(false)}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
!showRefined || !selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
{t('captures.transcript.raw')}
</button>
</div>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{showRefined && selected.transcript_refined
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
: selected.stt_model
? t('captures.transcript.rawHint', { model: selected.stt_model })
: null}
</span>
</div>
{/* Transcript body */}
<div className="rounded-xl border border-border bg-muted/10">
<Textarea
key={`${selected.id}-${showRefined}`}
defaultValue={
showRefined && selected.transcript_refined
? selected.transcript_refined
: selected.transcript_raw
}
readOnly
className="text-[15px] leading-relaxed min-h-[260px] border-0 bg-transparent resize-none focus-visible:ring-0 focus-visible:ring-offset-0 p-6"
/>
</div>
{/* Bottom actions */}
<div className="flex items-center gap-2 mt-4 flex-wrap">
<div className="inline-flex">
<Button
variant="outline"
size="sm"
onClick={() => handlePlayAs()}
disabled={!playAsVoice || playAsMutation.isPending}
className={cn(
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
>
{playbackState === 'generating' ? (
<>
<AudioBars mode="generating" className="h-3.5" />
{t('captures.actions.playAsGenerating')}
</>
) : playbackState === 'playing' ? (
<>
<Square className="h-3 w-3 fill-current" />
{playAsVoice
? t('captures.actions.playAsStop', { name: playAsVoice.name })
: t('captures.actions.playAsStopFallback')}
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5" />
{playAsVoice
? t('captures.actions.playAs', { name: playAsVoice.name })
: t('captures.actions.playAsFallback')}
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'rounded-l-none px-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
disabled={!profiles || !profiles.length}
>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.playAsDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="py-2"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
{v.description || v.language.toUpperCase()}
</div>
</div>
{v.id === playAsVoiceId && (
<Check className="h-3.5 w-3.5 text-accent shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.copy')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => session.refine(selected.id)}
disabled={session.isRefining}
>
{session.isRefining ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
{selected.transcript_refined
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.export')}
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.exportDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleExportAudio}>
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportAudio')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportTranscript}>
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportTranscript')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportMarkdown')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteMutation.isPending}
className="text-muted-foreground "
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{t('captures.actions.delete')}
</Button>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
{capturesLoading ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.loading')}</p>
</div>
) : captures.length ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.pickOne')}</p>
</div>
) : hotkeyEnabled && !readiness.canRecord ? (
<DictationReadinessChecklist readiness={readiness} />
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
<div className="max-w-sm mx-auto text-center space-y-5">
<div className="space-y-2">
{pushToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={pushToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('captures.empty.holdToRecord')}
</span>
</div>
) : null}
{toggleToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={toggleToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('captures.empty.toggleHandsFree')}
</span>
</div>
) : null}
</div>
<p className="text-sm">
{t('captures.empty.pressShortcut')}
</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.none')}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('captures.empty.turnOnShortcut')}
</p>
<Button asChild variant="outline" size="sm">
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
</Button>
</div>
)}
</div>
)}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => selected && deleteMutation.mutate(selected.id)}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,287 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Accessibility,
CheckCircle2,
Circle,
Cpu,
Download,
ExternalLink,
Keyboard,
Loader2,
} from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
import { cn } from '@/lib/utils/cn';
interface RowProps {
icon: React.ReactNode;
title: string;
description: string;
ready: boolean;
action?: React.ReactNode;
}
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
return (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
ready ? 'border-accent/20 bg-accent/5' : 'border-border bg-muted/20',
)}
>
<div className="mt-0.5 shrink-0">
{ready ? (
<CheckCircle2 className="h-5 w-5 text-accent" />
) : (
<Circle className="h-5 w-5 text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
<p className="text-sm font-medium text-foreground">{title}</p>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
</div>
</div>
);
}
function progressPercent(task: ActiveDownloadTask | undefined): number | null {
if (!task) return null;
if (typeof task.progress === 'number')
return Math.round(Math.max(0, Math.min(100, task.progress)));
if (task.current && task.total) return Math.round((task.current / task.total) * 100);
return null;
}
/**
* Renders one row per dictation-readiness gate. Each unmet gate gets an
* inline action — Download for missing models, Open Settings for missing
* TCC permissions — so the user can resolve everything without leaving
* Captures.
*
* Download-in-progress state is sourced from ``/tasks/active`` (same query
* the Models page uses) so it survives unmount: navigating away and back
* still shows "Downloading…" instead of resetting to "Download".
*
* The chord stays disarmed until every row is green; this is what stops the
* "stuck pill" failure mode of pressing the chord with a missing model.
*
* ``compact`` drops the centered title/subheading block and the
* empty-state max-width so the checklist can be embedded in a narrow
* sidebar alongside other settings. Callers own their own heading in
* that mode (typically an ``<h3>`` that matches the surrounding sidebar
* section style).
*/
export function DictationReadinessChecklist({
readiness,
compact = false,
}: {
readiness: DictationReadiness;
compact?: boolean;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(),
// Mirror ModelManagement's cadence: 1s while a download is in flight,
// 5s otherwise. Keeps progress feeling live without hammering when idle.
refetchInterval: (query) => {
const data = query.state.data;
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
return hasActive ? 1000 : 5000;
},
});
// Memo so the Map identity is stable across renders that don't change
// activeTasks — otherwise the cleanup effect below saw a fresh Map every
// render and re-fired on every 1 s poll tick.
const downloadByModel = useMemo(() => {
const m = new Map<string, ActiveDownloadTask>();
for (const dl of activeTasks?.downloads ?? []) {
if (dl.status === 'downloading') m.set(dl.model_name, dl);
}
return m;
}, [activeTasks]);
// When a download disappears from activeTasks, it just finished — refetch
// readiness immediately so the row flips to ✓ instead of waiting up to 5s
// for the next readiness poll.
const prevActive = useRef<Set<string>>(new Set());
useEffect(() => {
const current = new Set(downloadByModel.keys());
for (const name of prevActive.current) {
if (!current.has(name)) {
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
break;
}
}
prevActive.current = current;
}, [downloadByModel, queryClient]);
const downloadMutation = useMutation({
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
apiClient.triggerModelDownload(modelName),
onSuccess: (_data, vars) => {
// Bump activeTasks so the row immediately shows "Downloading…" without
// waiting for the next 5s poll. modelStatus + readiness invalidations
// keep adjacent UI in sync.
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({
title: t('captures.readiness.downloadStarted'),
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
});
},
onError: (err: Error) => {
toast({
title: t('captures.readiness.downloadFailed'),
description: err.message,
variant: 'destructive',
});
},
});
const sttSize =
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
const llmSize =
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
function modelDownloadButton(
gate: 'stt' | 'llm',
modelName: string,
ready: boolean,
): React.ReactNode {
const task = downloadByModel.get(modelName);
const downloading = !ready && !!task;
const pct = progressPercent(task);
return (
<Button
size="sm"
onClick={() => downloadMutation.mutate({ gate, modelName })}
disabled={downloading || downloadMutation.isPending}
className="gap-1.5"
>
{downloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null
? t('captures.readiness.downloadingPercent', { pct })
: t('captures.readiness.downloading')}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
{t('captures.readiness.downloadButton')}
</>
)}
</Button>
);
}
return (
<div className={cn('w-full space-y-2.5', !compact && 'max-w-md mx-auto')}>
{!compact && (
<div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground">
{t('captures.readiness.title')}
</h2>
<p className="text-xs text-muted-foreground">
{t('captures.readiness.subheading')}
</p>
</div>
)}
{readiness.stt && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={
readiness.stt.ready
? t('captures.readiness.stt.ready')
: sttSize
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
: t('captures.readiness.stt.missing')
}
ready={readiness.stt.ready}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
/>
)}
{readiness.llm && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={
readiness.llm.ready
? t('captures.readiness.llm.ready')
: llmSize
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
: t('captures.readiness.llm.missing')
}
ready={readiness.llm.ready}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
/>
)}
{/* Input Monitoring + Accessibility are macOS-only TCC permissions.
The Rust stubs return true on Windows/Linux, so rendering these
rows there would show permanent green checkmarks with copy
that talks about macOS — noise. Hide on non-mac. */}
{isMacOS && (
<ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />}
title={t('captures.readiness.inputMonitoring.label')}
description={
readiness.inputMonitoring
? t('captures.readiness.inputMonitoring.ready')
: t('captures.readiness.inputMonitoring.missing')
}
ready={readiness.inputMonitoring}
action={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.readiness.inputMonitoring.openSettings')}
</Button>
}
/>
)}
{isMacOS && (
<ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />}
title={t('captures.readiness.accessibility.label')}
description={
readiness.accessibility
? t('captures.readiness.accessibility.ready')
: t('captures.readiness.accessibility.missing')
}
ready={readiness.accessibility}
action={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.readiness.accessibility.openSettings')}
</Button>
}
/>
)}
</div>
);
}
const isMacOS =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
@@ -1,209 +0,0 @@
import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
canonicalKeyFromEvent,
displayLabelForKey,
modifierSideHint,
sortChordKeys,
} from '@/lib/utils/keyCodes';
import { cn } from '@/lib/utils/cn';
interface ChordPickerProps {
open: boolean;
/** Title shown in the modal — caller picks "push-to-talk" vs "toggle". */
title: string;
description?: string;
/** The chord currently saved, shown as the starting state. */
initialKeys: string[];
onSave: (keys: string[]) => void;
onCancel: () => void;
}
/**
* Modal that captures a key chord from the browser keyboard. Tracks the
* peak set of keys held during the session so the user can release
* before clicking Save (otherwise they'd be saving while still holding
* the shortcut, which is awkward).
*
* Browser limitation: we can only capture keys while Voicebox has key
* focus, so the picker pulls focus to a hidden capture surface inside
* the dialog. The actual chord runs through the Rust global hook —
* this picker only writes the configuration the hook reads.
*/
export function ChordPicker({
open,
title,
description,
initialKeys,
onSave,
onCancel,
}: ChordPickerProps) {
const { t } = useTranslation();
// Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value.
const [pressed, setPressed] = useState<Set<string>>(new Set());
const [captured, setCaptured] = useState<string[]>(initialKeys);
const [unsupportedAttempt, setUnsupportedAttempt] = useState<string | null>(null);
const captureRef = useRef<HTMLDivElement>(null);
// Reset every time the modal re-opens — otherwise the previous picker
// session's peak set leaks into the next open and confuses the user.
useEffect(() => {
if (open) {
setPressed(new Set());
setCaptured(initialKeys);
setUnsupportedAttempt(null);
// Defer focus to the next paint so the dialog is mounted.
const timeoutId = window.setTimeout(() => captureRef.current?.focus(), 50);
return () => window.clearTimeout(timeoutId);
}
return;
}, [open, initialKeys]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Esc reaches the dialog's onOpenChange and closes the modal — let
// it pass through unmodified.
if (event.key === 'Escape') return;
// Tab cycles focus inside the dialog; capturing it would trap the
// user. Same for the dialog's own keyboard interactions.
if (event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) {
setUnsupportedAttempt(event.code || event.key || 'unknown');
event.preventDefault();
return;
}
event.preventDefault();
event.stopPropagation();
setUnsupportedAttempt(null);
setPressed((prev) => {
if (prev.has(canonical)) return prev;
const next = new Set(prev);
next.add(canonical);
setCaptured((prevCaptured) => {
const candidate = sortChordKeys(Array.from(next));
// First key in a fresh sequence replaces the peak — otherwise a
// user trying to swap a longer saved chord for a shorter one is
// stuck because their candidate never beats the seed length.
if (prev.size === 0) return candidate;
return candidate.length >= prevCaptured.length ? candidate : prevCaptured;
});
return next;
});
},
[],
);
const handleKeyUp = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) return;
event.preventDefault();
setPressed((prev) => {
if (!prev.has(canonical)) return prev;
const next = new Set(prev);
next.delete(canonical);
return next;
});
}, []);
// Wire global listeners only while open. Capture phase so Voicebox's
// own command palette / global shortcuts don't swallow the chord first.
useEffect(() => {
if (!open) return;
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
};
}, [open, handleKeyDown, handleKeyUp]);
const displayKeys = pressed.size > 0
? sortChordKeys(Array.from(pressed))
: captured;
const canSave = captured.length > 0;
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<div
ref={captureRef}
tabIndex={-1}
className="rounded-lg border border-border bg-muted/30 p-6 outline-none focus:ring-2 focus:ring-accent"
>
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
{displayKeys.length === 0 ? (
<span className="text-sm text-muted-foreground italic">
{t('captures.chord.noKeys')}
</span>
) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />)
)}
</div>
{unsupportedAttempt ? (
<p className="text-xs text-destructive">
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
</p>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ChordKey({ name }: { name: string }) {
const side = modifierSideHint(name);
return (
<span
className={cn(
'relative inline-flex items-center justify-center h-8 min-w-[2rem] px-2',
'rounded-md border border-border bg-background font-mono text-sm font-medium',
'shadow-sm text-foreground',
)}
>
{displayLabelForKey(name)}
{side ? (
<span className="absolute -top-1 -right-1 h-3.5 min-w-[0.875rem] px-0.5 rounded-sm bg-accent text-[8px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
}
@@ -1,314 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useRef, useState } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
* Mounted when the URL contains ``?view=dictate``. The main window bypasses
* this branch and renders the full app shell.
*
* The pill surfaces for two independent cycles:
* 1. User dictation — driven by ``dictate:start`` / ``dictate:stop``
* from the Rust hotkey monitor.
* 2. Agent speech — driven by ``dictate:speak-start`` / ``dictate:speak-end``
* from the Rust ``speak_monitor`` (which owns the backend SSE stream).
* On speak-start we subscribe to this single generation's status SSE,
* then play ``/audio/{id}`` via a plain ``HTMLAudioElement`` when it
* lands. When the audio element's ``ended`` fires, we emit
* ``dictate:hide`` so Rust tucks the window away.
*/
export function DictateWindow() {
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
const prevHtml = document.documentElement.style.background;
const prevBody = document.body.style.background;
document.documentElement.style.background = 'transparent';
document.body.style.background = 'transparent';
return () => {
document.documentElement.style.background = prevHtml;
document.body.style.background = prevBody;
};
}, []);
// Mirrored from the main window: true only when dictation is armed and the
// user opted into keeping the microphone ready.
const [micWarm, setMicWarm] = useState(false);
const session = useCaptureRecordingSession({
keepMicWarm: micWarm,
onFinalText: async (text, _capture, allowAutoPaste, context) => {
// Focus is the snapshot taken at chord-start and threaded through as this
// take's context, so it survives the 1–2 s transcribe + refine window and
// overlapping dictations can't paste into each other's target.
const focus = context as FocusSnapshot | null;
if (!allowAutoPaste) return;
if (!focus || !text.trim()) return;
try {
await invoke('paste_final_text', { text, focus });
} catch (err) {
// Surface accessibility failures to the main window so it can prompt
// the user to grant permission. Other errors stay swallowed —
// the transcription still landed in the captures list.
const msg = err instanceof Error ? err.message : String(err);
if (/accessibility/i.test(msg)) {
emit('system:accessibility-missing').catch(() => {});
}
console.warn('[dictate] paste_final_text failed:', err);
}
},
});
// Route the chord events emitted from Rust into the session hook. Using a
// ref so the `listen` effect only subscribes once — rebinding every render
// would thrash the Tauri event bridge.
const sessionRef = useRef(session);
sessionRef.current = session;
useEffect(() => {
let disposed = false;
const unlistens: UnlistenFn[] = [];
const registrations = [
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
sessionRef.current.startRecording(event.payload?.focus ?? null);
}),
listen('dictate:stop', () => {
// Forward stops that arrive while getUserMedia is still resolving.
sessionRef.current.stopRecording();
}),
listen<boolean>('dictate:warm', (event) => {
setMicWarm(Boolean(event.payload));
}),
];
Promise.all(registrations)
.then((registered) => {
if (disposed) {
for (const unlisten of registered) unlisten();
return;
}
unlistens.push(...registered);
emit('dictate:warm-request').catch(() => {});
})
.catch((err) => console.warn('[dictate] event listener registration failed:', err));
return () => {
disposed = true;
for (const unlisten of unlistens) unlisten();
};
}, []);
useEffect(() => {
if (micWarm) void session.prewarm();
else session.releaseWarm();
}, [micWarm, session.prewarm, session.releaseWarm]);
// --- Agent-speak cycle ---------------------------------------------------
const [speaking, setSpeaking] = useState<{
generationId: string;
// Null while the backend is still generating audio; set to the
// wall-clock timestamp when audio playback actually begins, so the
// pill's elapsed counter only ticks while sound is coming out.
startedAt: number | null;
} | null>(null);
const [speakElapsed, setSpeakElapsed] = useState(0);
// Refs so handlers inside long-lived `listen()` callbacks can read the
// latest state without re-subscribing on every render.
const speakingRef = useRef<typeof speaking>(null);
speakingRef.current = speaking;
const statusSourceRef = useRef<EventSource | null>(null);
const statusTimeoutRef = useRef<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const clearStatusTimeout = () => {
if (statusTimeoutRef.current !== null) {
window.clearTimeout(statusTimeoutRef.current);
statusTimeoutRef.current = null;
}
};
const dismissSpeak = (id?: string) => {
// Guard against a late dismiss targeting a stale cycle (a new speak
// already started by the time audio.ended from the previous one fired).
if (id && speakingRef.current && speakingRef.current.generationId !== id) return;
statusSourceRef.current?.close();
statusSourceRef.current = null;
clearStatusTimeout();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
setSpeaking(null);
};
const startSpeakPlayback = (generationId: string) => {
const audio = new Audio(apiClient.getAudioUrl(generationId));
audio.onended = () => dismissSpeak(generationId);
audio.onerror = () => dismissSpeak(generationId);
// The pill window stays hidden through the ~1 s generation wait so the
// user doesn't see a silent pill. We surface it the moment audio
// actually starts playing, and that's also when the elapsed counter
// arms.
audio.onplaying = () => {
emit('dictate:show').catch(() => {});
setSpeaking((prev) =>
prev && prev.generationId === generationId
? { ...prev, startedAt: Date.now() }
: prev,
);
setSpeakElapsed(0);
};
audioRef.current = audio;
audio.play().catch((err) => {
console.warn('[dictate] audio.play failed:', err);
dismissSpeak(generationId);
});
};
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
// the payload shape for speak-start is
// {generation_id, profile_name, source, client_id}.
unlistens.push(
listen<string>('dictate:speak-start', (event) => {
let parsed: { generation_id?: string } = {};
try {
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
} catch {
return;
}
const id = parsed.generation_id;
if (!id) return;
// Tear down any previous cycle — last speak wins.
dismissSpeak();
setSpeaking({ generationId: id, startedAt: null });
setSpeakElapsed(0);
// Subscribe to this one generation's status. When it completes, the
// `/audio/{id}` endpoint will serve the WAV we need to play.
const source = new EventSource(apiClient.getGenerationStatusUrl(id));
statusSourceRef.current = source;
// Hard cap on how long the pill can sit in the 'speaking' state
// without ever hearing back from the backend. Covers the case where
// the gen row is deleted mid-flight (SSE 404s and EventSource silently
// retries) or the backend goes away while a request is in flight.
// Clears as soon as a real status event lands.
clearStatusTimeout();
statusTimeoutRef.current = window.setTimeout(() => {
statusTimeoutRef.current = null;
if (speakingRef.current?.generationId === id && !audioRef.current) {
dismissSpeak(id);
}
}, 60_000);
source.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data) as { status?: string };
if (data.status === 'completed') {
clearStatusTimeout();
source.close();
if (statusSourceRef.current === source) statusSourceRef.current = null;
startSpeakPlayback(id);
} else if (data.status === 'failed' || data.status === 'not_found') {
clearStatusTimeout();
source.close();
dismissSpeak(id);
}
} catch {
// heartbeats / junk — ignore.
}
};
source.onerror = () => {
// EventSource auto-reconnects on transient drops; the timeout above
// is the backstop for the case where it never recovers.
};
}),
);
// Speak-end from the backend is advisory: the authoritative dismiss is
// `audio.ended`. But if generation failed or nothing ever triggered
// playback, a short grace window followed by forced dismiss avoids a
// stuck-visible pill.
unlistens.push(
listen<string>('dictate:speak-end', (event) => {
let parsed: { generation_id?: string; status?: string } = {};
try {
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
} catch {
return;
}
if (parsed.status && parsed.status !== 'completed') {
// Failed / cancelled — dismiss immediately.
if (parsed.generation_id) dismissSpeak(parsed.generation_id);
return;
}
// Completed: if audio never started (shouldn't happen, but guard),
// auto-dismiss after 15 s so the pill never stays forever.
const id = parsed.generation_id;
window.setTimeout(() => {
if (speakingRef.current?.generationId === id && !audioRef.current) {
dismissSpeak(id);
}
}, 15_000);
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
dismissSpeak();
};
}, []);
// Advance the pill's elapsed-time label while audio is playing. Paused
// during the pre-playback generation window (startedAt is null) so the
// counter stays at 0:00 until sound actually starts.
useEffect(() => {
if (!speaking?.startedAt) return;
const anchor = speaking.startedAt;
const iv = window.setInterval(() => {
setSpeakElapsed(Date.now() - anchor);
}, 250);
return () => window.clearInterval(iv);
}, [speaking?.generationId, speaking?.startedAt]);
// --- Effective pill state -----------------------------------------------
const isSpeaking = Boolean(speaking);
const effectiveState = isSpeaking ? 'speaking' : session.pillState;
const effectiveElapsed = isSpeaking ? speakElapsed : session.pillElapsedMs;
// When the pill cycle ends (no capture AND no speak), tell Rust to tuck
// the window away. Rust owns the hide + park-off-screen + click-through
// combo because calling hide() directly from JS has been unreliable for
// transparent always-on-top windows on macOS.
useEffect(() => {
if (effectiveState === 'hidden') {
emit('dictate:hide').catch(() => {});
}
}, [effectiveState]);
return (
<div
className="h-screen w-screen flex items-center justify-center px-3"
style={{ background: 'transparent' }}
>
{effectiveState !== 'hidden' ? (
<CapturePill
state={effectiveState}
elapsedMs={effectiveElapsed}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
) : null}
</div>
);
}
@@ -18,7 +18,6 @@ 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 { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
@@ -56,7 +55,6 @@ export function EffectsChainEditor({
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
@@ -179,27 +177,17 @@ export function EffectsChainEditor({
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder={t('effects.chain.loadPreset')} />
<SelectValue placeholder="Load preset..." />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => {
const name = p.is_builtin
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
: p.name;
const description = p.is_builtin
? t(`effects.builtinPresets.${p.name}.description`, {
defaultValue: p.description ?? '',
})
: p.description;
return (
<SelectItem key={p.id} value={p.id}>
{name}
{description && (
<span className="ml-1 text-muted-foreground">- {description}</span>
)}
</SelectItem>
);
})}
{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>
@@ -210,7 +198,7 @@ export function EffectsChainEditor({
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
{t('effects.chain.clear')}
Clear
</Button>
)}
</div>
@@ -241,12 +229,12 @@ export function EffectsChainEditor({
<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={t('effects.chain.addEffect')} />
<SelectValue placeholder="Add effect..." />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
{e.label}
</SelectItem>
))}
</SelectContent>
@@ -282,7 +270,6 @@ function SortableEffectItem({
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
@@ -293,9 +280,7 @@ function SortableEffectItem({
zIndex: isDragging ? 10 : undefined,
};
const label = t(`effects.types.${effect.type}.label`, {
defaultValue: effectDef?.label ?? effect.type,
});
const label = effectDef?.label ?? effect.type;
return (
<div
@@ -343,16 +328,16 @@ function SortableEffectItem({
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
title={effect.enabled ? 'Disable' : 'Enable'}
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-0.5 text-muted-foreground "
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={onRemove}
title={t('effects.chain.remove')}
title="Remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -367,9 +352,7 @@ function SortableEffectItem({
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{t(`effects.types.${effect.type}.params.${paramName}`, {
defaultValue: paramDef.description,
})}
{paramDef.description}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
+54 -67
View File
@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
@@ -26,7 +25,6 @@ import { useEffectsStore } from '@/stores/effectsStore';
import { usePlayerStore } from '@/stores/playerStore';
export function EffectsDetail() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const workingChain = useEffectsStore((s) => s.workingChain);
@@ -97,18 +95,6 @@ export function EffectsDetail() {
const isEditing = !!selectedPresetId || isCreatingNew;
const isBuiltIn = preset?.is_builtin ?? false;
const presetName = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name
: '';
const presetDescription = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description
: '';
async function handlePreview() {
if (!previewGenId || workingChain.length === 0) return;
@@ -129,8 +115,8 @@ export function EffectsDetail() {
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: t('effects.toast.previewFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Preview failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
@@ -144,7 +130,7 @@ export function EffectsDetail() {
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
@@ -157,14 +143,11 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
@@ -183,11 +166,11 @@ export function EffectsDetail() {
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: t('effects.toast.updated') });
toast({ title: 'Preset updated' });
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
@@ -196,15 +179,15 @@ export function EffectsDetail() {
}
function handleSaveAsNew() {
const sourceName = isBuiltIn ? presetName : name;
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
// 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: t('effects.toast.nameRequired'), variant: 'destructive' });
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
@@ -217,14 +200,11 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
@@ -240,11 +220,11 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: t('effects.toast.deleted') });
toast({ title: 'Preset deleted' });
} catch (error) {
toast({
title: t('effects.toast.deleteFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Failed to delete',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
@@ -257,7 +237,7 @@ export function EffectsDetail() {
<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">{t('effects.placeholder')}</p>
<p className="text-sm">Select a preset or create a new one</p>
</div>
</div>
);
@@ -265,13 +245,10 @@ export function EffectsDetail() {
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
? t('effects.detail.newTitle')
: isBuiltIn
? presetName
: t('effects.detail.editTitle')}
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
@@ -279,12 +256,12 @@ export function EffectsDetail() {
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive gap-1.5"
className="h-8 text-destructive hover:text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? t('effects.detail.deleting') : t('common.delete')}
{deleting ? 'Deleting...' : 'Delete'}
</Button>
<Button
size="sm"
@@ -293,7 +270,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('common.save')}
{saving ? 'Saving...' : 'Save'}
</Button>
</>
)}
@@ -305,7 +282,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
{saving ? 'Saving...' : 'Save Preset'}
</Button>
)}
{isBuiltIn && (
@@ -317,46 +294,51 @@ export function EffectsDetail() {
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
{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">{t('effects.fields.name')}</Label>
<Label className="text-xs">Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('effects.fields.namePlaceholder')}
placeholder="My preset..."
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Label className="text-xs">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('effects.fields.descriptionPlaceholder')}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{isBuiltIn && presetDescription && (
<p className="text-sm text-muted-foreground">{presetDescription}</p>
{/* 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">{t('effects.preview.label')}</Label>
<Label className="text-xs">Preview</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
@@ -373,33 +355,38 @@ export function EffectsDetail() {
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('effects.preview.processing')}
Processing...
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
{t('effects.preview.button')}
Preview
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
<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>{t('effects.saveAs.title')}</DialogTitle>
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
<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">{t('effects.fields.name')}</Label>
<Label className="text-xs">Name</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder={t('effects.fields.namePlaceholder')}
placeholder="My preset..."
className="h-9"
autoFocus
onKeyDown={(e) => {
@@ -410,22 +397,22 @@ export function EffectsDetail() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Label className="text-xs">Description</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder={t('effects.fields.descriptionPlaceholder')}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
{t('common.cancel')}
Cancel
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? t('effects.detail.saving') : t('common.save')}
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
+67 -85
View File
@@ -1,14 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
@@ -16,7 +7,6 @@ import { cn } from '@/lib/utils/cn';
import { useEffectsStore } from '@/stores/effectsStore';
export function EffectsList() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
@@ -51,74 +41,75 @@ export function EffectsList() {
}
return (
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('effects.newPreset')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
</ListPaneHeader>
<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>
<ListPaneScroll className="pt-16">
<div className="px-4 pb-6 space-y-4">
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</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>
{/* 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>
)}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.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>
{/* 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>
)}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.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">{t('effects.unsaved.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</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>
</ListPaneScroll>
</ListPane>
<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>
);
}
@@ -131,16 +122,7 @@ function PresetCard({
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const effectCount = preset.effects_chain.length;
const name = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name;
const description = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description;
return (
<button
@@ -157,19 +139,19 @@ function PresetCard({
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{name}</span>
<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">
{t('effects.badge.builtin')}
built-in
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{description || t('effects.noDescription')}
{preset.description || 'No description'}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{t('effects.effectCount', { count: effectCount })}
{effectCount} effect{effectCount !== 1 ? 's' : ''}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
+2 -2
View File
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
<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">
@@ -11,7 +11,7 @@ export function EffectsTab() {
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col pr-8">
<div className="flex-1 min-h-0 flex flex-col">
<EffectsDetail />
</div>
</div>
@@ -1,9 +1,8 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
@@ -14,7 +13,6 @@ import {
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 { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
@@ -36,7 +34,6 @@ export function FloatingGenerateBox({
isPlayerOpen = false,
showVoiceSelector = false,
}: FloatingGenerateBoxProps) {
const { t } = useTranslation();
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
@@ -53,21 +50,6 @@ export function FloatingGenerateBox({
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onError: (err: Error) => {
toast({
title: t('generation.compose.failedTitle'),
description: err.message || t('generation.compose.failedDescription'),
variant: 'destructive',
});
},
});
// Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({
@@ -191,10 +173,6 @@ export function FloatingGenerateBox({
) {
setSelectedPresetId(null);
}
// Persona toggle only applies when the profile has a personality prompt.
if (selectedProfile && !selectedProfile.personality?.trim()) {
form.setValue('personality', false);
}
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
@@ -255,10 +233,10 @@ export function FloatingGenerateBox({
<motion.div
ref={containerRef}
className={cn(
'fixed',
'fixed right-auto',
isStoriesRoute
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
'left-[calc(5rem+360px+1.5rem)] right-8'
? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
@@ -298,12 +276,10 @@ export function FloatingGenerateBox({
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? t('generation.placeholder.storyWithEffects', {
name: currentStory.name,
})
? `Generate speech for "${currentStory.name}"... (type / for effects)`
: selectedProfile
? t('generation.placeholder.effectsHint')
: t('generation.placeholder.selectVoice')
? `Type / for effects like [laugh], [sigh]...`
: 'Select a voice profile above...'
}
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
style={{
@@ -326,12 +302,10 @@ export function FloatingGenerateBox({
}}
placeholder={
isStoriesRoute && currentStory
? t('generation.placeholder.story', { name: currentStory.name })
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? t('generation.placeholder.profile', {
name: selectedProfile.name,
})
: t('generation.placeholder.selectVoice')
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
style={{
@@ -351,90 +325,35 @@ export function FloatingGenerateBox({
/>
</motion.div>
<div className="flex items-start gap-2 shrink-0">
{/* Compose — fills the textarea with a fresh in-character line. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
disabled={composeMutation.isPending || !selectedProfileId}
onClick={async () => {
const result = await composeMutation.mutateAsync();
form.setValue('text', result.text, { shouldDirty: true });
setIsExpanded(true);
}}
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
aria-label={t('generation.compose.ariaLabel')}
>
{composeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Dices 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]">
{t('generation.compose.tooltip')}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<FormField
control={form.control}
name="personality"
render={({ field }) => {
const active = !!field.value;
return (
<FormItem className="space-y-0">
<FormControl>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => field.onChange(!active)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
active
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
aria-pressed={active}
>
<Wand2 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]">
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
</span>
</div>
</FormControl>
</FormItem>
);
}}
/>
</motion.div>
)}
</AnimatePresence>
<div className="relative shrink-0">
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles 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]">
{isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'}
</span>
</div>
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence>
@@ -444,6 +363,7 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<div className="group relative">
<Button
@@ -459,49 +379,20 @@ export function FloatingGenerateBox({
)}
aria-label={
isInstructExpanded
? t('generation.instruct.hide')
: t('generation.instruct.show')
? 'Hide delivery instructions'
: 'Show delivery instructions'
}
aria-pressed={isInstructExpanded}
>
<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]">
{t('generation.instruct.tooltip')}
Delivery instructions (tone, emotion, pace)
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles 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]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
</div>
</div>
@@ -523,7 +414,7 @@ export function FloatingGenerateBox({
<FormControl>
<Textarea
{...field}
placeholder={t('generation.instruct.placeholder')}
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
style={{ minHeight: '60px', maxHeight: '160px' }}
maxLength={500}
@@ -553,7 +444,7 @@ export function FloatingGenerateBox({
onValueChange={(value) => setSelectedProfileId(value || null)}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
<SelectValue placeholder="Select a voice..." />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
@@ -566,7 +457,6 @@ export function FloatingGenerateBox({
</div>
)}
<FormField
control={form.control}
name="language"
@@ -608,16 +498,16 @@ export function FloatingGenerateBox({
}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder={t('generation.effects.none')} />
<SelectValue placeholder="No effects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs">
{t('generation.effects.none')}
No effects
</SelectItem>
{selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 && (
<SelectItem value="_profile" className="text-xs">
{t('generation.effects.profileDefault')}
Profile default
</SelectItem>
)}
{effectPresets?.map((preset) => (
@@ -0,0 +1,220 @@
import { Loader2, Mic } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
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 { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
return (
<Card>
<CardHeader>
<CardTitle>Generate Speech</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Voice Profile</FormLabel>
{selectedProfile ? (
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<Mic className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{selectedProfile.name}</span>
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
</div>
) : (
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
Click on a profile card above to select a voice profile
</div>
)}
</div>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder="Enter text... type / for effects like [laugh], [sigh]"
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
/>
) : (
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
)}
</FormControl>
<FormDescription>
{form.watch('engine') === 'chatterbox_turbo'
? 'Max 5000 characters. Type / to insert sound effects.'
: 'Max 5000 characters'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('engine') === 'qwen_custom_voice' && (
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion,
pace). Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<EngineModelSelector form={form} selectedProfile={selectedProfile} />
<FormDescription>
{getEngineDescription(form.watch('engine') || 'qwen')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
return (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="seed"
render={({ field }) => (
<FormItem>
<FormLabel>Seed (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Random"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormDescription>For reproducible results</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
+66 -41
View File
@@ -14,9 +14,7 @@ import {
Wand2,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
@@ -58,8 +56,38 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
// ─── Audio Bars ─────────────────────────────────────────────────────────────
function AudioBars({ 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={`${mode}-${i}`}
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' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
export function HistoryTable() {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
const [total, setTotal] = useState(0);
@@ -432,7 +460,7 @@ export function HistoryTable() {
<div className="flex flex-col h-full min-h-0 relative">
{history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
{t('history.empty')}
No voice generations, yet...
</div>
) : (
<>
@@ -444,7 +472,7 @@ export function HistoryTable() {
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
onClick={() => setClearFailedDialogOpen(true)}
disabled={clearFailed.isPending}
>
@@ -643,47 +671,46 @@ export function HistoryTable() {
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label={t('history.actions.menu')}
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)}
>
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
<Play className="mr-2 h-4 w-4" />
{t('history.actions.play')}
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
{t('history.actions.exportAudio')}
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
{t('history.actions.exportPackage')}
Export Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
<Wand2 className="mr-2 h-4 w-4" />
{t('history.actions.applyEffects')}
Apply Effects
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('history.actions.regenerate')}
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" />
{t('common.delete')}
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -775,9 +802,10 @@ export function HistoryTable() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
<DialogTitle>Delete Generation</DialogTitle>
<DialogDescription>
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -788,14 +816,14 @@ export function HistoryTable() {
setGenerationToDelete(null);
}}
>
{t('common.cancel')}
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteGeneration.isPending}
>
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
@@ -804,23 +832,23 @@ export function HistoryTable() {
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
<DialogTitle>Clear failed generations</DialogTitle>
<DialogDescription>
{t('history.clearFailedDialog.body', { count: failedCount })}
This will permanently delete {failedCount} failed{' '}
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button
variant="destructive"
onClick={handleClearFailedConfirm}
disabled={clearFailed.isPending}
>
{clearFailed.isPending
? t('history.clearFailedDialog.clearing')
: t('history.clearFailedDialog.clearAll')}
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
</Button>
</DialogFooter>
</DialogContent>
@@ -829,9 +857,9 @@ export function HistoryTable() {
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
<DialogTitle>Import Generation</DialogTitle>
<DialogDescription>
{t('history.importDialog.body', { name: selectedFile?.name })}
Import the generation from "{selectedFile?.name}". This will add it to your history.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -845,15 +873,13 @@ export function HistoryTable() {
}
}}
>
{t('common.cancel')}
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={importGeneration.isPending || !selectedFile}
>
{importGeneration.isPending
? t('history.importDialog.importing')
: t('history.importDialog.action')}
{importGeneration.isPending ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</DialogContent>
@@ -862,20 +888,21 @@ export function HistoryTable() {
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
<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">
{t('history.effectsDialog.sourceLabel')}
</label>
<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={t('history.effectsDialog.sourcePlaceholder')} />
<SelectValue placeholder="Select source version" />
</SelectTrigger>
<SelectContent>
{effectsTargetVersions.map((v) => (
@@ -897,15 +924,13 @@ export function HistoryTable() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button
onClick={handleApplyEffectsConfirm}
disabled={applyingEffects || effectsChain.length === 0}
>
{applyingEffects
? t('history.effectsDialog.applying')
: t('history.effectsDialog.apply')}
{applyingEffects ? 'Applying...' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,108 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Input Monitoring permission state. Without it, `rdev::listen`
* sees no key events and the chord engine never fires — but neither does
* anything error-out visibly, so we surface an inline prompt next to the
* hotkey toggle instead of leaving the user wondering why the shortcut is
* dead.
*
* Re-checked on mount and on window focus (cheap way to pick up the user
* flipping the toggle in System Settings and alt-tabbing back).
*/
export function useInputMonitoringPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_input_monitoring_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[input-monitoring] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
const openSettings = useCallback(async () => {
try {
await invoke('open_input_monitoring_settings');
} catch (err) {
console.warn('[input-monitoring] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered under the global-shortcut toggle when the user has
* opted in but macOS Input Monitoring is not granted. Returns null when the
* permission is present (or when the toggle is off and the notice would just
* be noise).
*/
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!enabled || !needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{t('captures.permissions.inputMonitoring.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.permissions.inputMonitoring.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
{t('captures.permissions.inputMonitoring.stillMissing')}
</p>
)}
</div>
</div>
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import type { CSSProperties, ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils/cn';
interface ListPaneProps {
className?: string;
children: ReactNode;
}
export function ListPane({ className, children }: ListPaneProps) {
return (
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
<div
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
style={{
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
}}
/>
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{children}
</div>
);
}
interface ListPaneHeaderProps {
className?: string;
children: ReactNode;
}
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
return (
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
);
}
interface ListPaneTitleRowProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
}
interface ListPaneTitleProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
}
interface ListPaneActionsProps {
className?: string;
children: ReactNode;
}
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
}
interface ListPaneSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
return (
<div className={cn('relative', className)}>
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
}
interface ListPaneScrollProps {
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
return (
<div
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
style={style}
>
{children}
</div>
);
}
+23 -13
View File
@@ -1,6 +1,5 @@
import { Sparkles, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { HistoryTable } from '@/components/History/HistoryTable';
import { Button } from '@/components/ui/button';
@@ -21,7 +20,6 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore';
export function MainEditor() {
const { t } = useTranslation();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const scrollRef = useRef<HTMLDivElement>(null);
@@ -41,8 +39,8 @@ export function MainEditor() {
if (file) {
if (!file.name.endsWith('.voicebox.zip')) {
toast({
title: t('main.import.invalidTitle'),
description: t('main.import.invalidDescription'),
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
variant: 'destructive',
});
return;
@@ -62,13 +60,13 @@ export function MainEditor() {
fileInputRef.current.value = '';
}
toast({
title: t('main.import.successTitle'),
description: t('main.import.successDescription'),
title: 'Profile imported',
description: 'Voice profile imported successfully',
});
},
onError: (error) => {
toast({
title: t('main.import.failedTitle'),
title: 'Failed to import profile',
description: error.message,
variant: 'destructive',
});
@@ -78,17 +76,21 @@ export function MainEditor() {
};
return (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative lg: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-0 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-10">
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Voicebox</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
{t('main.importVoice')}
Import Voice
</Button>
<input
ref={fileInputRef}
@@ -99,12 +101,13 @@ export function MainEditor() {
/>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
{t('main.createVoice')}
Create Voice
</Button>
</div>
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
@@ -117,18 +120,25 @@ export function MainEditor() {
</div>
</div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
{/* Floating Generate Box */}
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
<DialogTitle>Import Profile</DialogTitle>
<DialogDescription>
{t('main.import.dialogDescription', { name: selectedFile?.name })}
Import the profile from "{selectedFile?.name}". This will create a new profile with
all samples.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -142,13 +152,13 @@ export function MainEditor() {
}
}}
>
{t('common.cancel')}
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={importProfile.isPending || !selectedFile}
>
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
{importProfile.isPending ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,116 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore';
export function GenerationSettings() {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
<CardHeader>
<CardTitle>Generation Settings</CardTitle>
<CardDescription>
Controls for long text generation. These settings apply to all engines.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="normalizeAudio"
className="text-sm font-medium leading-none cursor-pointer"
>
Normalize audio
</label>
<p className="text-sm text-muted-foreground">
Adjusts output volume to a consistent level across generations.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
import type { CudaDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -21,9 +21,6 @@ export function GpuAcceleration() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Query CUDA backend status
@@ -39,26 +36,10 @@ export function GpuAcceleration() {
enabled: !!health, // Only fetch when backend is reachable
});
// Query ROCm backend status
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
@@ -70,7 +51,7 @@ export function GpuAcceleration() {
};
}, []);
// SSE progress tracking during CUDA download
// SSE progress tracking during download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
@@ -107,43 +88,6 @@ export function GpuAcceleration() {
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
// SSE progress tracking during ROCm download
useEffect(() => {
if (!rocmDownloading || !serverUrl) {
return;
}
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setRocmDownloadProgress(null);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [rocmDownloading, serverUrl, refetchRocmStatus]);
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
@@ -169,7 +113,7 @@ export function GpuAcceleration() {
}, 1000);
}, [queryClient]);
const handleDownloadCuda = async () => {
const handleDownload = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
@@ -184,21 +128,6 @@ export function GpuAcceleration() {
}
};
const handleDownloadRocm = async () => {
setError(null);
try {
await apiClient.downloadRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
@@ -225,17 +154,18 @@ export function GpuAcceleration() {
}
};
const handleSwitchToCpuFromCuda = async () => {
const handleSwitchToCpu = async () => {
// To switch to CPU: delete the CUDA binary, then restart.
// start_server always prefers CUDA if present, so we must remove it first.
setError(null);
setRestartPhase('stopping');
try {
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
await apiClient.deleteCudaBackend();
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
@@ -254,36 +184,7 @@ export function GpuAcceleration() {
}
};
const handleSwitchToCpuFromRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
const handleDelete = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -293,16 +194,6 @@ export function GpuAcceleration() {
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete ROCm backend');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -314,7 +205,7 @@ export function GpuAcceleration() {
// Don't render until health data is available
if (!health) return null;
// If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
@@ -350,6 +241,8 @@ export function GpuAcceleration() {
)}
</div>
{/* Native GPU detected - no CUDA download needed */}
{/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
@@ -368,12 +261,7 @@ export function GpuAcceleration() {
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button
onClick={handleSwitchToCpuFromCuda}
variant="outline"
className="w-full"
size="sm"
>
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
@@ -388,207 +276,39 @@ export function GpuAcceleration() {
</>
)}
{/* Currently running ROCm - show switch back to CPU */}
{isCurrentlyRocm && platform.metadata.isTauri && (
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
<>
{restartPhase !== 'idle' ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with ROCm GPU acceleration for AMD. Switch back to CPU if needed (you can
re-download later).
</p>
<Button
onClick={handleSwitchToCpuFromRocm}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)}
{/* Backend download/manage sections - show when no native GPU and not currently running GPU */}
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
{/* CUDA Section */}
<div className="space-y-4">
<div className="text-sm font-medium">NVIDIA (CUDA)</div>
{/* CUDA Download progress */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
{/* Download progress (manual download or auto-update) */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
)}
{/* CUDA Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownloadCuda} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
)}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{cudaAvailable && (
<Button
onClick={handleDeleteCuda}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</div>
{/* Divider */}
<div className="border-t" />
{/* ROCm Section */}
<div className="space-y-4">
<div className="text-sm font-medium">AMD (ROCm)</div>
{/* ROCm Download progress */}
{rocmDownloading && rocmDownloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? 'Updating ROCm backend...'
: 'Downloading ROCm backend...')}
</span>
</div>
{rocmDownloadProgress.total > 0 && (
<span className="text-muted-foreground">
{rocmDownloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{rocmDownloadProgress.total > 0 && (
<>
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(rocmDownloadProgress.current)} /{' '}
{formatBytes(rocmDownloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* ROCm Actions */}
{restartPhase === 'idle' && !rocmDownloading && (
<div className="space-y-2">
{!rocmAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the ROCm backend (~2-3 GB) for AMD GPU acceleration. Requires an
AMD Radeon GPU with ROCm support.
</p>
<Button onClick={handleDownloadRocm} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download AMD ROCm Backend
</Button>
</div>
)}
{rocmAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
ROCm backend is downloaded and ready. Restart the server to enable AMD GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to ROCm Backend
</Button>
</div>
)}
{rocmAvailable && (
<Button
onClick={handleDeleteRocm}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove ROCm Backend
</Button>
)}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Restart in progress */}
{restartPhase !== 'idle' && (
@@ -609,6 +329,52 @@ export function GpuAcceleration() {
<span>{error}</span>
</div>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{/* Not downloaded yet - show download button */}
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownload} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{/* Delete option when downloaded (and not active) */}
{cudaAvailable && (
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</>
)}
</CardContent>
@@ -18,7 +18,6 @@ import {
X,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import {
AlertDialog,
AlertDialogAction,
@@ -83,12 +82,6 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
'whisper-turbo':
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
'qwen3-0.6b':
'Qwen3 0.6B — smallest of the Qwen3 instruct family. Very fast on CPU, runs at ~400 MB quantized on Apple Silicon. Good for dictation refinement and short completions.',
'qwen3-1.7b':
'Qwen3 1.7B — balanced size and quality. Handles subtle self-corrections and technical vocabulary better than the 0.6B. Runs at ~1.1 GB quantized on Apple Silicon.',
'qwen3-4b':
'Qwen3 4B — highest quality local refinement and longer-form reasoning. ~2.5 GB quantized on Apple Silicon, ~8 GB at full precision on PyTorch.',
};
function formatDownloads(n: number): string {
@@ -126,7 +119,6 @@ function formatBytes(bytes: number): string {
}
export function ModelManagement() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const platform = usePlatform();
@@ -278,8 +270,8 @@ export function ModelManagement() {
setDownloadingModel(null);
setDownloadingDisplayName(null);
toast({
title: t('models.toast.downloadFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Download failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
@@ -317,8 +309,8 @@ export function ModelManagement() {
setDownloadingModel(prevDownloadingModel);
setDownloadingDisplayName(prevDownloadingDisplayName);
toast({
title: t('models.toast.cancelFailed'),
description: t('models.toast.cancelFailedDescription'),
title: 'Cancel failed',
description: 'Could not cancel the download task.',
variant: 'destructive',
});
},
@@ -344,10 +336,8 @@ export function ModelManagement() {
},
onSuccess: async () => {
toast({
title: t('models.toast.deleted'),
description: t('models.toast.deletedDescription', {
name: modelToDelete?.displayName || t('models.defaultName'),
}),
title: 'Model deleted',
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
});
setDeleteDialogOpen(false);
setModelToDelete(null);
@@ -358,7 +348,7 @@ export function ModelManagement() {
},
onError: (error: Error) => {
toast({
title: t('models.toast.deleteFailed'),
title: 'Delete failed',
description: error.message,
variant: 'destructive',
});
@@ -371,15 +361,15 @@ export function ModelManagement() {
},
onSuccess: async (_data, modelName) => {
toast({
title: t('models.toast.unloaded'),
description: t('models.toast.unloadedDescription', { name: modelName }),
title: 'Model unloaded',
description: `${modelName} has been unloaded from memory.`,
});
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
},
onError: (error: Error) => {
toast({
title: t('models.toast.unloadFailed'),
title: 'Unload failed',
description: error.message,
variant: 'destructive',
});
@@ -387,7 +377,7 @@ export function ModelManagement() {
});
const formatSize = (sizeMb?: number): string => {
if (!sizeMb) return t('models.unknownSize');
if (!sizeMb) return 'Unknown size';
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
return `${(sizeMb / 1024).toFixed(2)} GB`;
};
@@ -417,13 +407,11 @@ export function ModelManagement() {
m.model_name.startsWith('kokoro'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
const llmModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen3-')) ?? [];
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
{ label: t('models.sections.transcription'), models: whisperModels },
{ label: t('models.sections.languageModels'), models: llmModels },
{ label: 'Voice Generation', models: voiceModels },
{ label: 'Transcription', models: whisperModels },
];
// Get detail modal state for selected model
@@ -439,14 +427,16 @@ export function ModelManagement() {
// Derive license from HF data
const license =
hfModelInfo?.cardData?.license ||
hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="shrink-0 pb-4">
<h1 className="text-lg font-semibold">{t('models.title')}</h1>
<p className="text-sm text-muted-foreground">{t('models.subtitle')}</p>
<h1 className="text-lg font-semibold">Models</h1>
<p className="text-sm text-muted-foreground">
Download and manage AI models for voice generation and transcription
</p>
</div>
{/* Model storage location */}
@@ -454,7 +444,7 @@ export function ModelManagement() {
<div className="shrink-0 pb-4 border-b mb-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground">{t('models.storage.location')}</span>
<span className="text-xs text-muted-foreground">Storage location</span>
<p
className="text-xs font-mono text-muted-foreground/70 truncate"
title={cacheDir.path}
@@ -471,12 +461,12 @@ export function ModelManagement() {
try {
await platform.filesystem.openPath(cacheDir.path);
} catch {
toast({ title: t('models.toast.openFolderFailed'), variant: 'destructive' });
toast({ title: 'Failed to open model folder', variant: 'destructive' });
}
}}
>
<FolderOpen className="h-3 w-3" />
{t('models.storage.open')}
Open
</Button>
<Button
variant="ghost"
@@ -485,12 +475,12 @@ export function ModelManagement() {
onClick={async () => {
try {
const newDir = await platform.filesystem.pickDirectory(
t('models.storage.pickerTitle'),
'Choose model storage folder',
);
if (!newDir) return;
setPendingMigrateDir(newDir);
} catch {
toast({ title: t('models.toast.pickerFailed'), variant: 'destructive' });
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
}
}}
disabled={migrating}
@@ -500,7 +490,7 @@ export function ModelManagement() {
) : (
<FolderOpen className="h-3 w-3" />
)}
{migrating ? t('models.storage.migrating') : t('models.storage.change')}
{migrating ? 'Migrating...' : 'Change'}
</Button>
{customModelsDir && (
<Button
@@ -510,13 +500,13 @@ export function ModelManagement() {
disabled={migrating}
onClick={async () => {
setCustomModelsDir(null);
toast({ title: t('models.toast.resetToDefault') });
toast({ title: 'Reset to default location. Restarting server...' });
await platform.lifecycle.restartServer('');
queryClient.invalidateQueries();
}}
>
<RotateCcw className="h-3 w-3" />
{t('models.storage.reset')}
Reset
</Button>
)}
</div>
@@ -530,7 +520,7 @@ export function ModelManagement() {
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelStatus ? (
<div className="flex-1 min-h-0 overflow-y-auto space-y-6 pb-6">
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
{sections.map((section) => (
<div key={section.label}>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
@@ -575,7 +565,7 @@ export function ModelManagement() {
<div className="text-[10px] text-muted-foreground truncate">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
: dl?.filename || t('models.progress.connecting')}
: dl?.filename || 'Connecting...'}
</div>
</div>
);
@@ -586,12 +576,12 @@ export function ModelManagement() {
<div className="shrink-0 flex items-center gap-2">
{hasError && (
<Badge variant="destructive" className="text-[10px] h-5">
{t('common.error')}
Error
</Badge>
)}
{model.loaded && (
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
{t('models.status.loaded')}
Loaded
</Badge>
)}
{model.downloaded && !isDownloading && !hasError && (
@@ -623,7 +613,7 @@ export function ModelManagement() {
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>{t('models.problems.title')}</span>
<span>Problems</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
@@ -636,7 +626,7 @@ export function ModelManagement() {
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
{t('models.problems.clearAll')}
Clear All
</Button>
</div>
{consoleOpen && (
@@ -655,13 +645,13 @@ export function ModelManagement() {
) : (
<>
{': '}
<span className="text-[#808080]">{t('models.problems.noDetails')}</span>
<span className="text-[#808080]">
No error details available. Try downloading again.
</span>
</>
)}
<div className="text-[#6a9955] mt-0.5">
{t('models.problems.startedAt', {
time: new Date(dl.started_at).toLocaleString(),
})}
started at {new Date(dl.started_at).toLocaleString()}
</div>
</div>
))}
@@ -702,13 +692,13 @@ export function ModelManagement() {
{freshSelectedModel.loaded && (
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
<CircleCheck className="h-3 w-3 mr-1" />
{t('models.status.loaded')}
Loaded
</Badge>
)}
{selectedState?.hasError && (
<Badge variant="destructive" className="text-xs">
<CircleX className="h-3 w-3 mr-1" />
{t('common.error')}
Error
</Badge>
)}
</div>
@@ -717,7 +707,7 @@ export function ModelManagement() {
{hfLoading && freshSelectedModel.hf_repo_id && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
{t('models.detail.loadingInfo')}
Loading model info...
</div>
)}
@@ -744,29 +734,23 @@ export function ModelManagement() {
)}
{hfModelInfo.author && (
<Badge variant="outline" className="text-[10px]">
{t('models.detail.byAuthor', { author: hfModelInfo.author })}
by {hfModelInfo.author}
</Badge>
)}
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span
className="flex items-center gap-1"
title={t('models.detail.downloads')}
>
<span className="flex items-center gap-1" title="Downloads">
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title={t('models.detail.likes')}>
<span className="flex items-center gap-1" title="Likes">
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span
className="flex items-center gap-1"
title={t('models.detail.license')}
>
<span className="flex items-center gap-1" title="License">
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
@@ -778,12 +762,8 @@ export function ModelManagement() {
<div>
<span className="text-xs text-muted-foreground">
{hfModelInfo.cardData.language.length > 10
? t('models.detail.languagesCount', {
count: hfModelInfo.cardData.language.length,
})
: t('models.detail.languagesList', {
list: hfModelInfo.cardData.language.join(', '),
})}
? `${hfModelInfo.cardData.language.length} languages supported`
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
</span>
</div>
)}
@@ -794,9 +774,7 @@ export function ModelManagement() {
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>
{t('models.detail.onDisk', { size: formatSize(freshSelectedModel.size_mb) })}
</span>
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
</div>
)}
@@ -818,7 +796,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
{t('models.actions.retry')}
Retry Download
</Button>
<Button
size="sm"
@@ -847,7 +825,7 @@ export function ModelManagement() {
<div className="text-xs text-muted-foreground">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
: dl?.filename || t('models.progress.connectingHf')}
: dl?.filename || 'Connecting to HuggingFace...'}
</div>
</>
);
@@ -880,9 +858,7 @@ export function ModelManagement() {
) : (
<Unplug className="h-4 w-4 mr-2" />
)}
{unloadMutation.isPending
? t('models.actions.unloading')
: t('models.actions.unload')}
{unloadMutation.isPending ? 'Unloading...' : 'Unload'}
</Button>
)}
<Button
@@ -899,13 +875,13 @@ export function ModelManagement() {
disabled={freshSelectedModel.loaded}
title={
freshSelectedModel.loaded
? t('models.actions.unloadFirst')
: t('models.actions.deleteModel')
? 'Unload model before deleting'
: 'Delete model'
}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
{t('models.actions.deleteModel')}
Delete Model
</Button>
</div>
) : (
@@ -915,7 +891,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
{t('models.actions.download')}
Download
</Button>
)}
</div>
@@ -929,23 +905,20 @@ export function ModelManagement() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('models.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey="models.deleteDialog.body"
values={{ name: modelToDelete?.displayName }}
components={{ strong: <strong /> }}
/>
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
{modelToDelete?.sizeMb && (
<>
{' '}
{t('models.deleteDialog.sizeNote', { size: formatSize(modelToDelete.sizeMb) })}
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
need to be re-downloaded if you want to use it again.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (modelToDelete) {
@@ -958,10 +931,10 @@ export function ModelManagement() {
{deleteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('models.deleteDialog.deleting')}
Deleting...
</>
) : (
t('common.delete')
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
@@ -975,8 +948,11 @@ export function ModelManagement() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('models.migrateDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('models.migrateDialog.description')}</AlertDialogDescription>
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
<AlertDialogDescription>
The server will shut down while models are being moved to the new folder. It will
restart automatically once the migration is complete.
</AlertDialogDescription>
</AlertDialogHeader>
<div
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
@@ -985,7 +961,7 @@ export function ModelManagement() {
{pendingMigrateDir}
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!pendingMigrateDir) return;
@@ -997,7 +973,7 @@ export function ModelManagement() {
total: 0,
progress: 0,
status: 'downloading',
filename: t('models.migrateDialog.preparing'),
filename: 'Preparing...',
});
try {
// Start the migration (background task)
@@ -1008,8 +984,8 @@ export function ModelManagement() {
setMigrating(false);
setMigrationProgress(null);
toast({
title: t('models.toast.noModelsToMigrate'),
description: t('models.toast.noModelsToMigrateDescription'),
title: 'No models to migrate',
description: 'Download at least one model before changing the storage location.',
});
setPendingMigrateDir(null);
return;
@@ -1027,7 +1003,7 @@ export function ModelManagement() {
resolve();
} else if (data.status === 'error') {
es.close();
reject(new Error(data.error || t('models.toast.migrationFailed')));
reject(new Error(data.error || 'Migration failed'));
}
} catch {
/* ignore parse errors */
@@ -1035,7 +1011,7 @@ export function ModelManagement() {
};
es.onerror = () => {
es.close();
reject(new Error(t('models.toast.migrationConnectionLost')));
reject(new Error('Lost connection during migration'));
};
});
@@ -1045,16 +1021,15 @@ export function ModelManagement() {
total: 1,
progress: 100,
status: 'complete',
filename: t('models.migrateDialog.restartingServer'),
filename: 'Restarting server...',
});
await platform.lifecycle.restartServer(newDir);
queryClient.invalidateQueries();
toast({ title: t('models.toast.migrated') });
toast({ title: 'Models moved successfully' });
} catch (e) {
toast({
title: t('models.toast.migrationFailed'),
description:
e instanceof Error ? e.message : t('models.toast.migrationFailedGeneric'),
title: 'Migration failed',
description: e instanceof Error ? e.message : 'Failed to migrate models',
variant: 'destructive',
});
} finally {
@@ -1063,7 +1038,7 @@ export function ModelManagement() {
}
}}
>
{t('models.migrateDialog.action')}
Move Models
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1075,11 +1050,11 @@ export function ModelManagement() {
<div className="w-full max-w-md px-8 space-y-6 text-center">
<div className="space-y-2">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<h2 className="text-lg font-semibold">{t('models.migrate.title')}</h2>
<h2 className="text-lg font-semibold">Moving models</h2>
<p className="text-sm text-muted-foreground">
{migrationProgress.status === 'complete'
? t('models.migrateDialog.restartingServer')
: t('models.migrate.offline')}
? 'Restarting server...'
: 'The server is offline while models are being moved.'}
</p>
</div>
{migrationProgress.total > 0 && (
@@ -1100,3 +1075,4 @@ export function ModelManagement() {
</div>
);
}
+14 -20
View File
@@ -1,7 +1,6 @@
import { ArrowUpRight } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { usePlatform } from '@/platform/PlatformContext';
@@ -17,7 +16,6 @@ function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }
}
export function AboutPage() {
const { t } = useTranslation();
const platform = usePlatform();
const [version, setVersion] = useState('');
@@ -59,13 +57,14 @@ export function AboutPage() {
<FadeIn delay={160}>
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
{t('settings.about.tagline')}
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
and build voice-powered apps — all running locally on your machine.
</p>
</FadeIn>
<FadeIn delay={240}>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span>{t('settings.about.createdBy')}</span>
<span>Created by</span>
<a
href="https://github.com/jamiepine"
target="_blank"
@@ -93,7 +92,7 @@ export function AboutPage() {
>
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
</svg>
{t('settings.about.buyCoffee')}
Buy me a coffee
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
<a
@@ -116,22 +115,17 @@ export function AboutPage() {
</div>
</FadeIn>
<FadeIn delay={480}>
<FadeIn delay={400}>
<p className="text-xs text-muted-foreground/40 pt-4">
<Trans
i18nKey="settings.about.license"
components={{
link: (
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
/>
),
}}
/>
Licensed under{' '}
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
>
MIT
</a>
</p>
</FadeIn>
</div>
@@ -1,623 +0,0 @@
import { Check, ChevronDown, FolderOpen, Info, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Toggle } from '@/components/ui/toggle';
import { useToast } from '@/components/ui/use-toast';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { cn } from '@/lib/utils/cn';
import { defaultChordKeys, displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
import { SettingRow, SettingSection } from './SettingRow';
function ChordPreview({ keys }: { keys: string[] }) {
const { t } = useTranslation();
if (keys.length === 0) {
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
}
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
const isWindows =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
const PILL_DURATIONS: Partial<Record<PillState, number>> = {
recording: 2600,
transcribing: 1500,
refining: 1500,
rest: 900,
};
function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
const [state, setState] = useState<PillState>('recording');
const [tick, setTick] = useState(0);
// Cycle recording → transcribing → refining → rest → …
useEffect(() => {
const t = window.setTimeout(() => {
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
setState(next);
}, PILL_DURATIONS[state] ?? 1000);
return () => window.clearTimeout(t);
}, [state]);
// Timer only advances while recording; holds its final value through
// transcribing and refining so users see the duration of the clip being
// processed.
useEffect(() => {
if (state !== 'recording') return;
setTick(0);
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
return () => window.clearInterval(iv);
}, [state]);
const elapsedMs = tick * 90;
return (
<div
className={cn(
'relative rounded-xl border overflow-hidden transition-opacity',
'bg-muted/30',
'aspect-[6/1]',
enabled ? 'border-border' : 'border-border/50 opacity-50',
)}
style={{
backgroundImage: `
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px)
`,
backgroundSize: '22px 22px',
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<CapturePill state={state} elapsedMs={elapsedMs} />
</div>
</div>
);
}
export function CapturesPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
const readiness = useDictationReadiness();
const sttModel = settings?.stt_model ?? 'turbo';
const language = settings?.language ?? 'auto';
const autoRefine = settings?.auto_refine ?? true;
const llmModel = settings?.llm_model ?? '0.6B';
const smartCleanup = settings?.smart_cleanup ?? true;
const selfCorrection = settings?.self_correction ?? true;
const preserveTechnical = settings?.preserve_technical ?? true;
const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const keepMicWarm = settings?.keep_mic_warm ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
const [opening, setOpening] = useState(false);
const [capturesPath, setCapturesPath] = useState<string | null>(null);
useEffect(() => {
fetch(`${serverUrl}/health/filesystem`)
.then((res) => res.json())
.then((data) => {
const dir = data.directories?.find((d: { path: string }) =>
d.path.includes('captures'),
);
if (dir?.path) setCapturesPath(dir.path);
})
.catch(() => {});
}, [serverUrl]);
const openCapturesFolder = useCallback(async () => {
if (!capturesPath) return;
setOpening(true);
try {
await platform.filesystem.openPath(capturesPath);
} catch (e) {
console.error('Failed to open captures folder:', e);
} finally {
setOpening(false);
}
}, [platform, capturesPath]);
const voices: VoiceProfileResponse[] = profiles ?? [];
const defaultVoice =
voices.find((v) => v.id === defaultVoiceId) ?? null;
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title={t('settings.captures.dictation.title')}
description={t('settings.captures.dictation.description')}
>
<div>
<SettingRow
title={t('settings.captures.dictation.globalShortcut.title')}
description={t('settings.captures.dictation.globalShortcut.description')}
htmlFor="hotkeyEnabled"
action={
<Toggle
id="hotkeyEnabled"
checked={hotkeyEnabled}
onCheckedChange={(v) => {
update({ hotkey_enabled: v });
// Surface model-readiness blocks at the toggle. The
// InputMonitoringNotice below already covers TCC, but
// missing models would otherwise be invisible from this
// page — the user toggles on, presses the chord, and
// nothing happens because useChordSync gates on readiness.
if (!v) return;
const missingModels = readiness.missing.filter(
(g) => g === 'stt' || g === 'llm',
);
if (missingModels.length === 0) return;
const names = [
missingModels.includes('stt') ? readiness.stt?.display_name : null,
missingModels.includes('llm') ? readiness.llm?.display_name : null,
]
.filter(Boolean)
.join(' and ');
toast({
title: t('captures.toast.shortcutNotArmed'),
description: t('captures.toast.shortcutNotArmedDescription', {
names,
count: missingModels.length,
}),
});
}}
/>
}
/>
<InputMonitoringNotice enabled={hotkeyEnabled} />
</div>
<SettingRow
title={t('settings.captures.dictation.keepMicWarm.title')}
description={t('settings.captures.dictation.keepMicWarm.description')}
htmlFor="keepMicWarm"
action={
<Toggle
id="keepMicWarm"
checked={keepMicWarm}
disabled={!hotkeyEnabled}
onCheckedChange={(v) => {
update({ keep_mic_warm: v });
}}
/>
}
/>
<SettingRow
title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('push')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.dictation.pushToTalk.change')}
</Button>
</div>
}
/>
<SettingRow
title={t('settings.captures.dictation.toggle.title')}
description={t('settings.captures.dictation.toggle.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('toggle')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.dictation.toggle.change')}
</Button>
</div>
}
/>
<ChordPicker
open={chordEditor === 'push'}
title={t('settings.captures.dictation.chordPicker.pttTitle')}
description={t('settings.captures.dictation.chordPicker.pttDescription')}
initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_push_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<ChordPicker
open={chordEditor === 'toggle'}
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_toggle_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<SettingRow
title={t('settings.captures.dictation.preview.title')}
description={t('settings.captures.dictation.preview.description')}
>
<HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow>
<div>
<SettingRow
title={t('settings.captures.dictation.autoPaste.title')}
description={t('settings.captures.dictation.autoPaste.description')}
htmlFor="autoPaste"
action={
<Toggle
id="autoPaste"
checked={allowAutoPaste}
onCheckedChange={(v) => update({ allow_auto_paste: v })}
disabled={!hotkeyEnabled}
/>
}
/>
<AccessibilityNotice />
</div>
</SettingSection>
<SettingSection
title={t('settings.captures.transcription.title')}
description={t('settings.captures.transcription.description')}
>
<SettingRow
title={t('settings.captures.transcription.model.title')}
description={t('settings.captures.transcription.model.description')}
action={
<Select
value={sttModel}
onValueChange={(v) => update({ stt_model: v as WhisperModelSize })}
>
<SelectTrigger className="w-[300px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
</SelectItem>
<SelectItem value="small">
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
</SelectItem>
<SelectItem value="medium">
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
</SelectItem>
<SelectItem value="large">
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
</SelectItem>
<SelectItem value="turbo">
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title={t('settings.captures.transcription.language.title')}
description={t('settings.captures.transcription.language.description')}
action={
<Select value={language} onValueChange={(v) => update({ language: v })}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
</SelectContent>
</Select>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.refinement.title')}
description={t('settings.captures.refinement.description')}
>
<SettingRow
title={t('settings.captures.refinement.auto.title')}
description={t('settings.captures.refinement.auto.description')}
htmlFor="autoRefine"
action={
<Toggle
id="autoRefine"
checked={autoRefine}
onCheckedChange={(v) => update({ auto_refine: v })}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.model.title')}
description={t('settings.captures.refinement.model.description')}
action={
<Select
value={llmModel}
onValueChange={(v) => update({ llm_model: v as Qwen3ModelSize })}
disabled={!autoRefine}
>
<SelectTrigger className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.6B">
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
</SelectItem>
<SelectItem value="1.7B">
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
</SelectItem>
<SelectItem value="4B">
{t('settings.captures.refinement.model.size40', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title={t('settings.captures.refinement.smartCleanup.title')}
description={t('settings.captures.refinement.smartCleanup.description')}
htmlFor="smartCleanup"
action={
<Toggle
id="smartCleanup"
checked={smartCleanup}
onCheckedChange={(v) => update({ smart_cleanup: v })}
disabled={!autoRefine}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.selfCorrection.title')}
description={t('settings.captures.refinement.selfCorrection.description')}
htmlFor="selfCorrection"
action={
<Toggle
id="selfCorrection"
checked={selfCorrection}
onCheckedChange={(v) => update({ self_correction: v })}
disabled={!autoRefine}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.preserveTechnical.title')}
description={t('settings.captures.refinement.preserveTechnical.description')}
htmlFor="preserveTechnical"
action={
<Toggle
id="preserveTechnical"
checked={preserveTechnical}
onCheckedChange={(v) => update({ preserve_technical: v })}
disabled={!autoRefine}
/>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.playback.title')}
description={t('settings.captures.playback.description')}
>
<SettingRow
title={t('settings.captures.playback.defaultVoice.title')}
description={t('settings.captures.playback.defaultVoice.description')}
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 min-w-[220px] justify-between"
disabled={voices.length === 0}
>
<div className="flex items-center gap-2 min-w-0">
{defaultVoice ? (
<span className="truncate">{defaultVoice.name}</span>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0
? t('settings.captures.playback.defaultVoice.noClonedVoices')
: t('settings.captures.playback.defaultVoice.noneSelected')}
</span>
)}
</div>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('settings.captures.playback.defaultVoice.clonedVoices')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{voices.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => update({ default_playback_voice_id: v.id })}
className="gap-2.5 py-2"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
{v.description ? (
<div className="text-[11px] text-muted-foreground truncate">
{v.description}
</div>
) : null}
</div>
{v.id === defaultVoiceId && <Check className="h-3.5 w-3.5 text-accent shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.storage.title')}
description={t('settings.captures.storage.description')}
>
<SettingRow
title={t('settings.captures.storage.folder.title')}
description={capturesPath ?? t('settings.captures.storage.folder.description')}
action={
<Button
variant="outline"
size="sm"
onClick={openCapturesFolder}
disabled={opening || !capturesPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.folder.open')}
</Button>
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.captures.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
{t('settings.captures.sidebar.local.body')}
</span>
</li>
<li className="flex gap-2.5">
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.captures.sidebar.playAs.title')}
</span>{' '}
{t('settings.captures.sidebar.playAs.body')}
</span>
</li>
<li className="flex gap-2.5">
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.captures.sidebar.crossPlatform.title')}
</span>{' '}
{t('settings.captures.sidebar.crossPlatform.body')}
</span>
</li>
</ul>
{isWindows && (
<div className="rounded-lg border border-accent/20 bg-accent/5 px-3 py-2.5">
<div className="flex items-start gap-2.5">
<Info className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium text-foreground">
{t('settings.captures.sidebar.windowsCaveat.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.captures.sidebar.windowsCaveat.body')}
</p>
</div>
</div>
</div>
)}
</div>
{/* Same six-gate checklist the CapturesTab empty state uses.
Surfaces missing models / permissions persistently while
users configure this page, so a red gate can't hide behind
a green toggle. Hidden once every gate is green — no value
in real estate full of checkmarks. */}
{!readiness.allReady && (
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('captures.readiness.title')}</h3>
<DictationReadinessChecklist readiness={readiness} compact />
</div>
)}
</aside>
</div>
);
}
@@ -1,6 +1,5 @@
import changelogRaw from 'virtual:changelog';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
@@ -177,7 +176,6 @@ function inlineMarkdown(text: string): React.ReactNode {
}
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
const isLong = entry.body.split('\n').length > 12;
@@ -187,9 +185,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
<div className="flex items-baseline gap-3 mb-3">
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
{entry.version === 'Unreleased' && (
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
)}
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
</div>
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
@@ -204,7 +200,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
onClick={() => setExpanded(!expanded)}
className="text-xs text-accent hover:underline mt-2"
>
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
@@ -1,151 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { SettingRow, SettingSection } from './SettingRow';
// "Log in with browser" device pairing. The backend opens the system browser
// and completes the code exchange; here we just kick it off and poll status
// until the link goes live. The API key never touches the frontend.
export function CloudSection() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [polling, setPolling] = useState(false);
const { data: status } = useQuery({
queryKey: ['cloud-status'],
queryFn: () => apiClient.getCloudStatus(),
refetchInterval: polling ? 2000 : false,
});
const connected = status?.connected ?? false;
// Once the browser flow completes, stop polling and celebrate.
useEffect(() => {
if (connected && polling) {
setPolling(false);
toast({
title: 'Connected to Voicebox Cloud',
description: `Linked as ${status?.device_name ?? 'this device'}.`,
});
}
}, [connected, polling, status?.device_name, toast]);
// Give up after two minutes so an abandoned browser flow doesn't leave the
// button stuck on "Waiting for browser…". The backend state stays valid for
// ten, so the user can simply start again.
useEffect(() => {
if (!polling) return;
const timeoutId = window.setTimeout(() => {
setPolling(false);
toast({
title: 'Sign-in timed out',
description: 'The browser sign-in was not completed. Try again.',
variant: 'destructive',
});
}, 120_000);
return () => window.clearTimeout(timeoutId);
}, [polling, toast]);
const startLogin = useMutation({
mutationFn: () => apiClient.startCloudLogin(),
onSuccess: () => {
setPolling(true);
toast({
title: 'Continue in your browser',
description: 'Authorize this device, then return here.',
});
},
onError: (error: Error) =>
toast({
title: 'Could not start sign-in',
description: error.message,
variant: 'destructive',
}),
});
const disconnect = useMutation({
mutationFn: () => apiClient.disconnectCloud(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
toast({
title: 'Disconnected',
description:
'This device is no longer linked. The key stays valid until revoked in your account.',
});
},
onError: (error: Error) =>
toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
});
const busy = startLogin.isPending || polling;
return (
<SettingSection
title="Voicebox Cloud"
description="End-to-end encrypted backup & sync across your devices."
>
<SettingRow
title={connected ? 'Connected' : 'Account'}
description={
connected
? `Linked as ${status?.device_name ?? 'this device'}${
status?.key_prefix ? ` · ${status.key_prefix}…` : ''
}`
: 'Log in to back up and sync your captures and generations.'
}
action={
connected ? (
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate()}
size="sm"
variant="outline"
>
{disconnect.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Disconnecting…
</>
) : (
'Disconnect'
)}
</Button>
) : (
<Button disabled={busy} onClick={() => startLogin.mutate()} size="sm">
{busy ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{polling ? 'Waiting for browser…' : 'Opening…'}
</>
) : (
<>
<Cloud className="h-3.5 w-3.5 mr-1.5" />
Log in with browser
</>
)}
</Button>
)
}
/>
{connected && (
<SettingRow
title="Manage"
description="Revoke this device, add API keys, or manage billing from your account."
>
<a
className="text-sm text-accent hover:underline"
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
rel="noopener noreferrer"
target="_blank"
>
Open account dashboard ↗
</a>
</SettingRow>
)}
</SettingSection>
);
}
+59 -112
View File
@@ -1,8 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
@@ -14,21 +13,15 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { CloudSection } from './CloudSection';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
import { ThemeSelect } from './ThemeSelect';
function makeConnectionSchema(invalidUrl: string) {
return z.object({
serverUrl: z.string().url(invalidUrl),
});
}
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
});
type ConnectionFormValues = { serverUrl: string };
type ConnectionFormValues = z.infer<typeof connectionSchema>;
export function GeneralPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
@@ -39,12 +32,8 @@ export function GeneralPage() {
const { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const resolver = useMemo(
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
[t],
);
const form = useForm<ConnectionFormValues>({
resolver,
resolver: zodResolver(connectionSchema),
defaultValues: { serverUrl },
});
@@ -52,21 +41,14 @@ export function GeneralPage() {
form.reset({ serverUrl });
}, [serverUrl, form]);
// Re-run validation when the locale changes so existing error messages retranslate.
useEffect(() => {
if (form.formState.errors.serverUrl) {
form.trigger('serverUrl');
}
}, [t, form]);
const { isDirty } = form.formState;
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
form.reset(data);
toast({
title: t('settings.general.serverUrl.updatedTitle'),
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
});
}
@@ -81,7 +63,7 @@ export function GeneralPage() {
>
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
<div className="text-sm font-medium">Read the Docs</div>
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
@@ -101,10 +83,8 @@ export function GeneralPage() {
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
<div className="text-xs text-muted-foreground">
{t('settings.general.discord.subtitle')}
</div>
<div className="text-sm font-medium">Join the Discord</div>
<div className="text-xs text-muted-foreground">Get help & share voices</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
@@ -112,8 +92,8 @@ export function GeneralPage() {
<SettingSection>
<SettingRow
title={t('settings.general.serverUrl.title')}
description={t('settings.general.serverUrl.description')}
title="Server URL"
description="The address of your voicebox backend server."
action={
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
}
@@ -134,7 +114,7 @@ export function GeneralPage() {
/>
{isDirty && (
<Button type="submit" size="sm">
{t('common.save')}
Save
</Button>
)}
</form>
@@ -142,8 +122,8 @@ export function GeneralPage() {
</SettingRow>
<SettingRow
title={t('settings.general.keepServerRunning.title')}
description={t('settings.general.keepServerRunning.description')}
title="Keep server running when app closes"
description="The server will continue running in the background after closing the app."
htmlFor="keepServerRunning"
action={
<Toggle
@@ -155,17 +135,17 @@ export function GeneralPage() {
console.error('Failed to sync setting to Rust:', error);
setKeepServerRunningOnClose(!checked);
toast({
title: t('settings.general.keepServerRunning.failedTitle'),
description: t('settings.general.keepServerRunning.failedDescription'),
title: 'Failed to update setting',
description: 'Could not sync setting to backend.',
variant: 'destructive',
});
return;
});
toast({
title: t('settings.general.keepServerRunning.updatedTitle'),
title: 'Setting updated',
description: checked
? t('settings.general.keepServerRunning.runningDescription')
: t('settings.general.keepServerRunning.stoppedDescription'),
? 'Server will continue running when app closes'
: 'Server will stop when app closes',
});
}}
/>
@@ -174,8 +154,8 @@ export function GeneralPage() {
{platform.metadata.isTauri && (
<SettingRow
title={t('settings.general.networkAccess.title')}
description={t('settings.general.networkAccess.description')}
title="Allow network access"
description="Makes the server accessible from other devices on your network. Restart the app after changing."
htmlFor="allowNetworkAccess"
action={
<Toggle
@@ -184,32 +164,18 @@ export function GeneralPage() {
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: t('settings.general.networkAccess.updatedTitle'),
title: 'Setting updated',
description: checked
? t('settings.general.networkAccess.enabled')
: t('settings.general.networkAccess.disabled'),
? 'Network access enabled. Restart the app to apply.'
: 'Network access disabled. Restart the app to apply.',
});
}}
/>
}
/>
)}
<SettingRow
title={t('settings.language.label')}
description={t('settings.language.description')}
action={<LanguageSelect />}
/>
<SettingRow
title={t('settings.theme.label')}
description={t('settings.theme.description')}
action={<ThemeSelect />}
/>
</SettingSection>
<CloudSection />
<ApiReferenceCard serverUrl={serverUrl} />
{platform.metadata.isTauri && <UpdatesSection />}
@@ -226,14 +192,11 @@ function ConnectionStatus({
isLoading: boolean;
healthError: ReturnType<typeof useServerHealth>['error'];
}) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.connecting')}
</span>
<span className="text-xs text-muted-foreground">Connecting</span>
</div>
);
}
@@ -244,7 +207,7 @@ function ConnectionStatus({
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
</span>
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
<span className="text-xs text-destructive">Offline</span>
</div>
);
}
@@ -255,9 +218,7 @@ function ConnectionStatus({
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.online')}
</span>
<span className="text-xs text-muted-foreground">Online</span>
</div>
);
}
@@ -265,41 +226,35 @@ function ConnectionStatus({
}
function UpdatesSection() {
const { t } = useTranslation();
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string | null>('');
const [currentVersion, setCurrentVersion] = useState<string>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion(null));
.catch(() => setCurrentVersion('Unknown'));
}, [platform]);
const versionLabel = currentVersion ?? t('common.unknown');
return (
<SettingSection
title={t('settings.general.updates.title')}
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
>
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
{isDev ? (
<SettingRow
title={t('settings.general.updates.devMode.title')}
description={t('settings.general.updates.devMode.description')}
title="Development mode"
description="Auto-updates are disabled in development mode."
/>
) : (
<>
<SettingRow
title={t('settings.general.updates.check.title')}
title="Check for updates"
description={
status.available
? t('settings.general.updates.check.available', { version: status.version })
? `Version ${status.version} available`
: status.checking
? t('settings.general.updates.check.checking')
: t('settings.general.updates.check.upToDate')
? 'Checking...'
: "You're up to date"
}
action={
<Button
@@ -311,13 +266,13 @@ function UpdatesSection() {
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
/>
{t('settings.general.updates.check.button')}
Check
</Button>
}
/>
{status.error && (
<SettingRow title={t('settings.general.updates.error')}>
<SettingRow title="Update error">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
@@ -327,19 +282,19 @@ function UpdatesSection() {
{status.available && !status.downloading && !status.readyToInstall && (
<SettingRow
title={t('settings.general.updates.download.title', { version: status.version })}
description={t('settings.general.updates.download.description')}
title={`Update to ${status.version}`}
description="Download and install the latest version."
action={
<Button onClick={downloadAndInstall} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.general.updates.download.button')}
Download
</Button>
}
/>
)}
{status.downloading && (
<SettingRow title={t('settings.general.updates.downloading')}>
<SettingRow title="Downloading update...">
<div className="space-y-1.5">
<Progress value={status.downloadProgress} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
@@ -361,14 +316,12 @@ function UpdatesSection() {
{status.readyToInstall && (
<SettingRow
title={t('settings.general.updates.ready.title')}
description={t('settings.general.updates.ready.description', {
version: status.version,
})}
title="Update ready to install"
description={`Version ${status.version} has been downloaded. Restart to complete.`}
action={
<Button onClick={restartAndInstall} size="sm">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.general.updates.ready.button')}
Restart Now
</Button>
}
/>
@@ -379,31 +332,25 @@ function UpdatesSection() {
);
}
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
const { t } = useTranslation();
const endpoints = [
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
];
const API_ENDPOINTS = [
{ method: 'POST', path: '/generate', label: 'Generate speech' },
{ method: 'GET', path: '/health', label: 'Server status' },
{ method: 'GET', path: '/profiles', label: 'List voices' },
{ method: 'GET', path: '/history', label: 'Past generations' },
];
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
return (
<div className="rounded-lg border border-border/60 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
<h3 className="text-sm font-medium">API Access</h3>
<p className="text-sm text-muted-foreground">
<Trans
i18nKey="settings.general.api.description"
values={{ url: serverUrl }}
components={{
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
}}
/>
Integrate Voicebox into your workflow via the REST API at{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
</p>
</div>
<div className="space-y-1">
{endpoints.map((ep) => (
{API_ENDPOINTS.map((ep) => (
<div key={ep.path} className="flex items-center gap-2.5 py-1">
<span
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
@@ -424,7 +371,7 @@ function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{t('settings.general.api.viewReference')}
View the full API reference
</a>
</p>
</div>
+29 -82
View File
@@ -1,30 +1,23 @@
import { FolderOpen, Languages, Mic, Zap } from 'lucide-react';
import { FolderOpen } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Toggle } from '@/components/ui/toggle';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
export function GenerationPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useGenerationSettings();
const persistedMaxChunkChars = settings?.max_chunk_chars ?? 800;
const persistedCrossfadeMs = settings?.crossfade_ms ?? 50;
const normalizeAudio = settings?.normalize_audio ?? true;
const autoplayOnGenerate = settings?.autoplay_on_generate ?? true;
// Slider mirrors persist on commit (pointer-up / keyboard-release) only —
// onValueChange would fire a PATCH for every pointer-move pixel and round-
// trip mid-drag failures could leave persisted state out of sync with UI.
const [maxChunkChars, setMaxChunkChars] = useState(persistedMaxChunkChars);
const [crossfadeMs, setCrossfadeMs] = useState(persistedCrossfadeMs);
useEffect(() => setMaxChunkChars(persistedMaxChunkChars), [persistedMaxChunkChars]);
useEffect(() => setCrossfadeMs(persistedCrossfadeMs), [persistedCrossfadeMs]);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
const [opening, setOpening] = useState(false);
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
@@ -53,18 +46,17 @@ export function GenerationPage() {
}, [platform, generationsPath]);
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<div className="space-y-8 max-w-2xl">
<SettingSection
title={t('settings.generation.title')}
description={t('settings.generation.description')}
title="Generation"
description="Controls for long text generation. These settings apply to all engines."
>
<SettingRow
title={t('settings.generation.chunkLimit.title')}
description={t('settings.generation.chunkLimit.description')}
title="Auto-chunking limit"
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
action={
<span className="text-sm tabular-nums text-muted-foreground">
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
{maxChunkChars} chars
</span>
}
>
@@ -72,22 +64,19 @@ export function GenerationPage() {
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
onValueCommit={([value]) => update({ max_chunk_chars: value })}
min={100}
max={5000}
step={50}
aria-label={t('settings.generation.chunkLimit.title')}
aria-label="Auto-chunking character limit"
/>
</SettingRow>
<SettingRow
title={t('settings.generation.crossfade.title')}
description={t('settings.generation.crossfade.description')}
title="Chunk crossfade"
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
action={
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0
? t('settings.generation.crossfade.cut')
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
}
>
@@ -95,43 +84,42 @@ export function GenerationPage() {
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
onValueCommit={([value]) => update({ crossfade_ms: value })}
min={0}
max={200}
step={10}
aria-label={t('settings.generation.crossfade.title')}
aria-label="Chunk crossfade duration"
/>
</SettingRow>
<SettingRow
title={t('settings.generation.normalize.title')}
description={t('settings.generation.normalize.description')}
title="Normalize audio"
description="Adjusts output volume to a consistent level across generations."
htmlFor="normalizeAudio"
action={
<Toggle
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={(v) => update({ normalize_audio: v })}
onCheckedChange={setNormalizeAudio}
/>
}
/>
<SettingRow
title={t('settings.generation.autoplay.title')}
description={t('settings.generation.autoplay.description')}
title="Autoplay on generate"
description="Automatically play audio when a generation completes."
htmlFor="autoplayOnGenerate"
action={
<Toggle
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
onCheckedChange={setAutoplayOnGenerate}
/>
}
/>
<SettingRow
title={t('settings.generation.folder.title')}
description={generationsPath ?? t('settings.generation.folder.description')}
title="Generations folder"
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
action={
<Button
variant="outline"
@@ -140,52 +128,11 @@ export function GenerationPage() {
disabled={opening || !generationsPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.generation.folder.open')}
Open
</Button>
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.generation.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Mic className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.generation.sidebar.clone.title')}
</span>{' '}
{t('settings.generation.sidebar.clone.body')}
</span>
</li>
<li className="flex gap-2.5">
<Languages className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.generation.sidebar.engines.title')}
</span>{' '}
{t('settings.generation.sidebar.engines.body')}
</span>
</li>
<li className="flex gap-2.5">
<Zap className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">{t('settings.generation.sidebar.agentReady.title')}</span>{' '}
{t('settings.generation.sidebar.agentReady.body')}
</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
+122 -341
View File
@@ -1,11 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress, RocmDownloadProgress, HealthResponse } from '@/lib/api/types';
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -41,19 +40,16 @@ function GpuIcon({ className }: { className?: string }) {
}
function GpuInfoCard({ health }: { health: HealthResponse }) {
const { t } = useTranslation();
const hasGpu = health.gpu_available && health.gpu_type;
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
const gpuName = hasGpu
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type!
: null;
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
const showBackendVariant =
health.backend_variant &&
health.backend_variant !== 'cpu' &&
health.backend_variant.toLowerCase() !== gpuBackend?.toLowerCase();
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
return (
<div className="rounded-lg border border-border/60 p-4">
@@ -68,7 +64,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
<div className="text-sm font-medium">{hasGpu ? gpuName : 'CPU Only'}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{hasGpu ? (
<>
@@ -82,14 +78,12 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
<>
<span className="text-border">|</span>
<span>
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
</span>
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
</>
)}
</>
) : (
<span>{t('settings.gpu.noAcceleration')}</span>
<span>No GPU acceleration detected</span>
)}
</div>
</div>
@@ -99,9 +93,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
</span>
<span className="text-[10px] font-medium text-muted-foreground">
{t('settings.gpu.active')}
</span>
<span className="text-[10px] font-medium text-muted-foreground">Active</span>
</div>
)}
</div>
@@ -110,7 +102,6 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
}
export function GpuPage() {
const { t } = useTranslation();
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
@@ -118,19 +109,9 @@ export function GpuPage() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [cudaStreaming, setCudaStreaming] = useState(false);
const [rocmStreaming, setRocmStreaming] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const {
data: cudaStatus,
isLoading: _cudaStatusLoading,
@@ -143,27 +124,9 @@ export function GpuPage() {
enabled: !!health,
});
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health,
});
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// The ROCm backend only applies to AMD GPUs on Windows. Show the section when
// the backend detects applicable hardware, or it is already downloaded/active.
const supportsRocm = (health?.supports_rocm ?? false) || rocmAvailable || isCurrentlyRocm;
useEffect(() => {
return () => {
@@ -175,7 +138,7 @@ export function GpuPage() {
}, []);
useEffect(() => {
if ((!cudaDownloading && !cudaStreaming) || !serverUrl) return;
if (!cudaDownloading || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
@@ -187,13 +150,11 @@ export function GpuPage() {
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setError(data.error || 'Download failed');
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
}
} catch (e) {
@@ -203,50 +164,12 @@ export function GpuPage() {
eventSource.onerror = () => {
eventSource.close();
setCudaStreaming(false);
};
return () => {
eventSource.close();
};
}, [cudaDownloading, cudaStreaming, serverUrl, refetchCudaStatus]);
useEffect(() => {
if ((!rocmDownloading && !rocmStreaming) || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
setRocmStreaming(false);
};
return () => {
eventSource.close();
};
}, [rocmDownloading, rocmStreaming, serverUrl, refetchRocmStatus]);
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
@@ -289,14 +212,13 @@ export function GpuPage() {
[platform, startHealthPolling, clearHealthPolling],
);
const handleDownloadCuda = async () => {
const handleDownload = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
setCudaStreaming(true);
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
@@ -305,80 +227,34 @@ export function GpuPage() {
}
};
const handleDownloadRocm = async () => {
const handleRestart = async () => {
setError(null);
try {
await apiClient.downloadRocmBackend();
setRocmStreaming(true);
refetchRocmStatus();
await restartServerWithPolling('Restart failed');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
setError(e instanceof Error ? e.message : 'Restart failed');
}
};
const handleSwitchToCpu = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('cpu');
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
await apiClient.deleteCudaBackend();
await restartServerWithPolling('Failed to switch to CPU');
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
refetchRocmStatus();
}
};
const handleSwitchToCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('cuda');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchCudaStatus();
}
};
const handleSwitchToRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('rocm');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
const handleDelete = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteRocm'));
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
}
};
@@ -395,7 +271,6 @@ export function GpuPage() {
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
!isCurrentlyRocm &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
@@ -403,222 +278,128 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
)}
{error && (
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownloadCuda} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleSwitchToCuda} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDeleteCuda}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
{supportsRocm && (
<SettingSection
title={t('settings.gpu.rocm.title')}
description={t('settings.gpu.rocm.description')}
>
{rocmDownloading && rocmDownloadProgress && (
<SettingRow title={t('settings.gpu.rocm.downloading')}>
<div className="space-y-1.5">
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? t('settings.gpu.rocm.updating')
: t('settings.gpu.rocm.downloadingShort'))}
</span>
<span>
{rocmDownloadProgress.total > 0
? `${formatBytes(rocmDownloadProgress.current)} / ${formatBytes(rocmDownloadProgress.total)}`
: `${rocmDownloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !rocmDownloading && (
<>
{!rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.downloadRocm.title')}
description={t('settings.gpu.downloadRocm.description')}
action={
<Button onClick={handleDownloadRocm} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.downloadRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToRocm.title')}
description={t('settings.gpu.switchToRocm.description')}
action={
<Button onClick={handleSwitchToRocm} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.removeRocm.title')}
description={t('settings.gpu.removeRocm.description')}
action={
<Button
onClick={handleDeleteRocm}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.removeRocm.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
</>
)}
{(isCurrentlyCuda || isCurrentlyRocm) && platform.metadata.isTauri && (
{/* CUDA section — only when no native GPU and not already on CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title={isCurrentlyCuda ? t('settings.gpu.cuda.activeTitle') : t('settings.gpu.rocm.activeTitle')}
description={t('settings.gpu.activeBackend.description')}
title="CUDA Backend"
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
>
{restartPhase !== 'idle' ? (
{/* Download progress */}
{cudaDownloading && downloadProgress && (
<SettingRow title="Downloading CUDA backend...">
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable ? 'Updating...' : 'Downloading...')}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{/* Restart in progress */}
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? t('settings.gpu.restart.ready')
? 'Server restarted successfully'
: restartPhase === 'waiting'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
? 'Restarting server...'
: 'Stopping server...'
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
) : (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{/* Error */}
{error && (
<SettingRow title={t('common.error')}>
<SettingRow title="Error">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</SettingRow>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Download CUDA backend"
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
action={
<Button onClick={handleDownload} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CUDA backend"
description="CUDA backend is downloaded and ready. Restart to enable."
action={
<Button onClick={handleRestart} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
Restart
</Button>
}
/>
)}
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CPU backend"
description="Disable GPU acceleration. You can re-download CUDA later."
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
Switch
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Remove CUDA backend"
description="Delete the downloaded CUDA binary to free disk space."
action={
<Button
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
<p className="text-xs text-muted-foreground/60 leading-relaxed">
Voicebox automatically detects and uses the best available GPU on your system. On Apple
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.
</p>
</div>
);
}
@@ -1,34 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
export function LanguageSelect() {
const { i18n } = useTranslation();
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language)?.code ?? 'en';
return (
<Select
value={current}
onValueChange={(value) => {
void i18n.changeLanguage(value as LanguageCode);
}}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+11 -8
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { type LogEntry, useLogStore } from '@/stores/logStore';
@@ -33,7 +32,6 @@ function LogLine({ entry }: { entry: LogEntry }) {
}
export function LogsPage() {
const { t } = useTranslation();
const entries = useLogStore((s) => s.entries);
const clear = useLogStore((s) => s.clear);
const containerRef = useRef<HTMLDivElement>(null);
@@ -58,9 +56,9 @@ export function LogsPage() {
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
<h3 className="text-sm font-medium">Server Logs</h3>
<p className="text-sm text-muted-foreground">
{t('settings.logs.lineCount', { count: entries.length })}
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
</p>
</div>
<div className="flex items-center gap-2">
@@ -73,11 +71,11 @@ export function LogsPage() {
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
}}
>
{t('settings.logs.scrollToBottom')}
Scroll to bottom
</Button>
)}
<Button variant="outline" size="sm" onClick={clear}>
{t('settings.logs.clear')}
Clear
</Button>
</div>
</div>
@@ -89,8 +87,13 @@ export function LogsPage() {
>
{entries.length === 0 ? (
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
<p>{t('settings.logs.empty')}</p>
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
<p>No log output yet.</p>
{!import.meta.env?.PROD && (
<p>
Server logs are only captured when the app manages the server process (production
builds).
</p>
)}
</div>
) : (
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
-354
View File
@@ -1,354 +0,0 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useServerStore } from '@/stores/serverStore';
import { formatDate } from '@/lib/utils/format';
import { SettingRow, SettingSection } from './SettingRow';
function getStdioShimCommand(): string {
if (typeof navigator === 'undefined') {
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
}
const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
if (platform.includes('win')) {
return 'C:\\Program Files\\Voicebox\\voicebox-mcp.exe';
}
if (platform.includes('linux')) {
return '/opt/voicebox/voicebox-mcp';
}
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
}
/**
* Settings → MCP — configure per-agent voice binding and show copy-paste
* install snippets for major MCP clients. Backend runs at /mcp on the
* existing Voicebox server; this page is the agent-onboarding surface.
*/
export function MCPPage() {
const { t } = useTranslation();
const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles();
const { settings: captureSettings, update: updateCapture } = useCaptureSettings();
const defaultProfileId = captureSettings?.default_playback_voice_id ?? '';
const mcpUrl = `${serverUrl}/mcp`;
const stdioShimCommand = getStdioShimCommand();
const [newClientId, setNewClientId] = useState('');
const [newLabel, setNewLabel] = useState('');
const [newProfileId, setNewProfileId] = useState('');
const [adding, setAdding] = useState(false);
const handleAdd = async () => {
if (!newClientId.trim()) return;
setAdding(true);
try {
await upsertAsync({
client_id: newClientId.trim(),
label: newLabel.trim() || null,
profile_id: newProfileId || null,
});
setNewClientId('');
setNewLabel('');
setNewProfileId('');
} finally {
setAdding(false);
}
};
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title={t('settings.mcp.install.title')}
description={t('settings.mcp.install.description')}
>
<SnippetRow
title={t('settings.mcp.install.http.title')}
description={t('settings.mcp.install.http.description')}
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
url: mcpUrl,
headers: { 'X-Voicebox-Client-Id': 'claude-code' },
},
},
},
null,
2,
)}
/>
<SnippetRow
title={t('settings.mcp.install.claudeCode.title')}
description={t('settings.mcp.install.claudeCode.description')}
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
/>
<SnippetRow
title={t('settings.mcp.install.stdio.title')}
description={t('settings.mcp.install.stdio.description')}
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
command: stdioShimCommand,
env: { VOICEBOX_CLIENT_ID: 'claude-code' },
},
},
},
null,
2,
)}
/>
</SettingSection>
<SettingSection
title={t('settings.mcp.defaultVoice.title')}
description={t('settings.mcp.defaultVoice.description')}
>
<SettingRow
title={t('settings.mcp.defaultVoice.label')}
description={t('settings.mcp.defaultVoice.labelHint')}
action={
<Select
value={defaultProfileId || '__default__'}
onValueChange={(v) =>
updateCapture({
default_playback_voice_id: v === '__default__' ? null : v,
})
}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.defaultVoice.none')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SettingSection>
<SettingSection
title={t('settings.mcp.bindings.title')}
description={t('settings.mcp.bindings.description')}
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 italic">
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
</p>
) : (
<div className="divide-y divide-border/60">
{bindings.map((b) => (
<div
key={b.client_id}
className="py-3 grid grid-cols-[1fr_auto_auto] gap-4 items-center"
>
<div className="min-w-0">
<div className="font-medium text-sm truncate">
{b.label || b.client_id}
</div>
<div className="text-xs text-muted-foreground truncate">
<code className="text-[11px]">{b.client_id}</code>
{' · '}
{b.last_seen_at ? (
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
</span>
) : (
<span>{t('settings.mcp.bindings.neverConnected')}</span>
)}
</div>
</div>
<Select
value={b.profile_id ?? '__default__'}
onValueChange={(v) =>
upsertAsync({
client_id: b.client_id,
label: b.label,
profile_id: v === '__default__' ? null : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => remove(b.client_id)}
aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="pt-4 space-y-2">
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
type="text"
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<input
type="text"
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<Select
value={newProfileId || '__default__'}
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
>
<SelectTrigger className="h-9 min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
{t('settings.mcp.bindings.add.action')}
</Button>
</div>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.mcp.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</h3>
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<li>
<code className="text-accent">voicebox.speak</code>
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
</li>
<li>
<code className="text-accent">voicebox.transcribe</code>
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_captures</code>
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_profiles</code>
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
</li>
</ul>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waypoints className="h-3.5 w-3.5 text-accent" />
<span>
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
</span>
</div>
</aside>
</div>
);
}
function SnippetRow({
title,
description,
snippet,
}: {
title: string;
description: string;
snippet: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore; user can still select-and-copy the pre content
}
};
return (
<div className="py-3 space-y-2">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<Button size="sm" variant="outline" onClick={copy}>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
{t('settings.mcp.install.copied')}
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
{t('settings.mcp.install.copy')}
</>
)}
</Button>
</div>
<pre className="text-[11px] font-mono p-3 rounded-md bg-muted/50 overflow-x-auto whitespace-pre-wrap break-all">
{snippet}
</pre>
</div>
);
}
+8 -15
View File
@@ -1,18 +1,14 @@
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface SettingsTab {
labelKey?: string;
label?: string;
label: string;
path:
| '/settings'
| '/settings/generation'
| '/settings/captures'
| '/settings/mcp'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
@@ -21,18 +17,15 @@ interface SettingsTab {
}
const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
{ label: 'General', path: '/settings' },
{ label: 'Generation', path: '/settings/generation' },
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
{ label: 'Changelog', path: '/settings/changelog' },
{ label: 'About', path: '/settings/about' },
];
export function SettingsLayout() {
const { t } = useTranslation();
const platform = usePlatform();
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
const matchRoute = useMatchRoute();
@@ -59,7 +52,7 @@ export function SettingsLayout() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
)}
>
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
{tab.label}
</Link>
);
})}
+1 -1
View File
@@ -14,7 +14,7 @@ export function SettingSection({
}) {
return (
<div className="space-y-1">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{title && <h3 className="text-sm font-medium">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
{children}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type Theme, useUIStore } from '@/stores/uiStore';
export function ThemeSelect() {
const { t } = useTranslation();
const theme = useUIStore((s) => s.theme);
const setTheme = useUIStore((s) => s.setTheme);
return (
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
</SelectContent>
</Select>
);
}
+21 -21
View File
@@ -1,7 +1,6 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
@@ -13,24 +12,17 @@ interface SidebarProps {
isMacOS?: boolean;
}
const tabs: Array<{
id: string;
path: string;
icon: LucideIcon;
labelKey?: string;
label?: string;
}> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ 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: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
];
export function Sidebar({ isMacOS }: SidebarProps) {
const { t } = useTranslation();
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
const platform = usePlatform();
@@ -47,7 +39,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
>
{/* Logo */}
<div className="mb-2">
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo 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 */}
@@ -72,8 +72,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
? '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 ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
title={tab.label}
aria-label={tab.label}
>
{isActive && (
<div
@@ -102,7 +102,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
to="/settings"
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
>
{t('nav.updateBadge')}
Update
</Link>
)}
</div>
+2 -2
View File
@@ -7,7 +7,7 @@ export function StoriesTab() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* Main content area */}
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
{/* Left Column - Story List */}
@@ -16,7 +16,7 @@ export function StoriesTab() {
</div>
{/* Right Column - Story Content */}
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
<StoryContent />
</div>
+13 -36
View File
@@ -1,8 +1,7 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -21,7 +20,6 @@ interface StoryChatItemProps {
storyId: string;
index: number;
onRemove: () => void;
onRegenerate?: () => void;
currentTimeMs: number;
isPlaying: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
@@ -31,13 +29,11 @@ interface StoryChatItemProps {
export function StoryChatItem({
item,
onRemove,
onRegenerate,
currentTimeMs,
isPlaying,
dragHandleProps,
isDragging,
}: StoryChatItemProps) {
const { t } = useTranslation();
const seek = useStoryStore((state) => state.seek);
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
@@ -85,9 +81,7 @@ export function StoryChatItem({
{/* Voice Avatar */}
<div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
{item.engine === 'import' ? (
<Music className="h-5 w-5 text-muted-foreground" />
) : !avatarError ? (
{!avatarError ? (
<img
src={avatarUrl}
alt={`${item.profile_name} avatar`}
@@ -106,56 +100,39 @@ export function StoryChatItem({
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm truncate">
{item.engine === 'import' ? item.text : item.profile_name}
</span>
{item.engine !== 'import' && (
<span className="text-xs text-muted-foreground">{item.language}</span>
)}
<span className="font-medium text-sm">{item.profile_name}</span>
<span className="text-xs text-muted-foreground">{item.language}</span>
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
{formatTime(itemStartMs)}
</span>
</div>
{item.engine === 'import' ? null : (
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
readOnly
onDoubleClick={handlePlay}
/>
)}
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
readOnly
onDoubleClick={handlePlay}
/>
</div>
{/* Actions */}
<div className="shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={t('history.actions.menu')}
>
<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}>
<Play className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.playFromHere')}
Play from here
</DropdownMenuItem>
{onRegenerate && (
<DropdownMenuItem onClick={onRegenerate}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.regenerate')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onRemove}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.removeFromStory')}
Remove from Story
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+23 -137
View File
@@ -15,15 +15,13 @@ import {
} from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Music, Plus, Upload } from 'lucide-react';
import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useHistory } from '@/lib/hooks/useHistory';
import {
useAddStoryItem,
@@ -38,7 +36,6 @@ import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem';
export function StoryContent() {
const { t } = useTranslation();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story, isLoading } = useStory(selectedStoryId);
const removeItem = useRemoveStoryItem();
@@ -47,12 +44,7 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const dragDepthRef = useRef(0);
// Add generation popover state
const [searchQuery, setSearchQuery] = useState('');
@@ -78,12 +70,8 @@ export function StoryContent() {
// Track editor is shown when story has items
const hasBottomBar = story && story.items.length > 0;
// Clear the floating generate box (always visible on this route) and the
// track editor bar when it's showing.
const FLOATING_BOX_CLEARANCE = 140;
const bottomPadding = hasBottomBar
? trackEditorHeight + FLOATING_BOX_CLEARANCE
: FLOATING_BOX_CLEARANCE;
// Calculate dynamic bottom padding: track editor + gap
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
// Drag and drop sensors
const sensors = useSensors(
@@ -148,19 +136,6 @@ export function StoryContent() {
}
}, [isPlaying]);
const handleRegenerate = async (generationId: string) => {
try {
await apiClient.regenerateGeneration(generationId);
addPendingGeneration(generationId);
} catch (error) {
toast({
title: t('storyContent.toast.regenerateFailed'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleRemoveItem = (itemId: string) => {
if (!story) return;
@@ -172,7 +147,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: t('storyContent.toast.removeFailed'),
title: 'Failed to remove item',
description: error.message,
variant: 'destructive',
});
@@ -204,7 +179,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: t('storyContent.toast.reorderFailed'),
title: 'Failed to reorder items',
description: error.message,
variant: 'destructive',
});
@@ -224,7 +199,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: t('storyContent.toast.exportFailed'),
title: 'Failed to export audio',
description: error.message,
variant: 'destructive',
});
@@ -233,33 +208,6 @@ export function StoryContent() {
);
};
const handleImportAudio = async (file: File) => {
if (!story) return;
setIsImporting(true);
try {
const generation = await apiClient.importAudio(file);
await addStoryItem.mutateAsync({
storyId: story.id,
data: { generation_id: generation.id },
});
setIsAddOpen(false);
} catch (error) {
toast({
title: t('storyContent.toast.importFailed'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
} finally {
setIsImporting(false);
}
};
const handleImportFiles = async (files: FileList | File[]) => {
for (const file of Array.from(files)) {
await handleImportAudio(file);
}
};
const handleAddGeneration = (generationId: string) => {
if (!story) return;
@@ -275,7 +223,7 @@ export function StoryContent() {
},
onError: (error) => {
toast({
title: t('storyContent.toast.addFailed'),
title: 'Failed to add generation',
description: error.message,
variant: 'destructive',
});
@@ -288,8 +236,8 @@ export function StoryContent() {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
<p className="text-lg font-medium mb-2">Select a story</p>
<p className="text-sm">Choose a story from the list to view its content</p>
</div>
</div>
);
@@ -298,7 +246,7 @@ export function StoryContent() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
<div className="text-muted-foreground">Loading story...</div>
</div>
);
}
@@ -307,62 +255,17 @@ export function StoryContent() {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
<p className="text-lg font-medium mb-2">Story not found</p>
<p className="text-sm">The selected story could not be loaded</p>
</div>
</div>
);
}
return (
<div
className="flex flex-col h-full min-h-0 relative overflow-hidden"
onDragEnter={(e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFile(true);
}}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
}}
onDragLeave={(e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFile(false);
}}
onDrop={(e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFile(false);
handleImportFiles(e.dataTransfer.files);
}}
>
<input
ref={importInputRef}
type="file"
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) handleImportFiles(e.target.files);
e.target.value = '';
}}
/>
{isDraggingFile && (
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
<div className="flex flex-col items-center gap-2 text-accent">
<Music className="h-8 w-8" />
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
</div>
</div>
)}
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
<div className="flex items-center justify-between mb-4 px-1">
<div>
<h2 className="text-2xl font-bold">{story.name}</h2>
{story.description && (
@@ -388,7 +291,7 @@ export function StoryContent() {
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t('storyContent.generatingCount', { count: pendingCount })}
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
</span>
</Link>
</motion.div>
@@ -398,34 +301,22 @@ export function StoryContent() {
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('storyContent.add')}
Add
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-2 border-b space-y-2">
<div className="p-2 border-b">
<Input
placeholder={t('storyContent.searchPlaceholder')}
placeholder="Search by name or transcript..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => importInputRef.current?.click()}
disabled={isImporting}
>
<Upload className="mr-2 h-4 w-4" />
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
</Button>
</div>
<div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery
? t('storyContent.searchNoMatches')
: t('storyContent.searchNoAvailable')}
{searchQuery ? 'No matching generations found' : 'No available generations'}
</div>
) : (
availableGenerations.map((gen) => (
@@ -453,7 +344,7 @@ export function StoryContent() {
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
{t('storyContent.exportAudio')}
Export Audio
</Button>
)}
</div>
@@ -462,13 +353,13 @@ export function StoryContent() {
{/* Content */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
className="flex-1 min-h-0 overflow-y-auto space-y-3"
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
>
{sortedItems.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
<p className="text-sm">{t('storyContent.empty.title')}</p>
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
<p className="text-sm">No items in this story</p>
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
</div>
) : (
<DndContext
@@ -497,11 +388,6 @@ export function StoryContent() {
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.id)}
onRegenerate={
item.engine === 'import'
? undefined
: () => handleRegenerate(item.generation_id)
}
currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id}
/>
+100 -136
View File
@@ -1,6 +1,5 @@
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
@@ -11,7 +10,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -29,15 +27,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import {
@@ -52,7 +41,6 @@ import { formatDate } from '@/lib/utils/format';
import { useStoryStore } from '@/stores/storyStore';
export function StoryList() {
const { t } = useTranslation();
const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
@@ -72,7 +60,6 @@ export function StoryList() {
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
const [newStoryName, setNewStoryName] = useState('');
const [newStoryDescription, setNewStoryDescription] = useState('');
const [search, setSearch] = useState('');
const { toast } = useToast();
// Auto-select the first story when the list loads with no selection
@@ -85,8 +72,8 @@ export function StoryList() {
const handleCreateStory = () => {
if (!newStoryName.trim()) {
toast({
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
title: 'Name required',
description: 'Please enter a story name',
variant: 'destructive',
});
return;
@@ -104,13 +91,13 @@ export function StoryList() {
setNewStoryName('');
setNewStoryDescription('');
toast({
title: t('stories.toast.created'),
description: t('stories.toast.createdDescription', { name: story.name }),
title: 'Story created',
description: `"${story.name}" has been created`,
});
},
onError: (error) => {
toast({
title: t('stories.toast.createFailed'),
title: 'Failed to create story',
description: error.message,
variant: 'destructive',
});
@@ -129,8 +116,8 @@ export function StoryList() {
const handleUpdateStory = () => {
if (!editingStory || !newStoryName.trim()) {
toast({
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
title: 'Name required',
description: 'Please enter a story name',
variant: 'destructive',
});
return;
@@ -153,7 +140,7 @@ export function StoryList() {
},
onError: (error) => {
toast({
title: t('stories.toast.updateFailed'),
title: 'Failed to update story',
description: error.message,
variant: 'destructive',
});
@@ -181,7 +168,7 @@ export function StoryList() {
},
onError: (error) => {
toast({
title: t('stories.toast.deleteFailed'),
title: 'Failed to delete story',
description: error.message,
variant: 'destructive',
});
@@ -189,113 +176,85 @@ export function StoryList() {
});
};
const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return storyList;
return storyList.filter((s) => {
const name = (s.name || '').toLowerCase();
const description = (s.description || '').toLowerCase();
return name.includes(q) || description.includes(q);
});
}, [search, storyList]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('stories.loading')}</div>
<div className="text-muted-foreground">Loading stories...</div>
</div>
);
}
return (
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('stories.searchPlaceholder')}
/>
</ListPaneHeader>
const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
<ListPaneScroll
return (
<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>
{/* 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="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{t('stories.empty.title')}</p>
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
<p>{t('stories.empty.noMatches', { query: search })}</p>
<p className="text-sm">No stories yet</p>
<p className="text-xs mt-2">Create your first story to get started</p>
</div>
) : (
<div className="px-4 pb-6 space-y-1">
{filtered.map((story) => {
const isActive = selectedStoryId === story.id;
return (
<div key={story.id} className="relative group">
<button
type="button"
onClick={() => setSelectedStoryId(story.id)}
aria-label={t('stories.row.ariaLabel', {
name: story.name,
count: story.item_count,
updated: formatDate(story.updated_at),
})}
aria-pressed={isActive}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(story.updated_at)}
<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>
<div className="flex-1" />
<span>·</span>
<span>{formatDate(story.updated_at)}</span>
</div>
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
<span className="text-foreground font-medium">{story.name}</span>
{story.description ? (
<>
<span className="mx-1.5 text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{story.description}</span>
</>
) : null}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
>
{t('stories.row.itemCount', { count: story.item_count })}
</Badge>
</div>
</button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={t('stories.row.actionsLabel', { name: story.name })}
aria-label={`Actions for ${story.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
@@ -303,37 +262,39 @@ export function StoryList() {
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditClick(story)}>
<Pencil className="mr-2 h-4 w-4" />
{t('common.edit')}
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(story.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('common.delete')}
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
))}
</div>
)}
</ListPaneScroll>
</div>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
<DialogTitle>Create New Story</DialogTitle>
<DialogDescription>
Create a new story to organize your voice generations into conversations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
<Label htmlFor="story-name">Name</Label>
<Input
id="story-name"
placeholder={t('stories.fields.namePlaceholder')}
placeholder="My Story"
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
@@ -344,10 +305,10 @@ export function StoryList() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
<Label htmlFor="story-description">Description (optional)</Label>
<Textarea
id="story-description"
placeholder={t('stories.fields.descriptionPlaceholder')}
placeholder="A conversation between..."
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
@@ -356,29 +317,28 @@ export function StoryList() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
{createStory.isPending
? t('stories.createDialog.creating')
: t('stories.createDialog.action')}
{createStory.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Story Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
<DialogTitle>Edit Story</DialogTitle>
<DialogDescription>Update the story name and description.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
<Label htmlFor="edit-story-name">Name</Label>
<Input
id="edit-story-name"
placeholder={t('stories.fields.namePlaceholder')}
placeholder="My Story"
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
@@ -389,10 +349,10 @@ export function StoryList() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
<Label htmlFor="edit-story-description">Description (optional)</Label>
<Textarea
id="edit-story-description"
placeholder={t('stories.fields.descriptionPlaceholder')}
placeholder="A conversation between..."
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
@@ -401,35 +361,39 @@ export function StoryList() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
{updateStory.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Story Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the story and all its items. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={handleDeleteConfirm}
disabled={deleteStory.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPane>
</div>
);
}
@@ -7,12 +7,9 @@ import {
Pause,
Play,
Plus,
RotateCcw,
Scissors,
Square,
Trash2,
Volume2,
VolumeX,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
@@ -23,8 +20,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
@@ -35,10 +30,8 @@ import {
useSetStoryItemVersion,
useSplitStoryItem,
useTrimStoryItem,
useUpdateStoryItemVolume,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support
@@ -82,19 +75,8 @@ function ClipWaveform({
const waveColor = getCSSVar('--accent-foreground');
// Hand WaveSurfer a muted <audio> element so the MediaElement backend
// can never bleed audio. Web Audio is doing the actual playback in
// useStoryPlayback; this clip waveform exists purely for the visual.
// Without this, long imported clips (MP3 / M4A) end up audible from
// wavesurfer's own element on top of the timeline, and that element
// doesn't get paused by stopAllSources().
const mediaElement = document.createElement('audio');
mediaElement.muted = true;
mediaElement.preload = 'metadata';
const wavesurfer = WaveSurfer.create({
container: waveformRef.current,
media: mediaElement,
waveColor,
progressColor: waveColor,
cursorWidth: 0,
@@ -136,66 +118,6 @@ function ClipWaveform({
);
}
// Per-clip volume popover. Local state drives the slider during a drag so
// each pointer-move pixel doesn't fire a PATCH; commits on release.
function ClipVolumePopover({
storyId,
itemId,
volume,
onChange,
}: {
storyId: string;
itemId: string;
volume: number;
onChange: (value: number) => void;
}) {
const [localVolume, setLocalVolume] = useState(volume);
// Re-sync when the selected clip changes or the persisted value updates
// out-of-band (split/duplicate carry the value forward).
useEffect(() => {
setLocalVolume(volume);
}, [volume, itemId, storyId]);
const display = Math.round(localVolume * 100);
const Icon = localVolume === 0 ? VolumeX : Volume2;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={`Volume — ${display}%`}
aria-label="Adjust clip volume"
>
<Icon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Volume</span>
<span className="text-xs tabular-nums">{display}%</span>
</div>
<Slider
value={[localVolume * 100]}
onValueChange={([v]) => setLocalVolume(v / 100)}
onValueCommit={([v]) => onChange(v / 100)}
min={0}
max={200}
step={1}
aria-label="Clip volume"
/>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground tabular-nums">
<span>0%</span>
<span>100%</span>
<span>200%</span>
</div>
</PopoverContent>
</Popover>
);
}
interface StoryTrackEditorProps {
storyId: string;
items: StoryItemDetail[];
@@ -203,21 +125,15 @@ interface StoryTrackEditorProps {
const TRACK_HEIGHT = 48;
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
const SCRUB_BAR_HEIGHT = 16;
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
// Zoom is expressed to the user as how many seconds of timeline are visible
// at once. Min scope = the most you can zoom IN; max scope = the entire
// project. Default scope is what we land on when the editor first measures.
const MIN_VISIBLE_SECONDS = 10;
const DEFAULT_VISIBLE_SECONDS = 60;
const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured
const MIN_PIXELS_PER_SECOND = 10;
const MAX_PIXELS_PER_SECOND = 200;
const DEFAULT_PIXELS_PER_SECOND = 50;
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
const MIN_EDITOR_HEIGHT = 120;
const MAX_EDITOR_HEIGHT = 500;
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND);
const hasAppliedDefaultZoomRef = useRef(false);
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
const [draggingItem, setDraggingItem] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
@@ -233,13 +149,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const updateVolume = useUpdateStoryItemVolume();
const { toast } = useToast();
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
// User-added empty tracks. Live in component state because a track only
// earns its keep once a clip lands on it — no need to persist an unused
// row across reloads.
const [extraTracks, setExtraTracks] = useState<number[]>([]);
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
@@ -348,32 +258,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
stop();
};
// Calculate unique tracks from items, always showing at least 3 default
// tracks. ``extraTracks`` lets the user open a fresh row without first
// having to drag a clip there.
// Calculate unique tracks from items, always showing at least 3 default tracks
const tracks = useMemo(() => {
const trackSet = new Set([
...DEFAULT_TRACKS,
...items.map((item) => item.track),
...extraTracks,
]);
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
}, [items, extraTracks]);
const handleAddTrackAbove = useCallback(() => {
setExtraTracks((prev) => {
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
const next = (all.size > 0 ? Math.max(...all) : 0) + 1;
return [...prev, next];
});
}, [items]);
const handleAddTrackBelow = useCallback(() => {
setExtraTracks((prev) => {
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
const next = (all.size > 0 ? Math.min(...all) : 0) - 1;
return [...prev, next];
});
}, [items]);
// Track container width for full-width minimum
@@ -394,44 +282,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return () => observer.disconnect();
}, []);
// Horizontal scrollbar state
const [timelineScrollLeft, setTimelineScrollLeft] = useState(0);
const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0);
const scrollbarTrackRef = useRef<HTMLDivElement>(null);
const scrollbarDragRef = useRef<{
mode: 'pan' | 'left' | 'right';
startX: number;
startScrollLeft: number;
startPixelsPerSecond: number;
} | null>(null);
// Anchor the visible left/right edge time during a zoom drag so the edge
// the user isn't dragging stays pinned in place across pixelsPerSecond changes.
const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null);
// Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it
useEffect(() => {
const el = tracksRef.current;
if (!el) return;
const onScroll = () => setTimelineScrollLeft(el.scrollLeft);
el.addEventListener('scroll', onScroll);
setTimelineScrollLeft(el.scrollLeft);
return () => el.removeEventListener('scroll', onScroll);
}, []);
// Track scrollbar track width for thumb sizing
useEffect(() => {
const el = scrollbarTrackRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setScrollbarTrackWidth(entry.contentRect.width);
}
});
ro.observe(el);
setScrollbarTrackWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
// Calculate effective duration (accounting for trims)
const getEffectiveDuration = (item: StoryItemDetail) => {
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
@@ -443,41 +293,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
}, [items, getEffectiveDuration]);
// Zoom bounds are framed in seconds-of-timeline-visible-at-once (the
// "scope") rather than abstract pixels-per-second so the bar reflects
// something meaningful: fully zoomed out shows the entire project, fully
// zoomed in shows MIN_VISIBLE_SECONDS. Convert to pixels using the visible
// track area (container minus the sticky label column).
const visibleTrackWidth = Math.max(0, containerWidth - LABEL_COL_WIDTH);
const projectSeconds = totalDurationMs / 1000;
const { minPps, maxPps } = useMemo(() => {
if (visibleTrackWidth <= 0 || projectSeconds <= 0) {
return { minPps: 10, maxPps: 200 };
}
const min = visibleTrackWidth / projectSeconds;
const max = visibleTrackWidth / MIN_VISIBLE_SECONDS;
// For projects shorter than MIN_VISIBLE_SECONDS the entire bar collapses
// to one point; clamp so the range stays non-inverted.
return { minPps: min, maxPps: Math.max(max, min) };
}, [visibleTrackWidth, projectSeconds]);
// Apply the default scope (60 s, or the whole project if shorter) once we
// have a real measurement to convert it into pixels-per-second.
useEffect(() => {
if (hasAppliedDefaultZoomRef.current) return;
if (visibleTrackWidth <= 0) return;
const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS));
setPixelsPerSecond(visibleTrackWidth / defaultScope);
hasAppliedDefaultZoomRef.current = true;
}, [visibleTrackWidth, projectSeconds]);
// Re-clamp the current zoom whenever the bounds shift (project length
// changed, window resized) so the user can't end up parked outside the
// valid range from a previous session.
useEffect(() => {
setPixelsPerSecond((prev) => Math.max(minPps, Math.min(maxPps, prev)));
}, [minPps, maxPps]);
// Calculate timeline width - at least full container width
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
const timelineWidth = Math.max(contentWidth, containerWidth);
@@ -509,11 +324,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
const handleZoomIn = () => {
setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps));
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
};
const handleZoomOut = () => {
setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps));
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
};
// Resize handlers
@@ -559,7 +374,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
const timeMs = Math.max(0, pixelsToMs(x));
seek(timeMs);
// Deselect clip when clicking on timeline
@@ -690,10 +505,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const item = items.find((i) => i.id === selectedClipId);
if (!item) return;
// currentTimeMs is driven by audio playback and arrives as a float;
// the backend's StoryItemSplit.split_time_ms is `int`, so round before
// sending or pydantic rejects the request.
const splitTimeMs = Math.round(currentTimeMs - item.start_time_ms);
const splitTimeMs = currentTimeMs - item.start_time_ms;
const effectiveDuration = getEffectiveDuration(item);
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
@@ -778,20 +590,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
);
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
const handleRegenerate = useCallback(async () => {
if (!selectedItem) return;
try {
await apiClient.regenerateGeneration(selectedItem.generation_id);
addPendingGeneration(selectedItem.generation_id);
} catch (error) {
toast({
title: 'Failed to regenerate',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [selectedItem, addPendingGeneration, toast]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -856,13 +654,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
y: e.clientY - rect.top,
});
setDragPosition({
// Subtract label column width because clips live in a sub-container offset
// by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords.
x:
rect.left -
tracksRef.current.getBoundingClientRect().left +
tracksRef.current.scrollLeft -
LABEL_COL_WIDTH,
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
});
@@ -874,12 +666,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
if (!draggingItem || !tracksRef.current) return;
const rect = tracksRef.current.getBoundingClientRect();
const x =
e.clientX -
rect.left +
tracksRef.current.scrollLeft -
dragOffset.x -
LABEL_COL_WIDTH;
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
// Subtract ruler height since clips are positioned relative to tracks area
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
@@ -975,106 +762,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate tracks area height
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT;
// Scrollbar thumb geometry
const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth);
const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1;
const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth);
const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth);
const thumbLeft =
maxTimelineScroll > 0 && thumbRange > 0
? (timelineScrollLeft / maxTimelineScroll) * thumbRange
: 0;
const canScrollHorizontally = maxTimelineScroll > 0;
const handleScrollbarMouseDown = useCallback(
(mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
scrollbarDragRef.current = {
mode,
startX: e.clientX,
startScrollLeft: timelineScrollLeft,
startPixelsPerSecond: pixelsPerSecond,
};
},
[timelineScrollLeft, pixelsPerSecond],
);
// After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored
// edge (left or right of the visible window) stays at the same time.
useEffect(() => {
const anchor = zoomAnchorRef.current;
if (!anchor || !tracksRef.current) return;
const timePx = (anchor.timeMs / 1000) * pixelsPerSecond;
tracksRef.current.scrollLeft =
anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth);
}, [pixelsPerSecond, containerWidth]);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const drag = scrollbarDragRef.current;
if (!drag || !tracksRef.current) return;
const deltaX = e.clientX - drag.startX;
if (drag.mode === 'pan') {
if (thumbRange <= 0) return;
const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll;
tracksRef.current.scrollLeft = Math.max(
0,
Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll),
);
return;
}
if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return;
// Recompute the thumb width that corresponded to the drag start, then
// apply the mouse delta to the dragged edge.
const startTimelinePx =
(totalDurationMs / 1000) * drag.startPixelsPerSecond + 200;
const startThumbWidth = Math.max(
30,
Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth),
);
const newThumbWidth = Math.max(
30,
Math.min(
scrollbarTrackWidth,
drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX,
),
);
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
const newPps = Math.max(minPps, Math.min(maxPps, rawPps));
zoomAnchorRef.current =
drag.mode === 'right'
? {
type: 'left',
timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000,
}
: {
type: 'right',
timeMs:
((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000,
};
setPixelsPerSecond(newPps);
};
const onMouseUp = () => {
scrollbarDragRef.current = null;
zoomAnchorRef.current = null;
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]);
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
if (items.length === 0) {
return null;
@@ -1148,31 +836,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Copy className="h-4 w-4" />
</Button>
{selectedItem && (
<ClipVolumePopover
storyId={storyId}
itemId={selectedItem.id}
volume={selectedItem.volume}
onChange={(value) =>
updateVolume.mutate(
{
storyId,
itemId: selectedItem.id,
data: { volume: value },
},
{
onError: (error) => {
toast({
title: 'Failed to update volume',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
},
)
}
/>
)}
<Button
variant="ghost"
size="icon"
@@ -1183,18 +846,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Trash2 className="h-4 w-4" />
</Button>
{selectedItem?.engine !== 'import' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerate}
title="Regenerate"
aria-label="Regenerate clip"
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
{hasMultipleVersions && (
<>
<div className="w-px h-4 bg-border mx-1" />
@@ -1265,25 +916,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
</div>
{/* Timeline scroll container */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
<div
ref={tracksRef}
className="overflow-auto relative"
style={{ height: `${timelineContainerHeight}px` }}
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
{/* Ruler row: corner spacer + time ruler, sticky to top */}
{/* Timeline container with track labels sidebar */}
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
{/* Track labels sidebar - fixed width */}
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
{/* Spacer for time ruler */}
<div className="h-6 border-b bg-muted/30" />
{/* Track labels */}
<div style={{ height: `${tracksAreaHeight}px` }}>
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
className={cn(
'border-b flex items-center justify-center',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
style={{ height: `${TRACK_HEIGHT}px` }}
>
<span className="text-[10px] text-muted-foreground select-none">
{trackNumber}
</span>
</div>
))}
</div>
</div>
{/* Scrollable timeline area */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
<div
className="flex sticky top-0 z-30"
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
ref={tracksRef}
className="overflow-auto relative flex-1"
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
{/* Time ruler - clickable to seek */}
<button
type="button"
className="h-6 border-b bg-muted/20 cursor-pointer text-left relative"
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
style={{ width: `${timelineWidth}px` }}
onClick={handleTimelineClick}
aria-label="Seek timeline"
@@ -1301,72 +971,27 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
))}
</button>
</div>
{/* Tracks area (rows with sticky labels + clips sub-container) */}
<div
className="relative"
style={{
width: `${timelineWidth + LABEL_COL_WIDTH}px`,
height: `${tracksAreaHeight}px`,
}}
>
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
{tracks.map((trackNumber, index) => {
const isFirst = index === 0;
const isLast = index === tracks.length - 1;
return (
{/* Tracks area */}
<div
className="relative"
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
>
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
className="absolute left-0 right-0 flex"
className={cn(
'absolute left-0 right-0 border-b pointer-events-none',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
style={{
top: `${index * TRACK_HEIGHT}px`,
height: `${TRACK_HEIGHT}px`,
}}
>
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
<span className="relative text-[10px] text-muted-foreground select-none">
{trackNumber}
</span>
{isFirst && (
<button
type="button"
onClick={handleAddTrackAbove}
title="Add track above"
aria-label="Add track above"
className="absolute top-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
{isLast && (
<button
type="button"
onClick={handleAddTrackBelow}
title="Add track below"
aria-label="Add track below"
className="absolute bottom-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
</div>
<div
className={cn(
'border-b flex-1 pointer-events-none',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
/>
</div>
);
})}
/>
))}
{/* Clip/playhead/seek layer offset past the label column */}
<div
className="absolute top-0 bottom-0"
style={{ left: `${LABEL_COL_WIDTH}px`, width: `${timelineWidth}px` }}
>
{/* Click area for seeking - z-index lower than clips */}
<button
type="button"
@@ -1427,7 +1052,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Clip label */}
<div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.engine === 'import' ? item.text : item.profile_name}
{item.profile_name}
</p>
</div>
{/* Waveform */}
@@ -1476,55 +1101,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
</div>
</div>
{/* Horizontal timeline scrollbar + zoom handles */}
<div
className="flex border-t bg-background/40"
style={{ height: `${SCRUB_BAR_HEIGHT}px` }}
>
<div className="w-16 shrink-0 border-r" />
<div
ref={scrollbarTrackRef}
className="relative flex-1 overflow-hidden select-none px-1"
>
<div
className="absolute top-1 bottom-1 bg-foreground/10 hover:bg-foreground/15 transition-colors group rounded-full"
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
>
{/* Left zoom handle */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
<div
role="slider"
aria-label="Zoom from left edge"
aria-valuenow={Math.round(pixelsPerSecond)}
aria-valuemin={Math.round(minPps)}
aria-valuemax={Math.round(maxPps)}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full"
onMouseDown={handleScrollbarMouseDown('left')}
/>
{/* Pan area */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
<div
className={cn(
'absolute top-0 bottom-0 left-1.5 right-1.5',
canScrollHorizontally ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
)}
onMouseDown={canScrollHorizontally ? handleScrollbarMouseDown('pan') : undefined}
/>
{/* Right zoom handle */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
<div
role="slider"
aria-label="Zoom from right edge"
aria-valuenow={Math.round(pixelsPerSecond)}
aria-valuemin={Math.round(minPps)}
aria-valuemax={Math.round(maxPps)}
className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full"
onMouseDown={handleScrollbarMouseDown('right')}
/>
</div>
</div>
</div>
</div>
</div>
);
@@ -1,6 +1,5 @@
import { Mic, Pause, Play, Square } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Visualizer } from 'react-sound-visualizer';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
@@ -49,7 +48,6 @@ export function AudioSampleRecording({
isTranscribing = false,
showWaveform = true,
}: AudioSampleRecordingProps) {
const { t } = useTranslation();
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
// Request microphone access when component mounts
@@ -92,10 +90,10 @@ export function AudioSampleRecording({
className="relative z-10 flex items-center gap-2"
>
<Mic className="h-5 w-5" />
{t('audioSample.startRecording')}
Start Recording
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
{t('audioSample.recordHint')}
Click to start recording. Maximum duration: 30 seconds.
</p>
</div>
)}
@@ -117,10 +115,10 @@ export function AudioSampleRecording({
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
>
<Square className="h-4 w-4" />
{t('audioSample.stopRecording')}
Stop Recording
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
{formatAudioDuration(30 - duration)} remaining
</p>
</div>
)}
@@ -129,18 +127,16 @@ export function AudioSampleRecording({
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
<span className="font-medium">Recording complete</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -152,7 +148,7 @@ export function AudioSampleRecording({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
@@ -160,7 +156,7 @@ export function AudioSampleRecording({
onClick={onCancel}
className="flex items-center gap-2"
>
{t('audioSample.recordAgain')}
Record Again
</Button>
</div>
</div>
@@ -1,5 +1,4 @@
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
import { formatAudioDuration } from '@/lib/utils/audio';
@@ -29,7 +28,6 @@ export function AudioSampleSystem({
isPlaying,
isTranscribing = false,
}: AudioSampleSystemProps) {
const { t } = useTranslation();
return (
<FormItem>
<FormControl>
@@ -38,10 +36,10 @@ export function AudioSampleSystem({
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
{t('audioSample.startCapture')}
Start Capture
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.systemHint')}
Capture audio from your system. Maximum duration: 30 seconds.
</p>
</div>
)}
@@ -63,10 +61,10 @@ export function AudioSampleSystem({
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
{t('audioSample.stopCapture')}
Stop Capture
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
{formatAudioDuration(30 - duration)} remaining
</p>
</div>
)}
@@ -75,18 +73,16 @@ export function AudioSampleSystem({
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.captureComplete')}</span>
<span className="font-medium">Capture complete</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -98,7 +94,7 @@ export function AudioSampleSystem({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
@@ -106,7 +102,7 @@ export function AudioSampleSystem({
onClick={onCancel}
className="flex items-center gap-2"
>
{t('audioSample.captureAgain')}
Capture Again
</Button>
</div>
</div>
@@ -1,6 +1,5 @@
import { Mic, Pause, Play, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
@@ -27,7 +26,6 @@ export function AudioSampleUpload({
isDisabled = false,
fieldName,
}: AudioSampleUploadProps) {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -92,21 +90,19 @@ export function AudioSampleUpload({
className="flex items-center gap-2"
>
<Upload className="h-5 w-5" />
{t('audioSample.chooseFile')}
Choose File
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.uploadHint')}
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
<span className="font-medium">File uploaded</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
@@ -114,7 +110,7 @@ export function AudioSampleUpload({
variant="outline"
onClick={onPlayPause}
disabled={isValidating}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -126,7 +122,7 @@ export function AudioSampleUpload({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
@@ -138,7 +134,7 @@ export function AudioSampleUpload({
}
}}
>
{t('audioSample.remove')}
Remove
</Button>
</div>
</>
@@ -1,6 +1,5 @@
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -30,7 +29,6 @@ interface ProfileCardProps {
}
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
@@ -43,6 +41,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
if (disabled && isSelected) {
setSelectedProfileId(null);
setTimeout(() => setSelectedProfileId(profile.id), 0);
@@ -80,10 +79,9 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
}
};
const selectLabel = t(
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
{ name: profile.name, language: profile.language },
);
const selectLabel = isSelected
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
return (
<>
@@ -91,7 +89,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
className={cn(
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
@@ -107,7 +105,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
</CardHeader>
<CardContent className="p-3 pt-0 flex flex-col flex-1">
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || t('profiles.card.noDescription')}
{profile.description || 'No description'}
</p>
<div className="mb-2 flex items-center gap-1.5">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
@@ -120,22 +118,19 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
)}
{profile.voice_type === 'designed' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{t('profiles.card.designed')}
designed
</Badge>
)}
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
{profile.personality?.trim() && (
<Wand2 className="h-3.5 w-3.5 text-accent" />
)}
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
icon={Download}
onClick={handleExport}
disabled={exportProfile.isPending}
aria-label={t('profiles.card.export')}
aria-label="Export profile"
/>
<CircleButton
icon={Edit}
@@ -143,13 +138,13 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
e.stopPropagation();
handleEdit();
}}
aria-label={t('profiles.card.edit')}
aria-label="Edit profile"
/>
<CircleButton
icon={Trash2}
onClick={handleDeleteClick}
disabled={deleteProfile.isPending}
aria-label={t('profiles.card.delete')}
aria-label="Delete profile"
/>
</div>
</CardContent>
@@ -158,21 +153,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
{t('profiles.deleteDialog.body', { name: profile.name })}
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
+122 -185
View File
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Badge } from '@/components/ui/badge';
@@ -72,40 +71,30 @@ const DEFAULT_ENGINE_OPTIONS = [
{ value: 'kokoro', label: 'Kokoro 82M' },
] as const;
function makeProfileSchema(t: (key: string) => string) {
const baseProfileSchema = z.object({
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
personality: z.string().max(2000).optional(),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
});
const baseProfileSchema = 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[]]),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
});
return baseProfileSchema.refine(
(data) => {
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: t('profileForm.validation.referenceRequired'),
path: ['referenceText'],
},
);
}
const profileSchema = baseProfileSchema.refine(
(data) => {
// If sample file is provided, reference text is required
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: 'Reference text is required when adding a sample',
path: ['referenceText'],
},
);
type ProfileFormValues = {
name: string;
description?: string;
language: LanguageCode;
personality?: string;
sampleFile?: File;
referenceText?: string;
avatarFile?: File;
};
type ProfileFormValues = z.infer<typeof profileSchema>;
// Helper to convert File to base64
async function fileToBase64(file: File): Promise<string> {
@@ -130,7 +119,6 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
}
export function ProfileForm() {
const { t } = useTranslation();
const platform = usePlatform();
const open = useUIStore((state) => state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
@@ -163,12 +151,11 @@ export function ProfileForm() {
const [defaultEngine, setDefaultEngine] = useState<string>('');
const form = useForm<ProfileFormValues>({
resolver: zodResolver(makeProfileSchema(t)),
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
@@ -188,10 +175,7 @@ export function ProfileForm() {
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: t('profileForm.validation.audioTooLong', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
} else {
form.clearErrors('sampleFile');
@@ -200,13 +184,14 @@ export function ProfileForm() {
.catch((error) => {
console.error('Failed to get audio duration:', error);
setAudioDuration(null);
// For recordings, we auto-stop at max duration, so we can skip validation errors
const isRecordedFile =
selectedFile.name.startsWith('recording-') ||
selectedFile.name.startsWith('system-audio-');
if (!isRecordedFile) {
form.setError('sampleFile', {
type: 'manual',
message: t('profileForm.validation.audioFailed'),
message: 'Failed to validate audio file. Please try a different file.',
});
} else {
// Clear any existing errors for recorded files
@@ -220,7 +205,7 @@ export function ProfileForm() {
setAudioDuration(null);
form.clearErrors('sampleFile');
}
}, [selectedFile, form, t]);
}, [selectedFile, form]);
const {
isRecording,
@@ -241,8 +226,8 @@ export function ProfileForm() {
}
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: t('profileForm.toast.recordingComplete'),
description: t('profileForm.toast.recordingCompleteDescription'),
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
});
},
});
@@ -267,8 +252,8 @@ export function ProfileForm() {
}
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: t('profileForm.toast.systemAudioCaptured'),
description: t('profileForm.toast.systemAudioCapturedDescription'),
title: 'System audio captured',
description: 'Audio has been captured successfully.',
});
},
});
@@ -297,22 +282,23 @@ export function ProfileForm() {
useEffect(() => {
if (recordingError) {
toast({
title: t('profileForm.toast.recordingError'),
title: 'Recording error',
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast, t]);
}, [recordingError, toast]);
// Show system audio recording errors
useEffect(() => {
if (systemRecordingError) {
toast({
title: t('profileForm.toast.systemAudioError'),
title: 'System audio capture error',
description: systemRecordingError,
variant: 'destructive',
});
}
}, [systemRecordingError, toast, t]);
}, [systemRecordingError, toast]);
// Handle avatar preview
useEffect(() => {
@@ -334,7 +320,6 @@ export function ProfileForm() {
name: editingProfile.name,
description: editingProfile.description || '',
language: editingProfile.language as LanguageCode,
personality: editingProfile.personality || '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -348,7 +333,6 @@ export function ProfileForm() {
name: profileFormDraft.name,
description: profileFormDraft.description,
language: profileFormDraft.language as LanguageCode,
personality: profileFormDraft.personality || '',
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
avatarFile: undefined,
@@ -373,7 +357,6 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -405,8 +388,8 @@ export function ProfileForm() {
const file = form.getValues('sampleFile');
if (!file) {
toast({
title: t('profileForm.toast.noFile'),
description: t('profileForm.toast.noFileDescription'),
title: 'No file selected',
description: 'Please select an audio file first.',
variant: 'destructive',
});
return;
@@ -419,9 +402,8 @@ export function ProfileForm() {
form.setValue('referenceText', result.text, { shouldValidate: true });
} catch (error) {
toast({
title: t('profileForm.toast.transcribeFailed'),
description:
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
title: 'Transcription failed',
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
variant: 'destructive',
});
}
@@ -447,16 +429,16 @@ export function ProfileForm() {
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: t('profileForm.toast.invalidFile'),
description: t('profileForm.toast.invalidImageFormat'),
title: 'Invalid file type',
description: 'Please select an image file (PNG, JPG, or WebP)',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
title: 'File too large',
description: 'Image must be less than 5MB',
variant: 'destructive',
});
return;
@@ -470,13 +452,13 @@ export function ProfileForm() {
try {
await deleteAvatar.mutateAsync(editingProfileId);
toast({
title: t('profileForm.toast.avatarRemoved'),
description: t('profileForm.toast.avatarRemovedDescription'),
title: 'Avatar removed',
description: 'Avatar image has been removed successfully.',
});
} catch (error) {
toast({
title: t('profileForm.toast.avatarRemoveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
title: 'Failed to remove avatar',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
@@ -499,7 +481,6 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
},
});
@@ -512,11 +493,9 @@ export function ProfileForm() {
});
} catch (avatarError) {
toast({
title: t('profileForm.toast.avatarUploadFailed'),
title: 'Avatar upload failed',
description:
avatarError instanceof Error
? avatarError.message
: t('profileForm.toast.avatarUploadFailedFallback'),
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
variant: 'destructive',
});
}
@@ -531,11 +510,9 @@ export function ProfileForm() {
);
} catch (fxError) {
toast({
title: t('profileForm.toast.effectsUpdateFailed'),
title: 'Effects update failed',
description:
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
variant: 'destructive',
});
return;
@@ -543,15 +520,15 @@ export function ProfileForm() {
}
toast({
title: t('profileForm.toast.voiceUpdated'),
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
});
} else if (voiceSource === 'builtin') {
// Creating preset profile from built-in voice
if (!selectedPresetVoiceId) {
toast({
title: t('profileForm.toast.noVoiceSelected'),
description: t('profileForm.toast.noVoiceSelectedDescription'),
title: 'No voice selected',
description: 'Please select a built-in voice.',
variant: 'destructive',
});
return;
@@ -565,7 +542,6 @@ export function ProfileForm() {
preset_engine: selectedPresetEngine,
preset_voice_id: selectedPresetVoiceId,
default_engine: selectedPresetEngine,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Handle avatar upload if provided
@@ -577,19 +553,17 @@ export function ProfileForm() {
});
} catch (avatarError) {
toast({
title: t('profileForm.toast.avatarUploadFailed'),
title: 'Avatar upload failed',
description:
avatarError instanceof Error
? avatarError.message
: t('profileForm.toast.avatarUploadFailedFallback'),
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
variant: 'destructive',
});
}
}
toast({
title: t('profileForm.toast.profileCreated'),
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
title: 'Profile created',
description: `"${data.name}" has been created with a built-in voice.`,
});
} else {
// Creating cloned profile: require sample file and reference text
@@ -599,11 +573,11 @@ export function ProfileForm() {
if (!sampleFile) {
form.setError('sampleFile', {
type: 'manual',
message: t('profileForm.validation.sampleRequired'),
message: 'Audio sample is required',
});
toast({
title: t('profileForm.toast.sampleRequired'),
description: t('profileForm.toast.sampleRequiredDescription'),
title: 'Audio sample required',
description: 'Please provide an audio sample to create the voice profile.',
variant: 'destructive',
});
return;
@@ -612,48 +586,42 @@ export function ProfileForm() {
if (!referenceText || referenceText.trim().length === 0) {
form.setError('referenceText', {
type: 'manual',
message: t('profileForm.validation.referenceTextRequired'),
message: 'Reference text is required',
});
toast({
title: t('profileForm.toast.referenceTextRequired'),
description: t('profileForm.toast.referenceTextRequiredDescription'),
title: 'Reference text required',
description: 'Please provide the reference text for the audio sample.',
variant: 'destructive',
});
return;
}
// Validate audio duration before creating profile
try {
const duration = await getAudioDuration(sampleFile);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: t('profileForm.validation.audioTooLong', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
toast({
title: t('profileForm.toast.invalidAudio'),
description: t('profileForm.toast.invalidAudioDescription', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
title: 'Invalid audio file',
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
variant: 'destructive',
});
return;
return; // Prevent form submission
}
} catch (error) {
form.setError('sampleFile', {
type: 'manual',
message: t('profileForm.validation.audioFailed'),
message: 'Failed to validate audio file. Please try a different file.',
});
toast({
title: t('profileForm.toast.validationError'),
description:
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
title: 'Validation error',
description: error instanceof Error ? error.message : 'Failed to validate audio file',
variant: 'destructive',
});
return;
return; // Prevent form submission
}
// Creating: create profile, then add sample
@@ -662,7 +630,6 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
@@ -703,8 +670,8 @@ export function ProfileForm() {
}
toast({
title: t('profileForm.toast.profileCreated'),
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
});
} catch (sampleError) {
let rollbackSucceeded = false;
@@ -713,26 +680,23 @@ export function ProfileForm() {
rollbackSucceeded = true;
} catch (rollbackError) {
toast({
title: t('profileForm.toast.rollbackFailed'),
title: 'Rollback failed',
description:
rollbackError instanceof Error
? rollbackError.message
: t('profileForm.toast.rollbackFailedDescription'),
: 'Created profile could not be removed after sample upload failure.',
variant: 'destructive',
});
}
const rollbackSuffix = rollbackSucceeded
? ` ${t('profileForm.toast.profileRolledBack')}`
: '';
toast({
title: t('profileForm.toast.sampleFailed'),
title: 'Failed to add sample',
description:
sampleError instanceof Error
? `${sampleError.message}${rollbackSuffix}`
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
: rollbackSucceeded
? t('profileForm.toast.sampleFailedRolledBack')
: t('profileForm.toast.sampleFailedDescription'),
? 'Failed to add sample. The profile was rolled back.'
: 'Failed to add sample.',
variant: 'destructive',
});
return;
@@ -746,8 +710,8 @@ export function ProfileForm() {
setOpen(false);
} catch (error) {
toast({
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
@@ -765,7 +729,6 @@ export function ProfileForm() {
name: values.name || '',
description: values.description || '',
language: values.language || 'en',
personality: values.personality || '',
referenceText: values.referenceText || '',
sampleMode,
};
@@ -805,18 +768,16 @@ export function ProfileForm() {
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="text-2xl">
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
</DialogTitle>
<DialogDescription>
{editingProfileId
? t('profileForm.editDescription')
: t('profileForm.createDescription')}
? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile from an audio sample or a built-in voice.'}
</DialogDescription>
{isCreating && profileFormDraft && (
<div className="flex items-center gap-2 pt-2">
<span className="text-xs text-muted-foreground">
{t('profileForm.draftRestored')}
</span>
<span className="text-xs text-muted-foreground">Draft restored</span>
<Button
type="button"
variant="ghost"
@@ -828,16 +789,14 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
});
setSampleMode('record');
}}
>
<X className="h-3 w-3 mr-1" />
{t('profileForm.discard')}
Discard
</Button>
</div>
)}
@@ -863,7 +822,7 @@ export function ProfileForm() {
}`}
>
<Mic className="h-3.5 w-3.5" />
{t('profileForm.source.clone')}
Clone from audio
</button>
<button
type="button"
@@ -875,17 +834,20 @@ export function ProfileForm() {
}`}
>
<Music className="h-3.5 w-3.5" />
{t('profileForm.source.builtin')}
Built-in voice
</button>
</div>
</div>
{voiceSource === 'builtin' ? (
<div className="space-y-4">
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
<FormDescription>
Choose a pre-built voice. These don't require an audio sample.
</FormDescription>
{/* Engine selector */}
<FormItem>
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
<FormLabel>Engine</FormLabel>
<Select
value={selectedPresetEngine}
onValueChange={setSelectedPresetEngine}
@@ -904,7 +866,7 @@ export function ProfileForm() {
{/* Voice picker */}
<FormItem>
<FormLabel>{t('profileForm.fields.voice')}</FormLabel>
<FormLabel>Voice</FormLabel>
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
{presetVoices.map((voice: PresetVoice) => (
<button
@@ -959,16 +921,16 @@ export function ProfileForm() {
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
{t('profileForm.sampleTabs.upload')}
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
{t('profileForm.sampleTabs.record')}
Record
</TabsTrigger>
{platform.metadata.isTauri && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4 shrink-0" />
{t('profileForm.sampleTabs.system')}
System Audio
</TabsTrigger>
)}
</TabsList>
@@ -1046,10 +1008,10 @@ export function ProfileForm() {
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
@@ -1069,7 +1031,7 @@ export function ProfileForm() {
<div className="space-y-4 pt-4">
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="text-sm font-medium text-muted-foreground">
{t('profileForm.builtin.badge')}
Built-in Voice
</div>
<div className="flex items-center gap-3">
<div className="text-lg font-semibold">
@@ -1098,7 +1060,8 @@ export function ProfileForm() {
})()}
</div>
<p className="text-xs text-muted-foreground">
{t('profileForm.builtin.note')}
This profile uses a built-in voice. The voice cannot be changed after
creation.
</p>
</div>
) : (
@@ -1124,7 +1087,7 @@ export function ProfileForm() {
{avatarPreview ? (
<img
src={avatarPreview}
alt={t('profileForm.avatar.alt')}
alt="Avatar preview"
className="h-full w-full object-cover"
/>
) : (
@@ -1168,9 +1131,9 @@ export function ProfileForm() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1182,45 +1145,21 @@ export function ProfileForm() {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
{...field}
/>
<Textarea placeholder="Describe this voice..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="personality"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.personalityLabel')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.personalityPlaceholder')}
className="min-h-[96px]"
{...field}
/>
</FormControl>
<FormDescription>
{t('profileForm.fields.personalityHint')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
@@ -1241,7 +1180,7 @@ export function ProfileForm() {
/>
<FormItem>
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
<FormLabel>Default Engine</FormLabel>
<Select
value={defaultEngine || '_none'}
onValueChange={(v) => {
@@ -1253,13 +1192,11 @@ export function ProfileForm() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
<SelectValue placeholder="No preference" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="_none">
{t('profileForm.fields.noPreference')}
</SelectItem>
<SelectItem value="_none">No preference</SelectItem>
{availableDefaultEngines.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@@ -1268,15 +1205,15 @@ export function ProfileForm() {
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('profileForm.fields.defaultEngineHint')}
Auto-selects this engine when the profile is chosen.
</p>
</FormItem>
{editingProfileId && (
<div className="space-y-2">
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
{t('profileForm.fields.defaultEffectsHint')}
Effects applied automatically to all new generations with this voice.
</p>
<EffectsChainEditor
value={profileEffectsChain}
@@ -1293,7 +1230,7 @@ export function ProfileForm() {
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.cancel')}
Cancel
</Button>
<Button
type="submit"
@@ -1302,10 +1239,10 @@ export function ProfileForm() {
}
>
{createProfile.isPending || updateProfile.isPending || addSample.isPending
? t('profileForm.actions.saving')
? 'Saving...'
: editingProfileId
? t('profileForm.actions.saveChanges')
: t('profileForm.actions.createProfile')}
? 'Save Changes'
: 'Create Profile'}
</Button>
</div>
</form>
@@ -1,6 +1,5 @@
import { Info, Mic, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -12,7 +11,6 @@ import { ProfileForm } from './ProfileForm';
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
export function ProfileList() {
const { t } = useTranslation();
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedEngine = useUIStore((state) => state.selectedEngine);
@@ -47,9 +45,7 @@ export function ProfileList() {
if (error) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-destructive">
{t('profiles.list.errorLoading', { message: error.message })}
</div>
<div className="text-destructive">Error loading profiles: {error.message}</div>
</div>
);
}
@@ -77,10 +73,12 @@ export function ProfileList() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
<p className="text-muted-foreground mb-4">
No voice profiles yet. Create your first profile to get started.
</p>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
{t('profiles.list.createVoice')}
Create Voice
</Button>
</CardContent>
</Card>
@@ -101,7 +99,7 @@ export function ProfileList() {
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>{t('profiles.list.unsupportedNote')}</span>
<span>Only supported voice profiles can be selected for the current model.</span>
</div>
)}
</div>
+34 -33
View File
@@ -1,6 +1,5 @@
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button';
import {
@@ -25,7 +24,6 @@ interface MiniSamplePlayerProps {
}
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@@ -104,7 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handlePlayPause}
disabled={isLoading}
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
</Button>
@@ -116,11 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
max={100}
step={0.1}
className="flex-1"
aria-label={t('sampleList.player.position')}
aria-valuetext={t('sampleList.player.positionValue', {
current: formatAudioDuration(currentTime),
total: formatAudioDuration(duration),
})}
aria-label="Sample playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
@@ -135,8 +130,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleStop}
title={t('sampleList.player.stop')}
aria-label={t('sampleList.player.stopAria')}
title="Stop"
aria-label="Stop playback"
>
<X className="h-3.5 w-3.5" />
</Button>
@@ -150,7 +145,6 @@ interface SampleListProps {
}
export function SampleList({ profileId }: SampleListProps) {
const { t } = useTranslation();
const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample();
const updateSample = useUpdateSample();
@@ -187,8 +181,8 @@ export function SampleList({ profileId }: SampleListProps) {
const handleSaveEdit = async (sampleId: string) => {
if (!editedText.trim()) {
toast({
title: t('sampleList.toast.invalidText'),
description: t('sampleList.toast.invalidTextDescription'),
title: 'Invalid text',
description: 'Reference text cannot be empty.',
variant: 'destructive',
});
return;
@@ -197,23 +191,22 @@ export function SampleList({ profileId }: SampleListProps) {
try {
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
toast({
title: t('sampleList.toast.updated'),
description: t('sampleList.toast.updatedDescription'),
title: 'Sample updated',
description: 'Reference text has been updated successfully.',
});
setEditingSampleId(null);
setEditedText('');
} catch (error) {
toast({
title: t('sampleList.toast.updateFailed'),
description:
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
title: 'Update failed',
description: error instanceof Error ? error.message : 'Failed to update sample',
variant: 'destructive',
});
}
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
}
return (
@@ -221,8 +214,10 @@ export function SampleList({ profileId }: SampleListProps) {
{samples && samples.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
<p className="text-sm text-muted-foreground">No samples yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Add your first audio sample to get started
</p>
</div>
) : (
<div className="space-y-2">
@@ -242,13 +237,13 @@ export function SampleList({ profileId }: SampleListProps) {
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<Edit className="h-3 w-3" />
<span>{t('sampleList.editing')}</span>
<span>Editing transcription</span>
</div>
<Textarea
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
className="min-h-[100px] text-sm resize-none"
placeholder={t('sampleList.placeholder')}
placeholder="Enter reference text..."
autoFocus
/>
<div className="flex items-center justify-end gap-2 pt-1">
@@ -260,7 +255,7 @@ export function SampleList({ profileId }: SampleListProps) {
disabled={updateSample.isPending}
>
<X className="h-4 w-4 mr-1" />
{t('common.cancel')}
Cancel
</Button>
<Button
type="button"
@@ -269,7 +264,7 @@ export function SampleList({ profileId }: SampleListProps) {
disabled={updateSample.isPending}
>
<Check className="h-4 w-4 mr-1" />
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
{updateSample.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
@@ -288,12 +283,12 @@ export function SampleList({ profileId }: SampleListProps) {
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<CircleButton
icon={Edit}
title={t('sampleList.editTranscription')}
title="Edit transcription"
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
/>
<CircleButton
icon={Trash2}
title={t('sampleList.deleteSample')}
title="Delete sample"
onClick={() => handleDeleteClick(sample.id)}
disabled={deleteSample.isPending}
/>
@@ -322,18 +317,24 @@ export function SampleList({ profileId }: SampleListProps) {
onClick={() => setUploadOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t('sampleList.addSample')}
Add Sample
</Button>
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
<p className="text-xs text-muted-foreground text-center px-2">
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
samples. In a future update samples might be interchangeable and tagged for varying styles
of the same voice.
</p>
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
<DialogTitle>Delete Sample</DialogTitle>
<DialogDescription>
Are you sure you want to delete this audio sample? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
@@ -343,14 +344,14 @@ export function SampleList({ profileId }: SampleListProps) {
setSampleToDelete(null);
}}
>
{t('common.cancel')}
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteSample.isPending}
>
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
+32 -51
View File
@@ -2,7 +2,6 @@ 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 { useTranslation } from 'react-i18next';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
@@ -39,26 +38,19 @@ import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
function makeProfileSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
}
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 = {
name: string;
description?: string;
language: LanguageCode;
};
type ProfileFormValues = z.infer<typeof profileSchema>;
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { t } = useTranslation();
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
@@ -76,7 +68,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(makeProfileSchema(t)),
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
@@ -112,31 +104,32 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: t('profileForm.toast.invalidFile'),
description: t('voiceInspector.toast.invalidImageFormat'),
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
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: t('voiceInspector.toast.avatarUpdated') });
toast({ title: 'Avatar updated' });
},
onError: (err) => {
toast({
title: t('profileForm.toast.avatarUploadFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
},
@@ -148,11 +141,11 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: t('profileForm.toast.avatarRemoved') });
toast({ title: 'Avatar removed' });
} catch (err) {
toast({
title: t('profileForm.toast.avatarRemoveFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
}
@@ -181,25 +174,19 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
setEffectsDirty(false);
} catch (fxError) {
toast({
title: t('profileForm.toast.effectsUpdateFailed'),
description:
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
variant: 'destructive',
});
return;
}
}
toast({
title: t('profileForm.toast.voiceUpdated'),
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
});
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
} catch (error) {
toast({
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
@@ -208,7 +195,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t('voiceInspector.loading')}
Loading...
</div>
);
}
@@ -269,9 +256,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -283,13 +270,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
rows={2}
{...field}
/>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -301,7 +284,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
@@ -323,9 +306,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Effects */}
<div className="space-y-2">
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
{t('voiceInspector.defaultEffectsHint')}
Applied automatically to new generations with this voice.
</p>
<EffectsChainEditor
value={effectsChain}
@@ -340,9 +323,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending
? t('profileForm.actions.saving')
: t('profileForm.actions.saveChanges')}
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</Button>
)}
</div>
+13 -16
View File
@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,7 +25,6 @@ import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() {
const { t } = useTranslation();
const { data: profiles, isLoading } = useProfiles();
const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
@@ -97,7 +95,7 @@ export function VoicesTab() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
<div className="text-muted-foreground">Loading voices...</div>
</div>
);
}
@@ -112,12 +110,12 @@ export function VoicesTab() {
{/* 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">{t('voicesTab.title')}</h1>
<h1 className="text-2xl font-bold">Voices</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t('voicesTab.searchPlaceholder')}
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"
@@ -125,7 +123,7 @@ export function VoicesTab() {
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('voicesTab.newVoice')}
New Voice
</Button>
</div>
</div>
@@ -141,12 +139,12 @@ export function VoicesTab() {
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">{t('voicesTab.columns.name')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
<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>
@@ -196,7 +194,6 @@ function VoiceRow({
channels,
onChannelChange,
}: VoiceRowProps) {
const { t } = useTranslation();
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
@@ -215,7 +212,7 @@ function VoiceRow({
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={t('voicesTab.avatarAlt', { name: profile.name })}
alt={`${profile.name} avatar`}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
@@ -251,11 +248,11 @@ function VoiceRow({
<MultiSelect
options={channels.map((ch) => ({
value: ch.id,
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
}))}
value={channelIds}
onChange={onChannelChange}
placeholder={t('voicesTab.selectChannels')}
placeholder="Select channels..."
className="w-full"
/>
</TableCell>
+1 -1
View File
@@ -9,7 +9,7 @@ const badgeVariants = cva(
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
+3 -7
View File
@@ -3,18 +3,14 @@ import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const buttonVariants = cva([
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
],
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-accent underline-offset-4 hover:underline',
-22
View File
@@ -1,22 +0,0 @@
import { useEffect } from 'react';
import { useUIStore } from '@/stores/uiStore';
export function useThemeSync() {
const theme = useUIStore((s) => s.theme);
useEffect(() => {
if (theme !== 'system') {
document.documentElement.classList.toggle('dark', theme === 'dark');
return;
}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
document.documentElement.classList.toggle('dark', mq.matches);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, [theme]);
}
-55
View File
@@ -1,55 +0,0 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import es from './locales/es/translation.json';
import fr from './locales/fr/translation.json';
import it from './locales/it/translation.json';
import ja from './locales/ja/translation.json';
import ko from './locales/ko/translation.json';
import ptBR from './locales/pt-BR/translation.json';
import zhCN from './locales/zh-CN/translation.json';
import zhTW from './locales/zh-TW/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'pt-BR', label: 'Português (Brasil)' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
es: { translation: es },
'pt-BR': { translation: ptBR },
ja: { translation: ja },
ko: { translation: ko },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
fr: { translation: fr },
it: { translation: it },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
load: 'currentOnly',
interpolation: { escapeValue: false },
react: { useSuspense: false },
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'voicebox:lang',
caches: ['localStorage'],
},
});
export default i18n;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -20
View File
@@ -44,24 +44,24 @@
:root {
--background: 0 0% 95%;
--foreground: 0 0% 5%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 97%;
--card-foreground: 0 0% 5%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 97%;
--popover-foreground: 0 0% 5%;
--primary: 43 55% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 92%;
--secondary-foreground: 0 0% 11%;
--muted: 0 0% 90%;
--muted-foreground: 0 0% 47%;
--accent: 43 55% 58%;
--accent-foreground: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 92%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 90%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 43 50% 50%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 85%;
--input: 0 0% 88%;
--ring: 0 0% 5%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 85%;
--input: 214.3 31.8% 88%;
--ring: 222.2 84% 4.9%;
--sidebar: 0 0% 92%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
@@ -157,11 +157,6 @@
opacity: 0;
}
.dark .sidebar-logo {
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));
}
/* react-loaders */
.line-scale-pulse-out-rapid > div,
.line-scale > div {
-203
View File
@@ -18,9 +18,7 @@ import type {
ModelDownloadRequest,
ModelStatusListResponse,
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
RocmStatus,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
@@ -31,28 +29,11 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
VoiceProfileResponse,
WhisperModelSize,
CaptureListResponse,
CaptureResponse,
CaptureCreateResponse,
CaptureReadinessResponse,
CaptureRefineRequest,
CaptureRetranscribeRequest,
CaptureSettings,
CaptureSettingsUpdate,
CaptureSource,
GenerationSettings,
GenerationSettingsUpdate,
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
CloudLoginStartResponse,
CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -134,17 +115,6 @@ class ApiClient {
});
}
// ── Personality-driven text generation ─────────────────────────────
// Compose produces a fresh in-character utterance the UI drops into
// the generate textarea. Rewrite now happens server-side inside
// `/generate` when `personality: true` is passed in the request body.
async composeWithPersonality(profileId: string): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
method: 'POST',
});
}
async addProfileSample(
profileId: string,
file: File,
@@ -276,20 +246,6 @@ class ApiClient {
});
}
async importAudio(file: File): Promise<GenerationResponse> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${this.getBaseUrl()}/generate/import`, {
method: 'POST',
body: form,
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
return res.json();
}
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
method: 'POST',
@@ -425,122 +381,6 @@ class ApiClient {
return response.json();
}
// Captures
async listCaptures(limit = 50, offset = 0): Promise<CaptureListResponse> {
return this.request<CaptureListResponse>(
`/captures?limit=${limit}&offset=${offset}`,
);
}
async getCapture(captureId: string): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}`);
}
async createCapture(
file: File,
options?: {
source?: CaptureSource;
language?: LanguageCode;
sttModel?: WhisperModelSize;
},
): Promise<CaptureCreateResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('source', options?.source ?? 'file');
if (options?.language) formData.append('language', options.language);
if (options?.sttModel) formData.append('stt_model', options.sttModel);
const url = `${this.getBaseUrl()}/captures`;
const response = await fetch(url, { method: 'POST', body: formData });
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
}
return response.json();
}
async deleteCapture(captureId: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/captures/${captureId}`, {
method: 'DELETE',
});
}
async refineCapture(
captureId: string,
body: CaptureRefineRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/refine`, {
method: 'POST',
body: JSON.stringify(body),
});
}
async retranscribeCapture(
captureId: string,
body: CaptureRetranscribeRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/retranscribe`, {
method: 'POST',
body: JSON.stringify(body),
});
}
getCaptureAudioUrl(captureId: string): string {
return `${this.getBaseUrl()}/captures/${captureId}/audio`;
}
// Settings
async getCaptureSettings(): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures');
}
async getCaptureReadiness(): Promise<CaptureReadinessResponse> {
return this.request<CaptureReadinessResponse>('/capture/readiness');
}
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
async getGenerationSettings(): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation');
}
async updateGenerationSettings(
patch: GenerationSettingsUpdate,
): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
// MCP bindings — per-MCP-client voice/engine/personality mapping.
async listMCPBindings(): Promise<MCPClientBindingListResponse> {
return this.request<MCPClientBindingListResponse>('/mcp/bindings');
}
async upsertMCPBinding(
data: MCPClientBindingUpsert,
): Promise<MCPClientBinding> {
return this.request<MCPClientBinding>('/mcp/bindings', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteMCPBinding(clientId: string): Promise<{ deleted: string }> {
return this.request<{ deleted: string }>(
`/mcp/bindings/${encodeURIComponent(clientId)}`,
{ method: 'DELETE' },
);
}
// Model Management
async getModelStatus(): Promise<ModelStatusListResponse> {
return this.request<ModelStatusListResponse>('/models/status');
@@ -696,23 +536,6 @@ class ApiClient {
});
}
// ROCm Backend Management
async getRocmStatus(): Promise<RocmStatus> {
return this.request<RocmStatus>('/backend/rocm-status');
}
async downloadRocmBackend(): Promise<{ message: string; progress_key: string }> {
return this.request<{ message: string; progress_key: string }>('/backend/download-rocm', {
method: 'POST',
});
}
async deleteRocmBackend(): Promise<{ message: string }> {
return this.request<{ message: string }>('/backend/rocm', {
method: 'DELETE',
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
@@ -791,17 +614,6 @@ class ApiClient {
});
}
async updateStoryItemVolume(
storyId: string,
itemId: string,
data: StoryItemVolumeUpdate,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/volume`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(
storyId: string,
itemId: string,
@@ -940,21 +752,6 @@ class ApiClient {
return response.blob();
}
// Cloud (backup & sync) — browser-based device login. startCloudLogin opens
// the system browser server-side; the UI then polls getCloudStatus until the
// backend completes the exchange and the link goes live.
async getCloudStatus(): Promise<CloudStatus> {
return this.request<CloudStatus>('/cloud/status');
}
async startCloudLogin(): Promise<CloudLoginStartResponse> {
return this.request<CloudLoginStartResponse>('/cloud/login/start', { method: 'POST' });
}
async disconnectCloud(): Promise<CloudStatus> {
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
}
}
export const apiClient = new ApiClient();
+1 -1
View File
@@ -9,7 +9,7 @@ export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // True if download is in progress
downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
+2 -199
View File
@@ -12,8 +12,6 @@ export interface VoiceProfileCreate {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
/** Free-form character prompt used by compose and the `/generate` personality-rewrite path. */
personality?: string;
}
export interface VoiceProfileResponse {
@@ -28,19 +26,12 @@ export interface VoiceProfileResponse {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
personality?: string | null;
generation_count: number;
sample_count: number;
created_at: string;
updated_at: string;
}
/** Response returned by /profiles/{id}/compose. */
export interface PersonalityTextResponse {
text: string;
model_size: string;
}
export interface PresetVoice {
voice_id: string;
name: string;
@@ -80,8 +71,6 @@ export interface GenerationRequest {
| 'tada'
| 'kokoro';
instruct?: string;
/** When true and the profile has a personality prompt, input text is rewritten in-character before TTS. */
personality?: boolean;
max_chunk_chars?: number;
crossfade_ms?: number;
normalize?: boolean;
@@ -138,122 +127,6 @@ export interface HistoryListResponse {
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
export type Qwen3ModelSize = '0.6B' | '1.7B' | '4B';
export type CaptureSource = 'dictation' | 'recording' | 'file';
/**
* Snapshot of the accessibility-focused UI element at chord-start. Emitted
* from Rust as part of the ``dictate:start`` payload so the frontend can
* pass it back to ``paste_final_text`` once the final text is ready.
*/
export interface FocusSnapshot {
pid: number;
bundle_id: string | null;
role: string | null;
}
export interface RefinementFlags {
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
}
export interface CaptureResponse {
id: string;
audio_path: string;
source: CaptureSource;
language?: string | null;
duration_ms?: number | null;
transcript_raw: string;
transcript_refined?: string | null;
stt_model?: string | null;
llm_model?: string | null;
refinement_flags?: RefinementFlags | null;
created_at: string;
}
export interface CaptureListResponse {
items: CaptureResponse[];
total: number;
}
/**
* Response of ``POST /captures``. Adds ``auto_refine`` and ``allow_auto_paste``
* — the server's current settings captured at request time — so the client
* can decide whether to chain a refine call and whether to fire the
* synthetic-paste pipeline without relying on its own (possibly stale) copy
* of capture_settings.
*/
export interface CaptureCreateResponse extends CaptureResponse {
auto_refine: boolean;
allow_auto_paste: boolean;
}
export interface CaptureRefineRequest {
flags?: RefinementFlags;
model_size?: Qwen3ModelSize;
}
export interface CaptureRetranscribeRequest {
model?: WhisperModelSize;
language?: LanguageCode;
}
export interface CaptureSettings {
stt_model: WhisperModelSize;
language: string;
auto_refine: boolean;
llm_model: Qwen3ModelSize;
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
allow_auto_paste: boolean;
default_playback_voice_id: string | null;
/** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean;
/** Hold the mic open while dictation is enabled so push-to-talk doesn't clip
* the first words. Off by default — when on, the OS mic indicator stays lit
* the whole time dictation is enabled. */
keep_mic_warm: boolean;
/** keytap key names. Defaults are platform-specific right-hand modifiers. */
chord_push_to_talk_keys: string[];
/** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
chord_toggle_to_talk_keys: string[];
}
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
/**
* One row in the dictation readiness checklist. ``model_name`` is the
* canonical id understood by ``POST /models/download`` so the UI can wire a
* one-click "Download" button without a second lookup.
*/
export interface ModelReadiness {
ready: boolean;
model_name: string;
display_name: string;
size: string;
size_mb?: number | null;
}
/** Backend half of the dictation readiness check. The frontend combines this
* with TCC permission state into the full checklist used by useDictationReadiness. */
export interface CaptureReadinessResponse {
stt: ModelReadiness;
llm: ModelReadiness;
}
export interface GenerationSettings {
max_chunk_chars: number;
crossfade_ms: number;
normalize_audio: boolean;
autoplay_on_generate: boolean;
}
export type GenerationSettingsUpdate = Partial<GenerationSettings>;
export interface TranscriptionRequest {
language?: LanguageCode;
model?: WhisperModelSize;
@@ -273,8 +146,7 @@ export interface HealthResponse {
gpu_type?: string;
vram_used_mb?: number;
backend_type?: string;
backend_variant?: string; // "cpu", "cuda", or "rocm"
supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable
backend_variant?: string; // "cpu" or "cuda"
}
export interface CudaDownloadProgress {
@@ -291,34 +163,11 @@ export interface CudaDownloadProgress {
export interface CudaStatus {
available: boolean; // CUDA binary exists on disk
active: boolean; // Currently running the CUDA binary
binary_path: string | null;
cuda_libs_version: string | null;
download_supported: boolean; // Platform has a matching release asset
unsupported_reason: string | null;
binary_path?: string;
downloading: boolean; // Download in progress
download_progress?: CudaDownloadProgress;
}
export interface RocmDownloadProgress {
model_name: string;
current: number;
total: number;
progress: number;
filename?: string;
status: 'downloading' | 'extracting' | 'complete' | 'error';
timestamp: string;
error?: string;
}
export interface RocmStatus {
available: boolean; // ROCm binary exists on disk
active: boolean; // Currently running the ROCm binary
binary_path?: string;
rocm_libs_version?: string;
downloading: boolean; // Download in progress
download_progress?: RocmDownloadProgress;
}
export interface ModelProgress {
model_name: string;
current: number;
@@ -419,17 +268,11 @@ export interface StoryItemDetail {
duration: number;
seed?: number;
instruct?: string;
engine?: string;
volume: number;
generation_created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface StoryItemVolumeUpdate {
volume: number;
}
export interface StoryItemVersionUpdate {
version_id: string | null;
}
@@ -524,43 +367,3 @@ export interface ApplyEffectsRequest {
label?: string;
set_as_default?: boolean;
}
/* ─── MCP ─────────────────────────────────────────────────────────────── */
export interface MCPClientBinding {
client_id: string;
label: string | null;
profile_id: string | null;
default_engine: string | null;
default_personality: boolean;
last_seen_at: string | null;
created_at: string;
updated_at: string;
}
export interface MCPClientBindingUpsert {
client_id: string;
label?: string | null;
profile_id?: string | null;
default_engine?: string | null;
default_personality?: boolean;
}
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
export interface CloudLoginStartResponse {
authorize_url: string;
}
export interface CloudStatus {
connected: boolean;
device_name: string | null;
account_user_id: string | null;
key_prefix: string | null;
connected_at: string | null;
dashboard_url: string;
}
+134 -372
View File
@@ -4,45 +4,12 @@ import { convertToWav } from '@/lib/utils/audio';
interface UseAudioRecordingOptions {
maxDurationSeconds?: number;
// ``context`` is whatever was handed to ``startRecording`` for this take,
// threaded back untouched so callers can correlate the result with the
// recording it came from (the dictate window pairs it with the focus
// snapshot captured at chord-start).
onRecordingComplete?: (blob: Blob, duration?: number, context?: unknown) => void;
/**
* Keep the microphone ``MediaStream`` open between recordings instead of
* tearing it down on every stop. This is what removes the "first words get
* clipped" problem on push-to-talk dictation: ``getUserMedia`` on macOS can
* take several hundred ms — up to a second cold — to hand back a stream, and
* ``MediaRecorder`` only starts capturing *after* it resolves, so everything
* spoken in that window is lost. With a warm stream already open, the next
* ``startRecording`` skips ``getUserMedia`` entirely.
*
* Off by default: the voice-clone sample recorders release the device
* immediately, and the dictation session only opts in when the user enables
* the "keep microphone ready" setting. While on, the warm stream stays open —
* and the OS mic-in-use indicator stays lit — until it's explicitly released
* (dictation disabled or the setting turned off), so the trade-off is visible
* and user-controlled rather than a background mic that's always warm.
*/
keepWarm?: boolean;
onRecordingComplete?: (blob: Blob, duration?: number) => void;
}
// Audio constraints for capture. Kept identical to the previous inline value so
// this change is purely about *when* the stream is opened, not *how*.
const AUDIO_CONSTRAINTS: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
const streamHasLiveAudio = (stream: MediaStream | null): stream is MediaStream =>
!!stream && stream.getAudioTracks().some((t) => t.readyState === 'live');
export function useAudioRecording({
maxDurationSeconds,
maxDurationSeconds = 29,
onRecordingComplete,
keepWarm = false,
}: UseAudioRecordingOptions = {}) {
const platform = usePlatform();
const [isRecording, setIsRecording] = useState(false);
@@ -50,392 +17,189 @@ export function useAudioRecording({
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// The stream currently backing the MediaRecorder. When ``keepWarm`` is set
// this is the same object as ``warmStreamRef`` and is *not* torn down on
// stop; otherwise it's stopped as soon as the recording completes.
const streamRef = useRef<MediaStream | null>(null);
// Persistent pre-opened stream reused across recordings when ``keepWarm``.
const warmStreamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false);
// Mirror of ``isRecording`` for reads inside callbacks that would otherwise
// close over a stale render.
const isRecordingRef = useRef(false);
// A ``getUserMedia`` call in flight, shared so concurrent acquirers (prewarm
// plus an immediate chord) coalesce onto one stream instead of each opening —
// and orphaning — their own.
const acquiringRef = useRef<Promise<MediaStream> | null>(null);
// True from ``startRecording`` entry until the recorder is actually running
// (or has failed), so a stop that arrives mid-acquisition can be deferred.
const startingRef = useRef(false);
// True from MediaRecorder.stop() until onstop has snapshotted the take's
// shared refs. React state and MediaRecorder.state both flip before onstop,
// so without this gate a rapid next chord can clear chunks/duration/cancel
// state out from under the recorder that is still finalising.
const finishingRef = useRef(false);
const pendingStopRef = useRef(false);
// Bumped per recording so a stale recorder's ``onstop`` can tell it's no
// longer the active one before it touches the shared stream refs.
const recordingCounterRef = useRef(0);
// Bumped whenever the warm stream is released/aborted so a ``getUserMedia``
// still in flight can tell its result is stale and stop it instead of
// adopting a live mic after disable/unmount.
const acquireGenRef = useRef(0);
// Set when a release is requested mid-recording; the onstop path performs the
// deferred release once capture finishes rather than yanking the device now.
const releaseAfterStopRef = useRef(false);
// Keeps the ref in lockstep with the state so the synchronous stop path reads
// a fresh value without waiting for a rerender.
const setRecording = useCallback((next: boolean) => {
isRecordingRef.current = next;
setIsRecording(next);
}, []);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
const releaseWarmStream = useCallback(() => {
// Invalidate any getUserMedia still in flight so its stream is stopped on
// resolve rather than adopted as the warm stream.
acquireGenRef.current += 1;
// Don't tear the device out from under an active/starting recording — the
// warm stream is the one backing it; defer to the onstop path instead.
if (isRecordingRef.current || startingRef.current) {
releaseAfterStopRef.current = true;
return;
}
warmStreamRef.current?.getTracks().forEach((track) => {
track.stop();
});
warmStreamRef.current = null;
}, []);
// Assert that getUserMedia is reachable, mirroring the previous inline guard
// (Tauri webviews occasionally expose ``navigator.mediaDevices`` a beat late).
const assertMediaDevices = useCallback(async () => {
if (typeof navigator === 'undefined') {
throw new Error('Navigator API is not available. This might be a Tauri configuration issue.');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(
platform.metadata.isTauri
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.',
);
// Check if getUserMedia is available
// In Tauri, navigator.mediaDevices might not be available immediately
if (typeof navigator === 'undefined') {
const errorMsg =
'Navigator API is not available. This might be a Tauri configuration issue.';
setError(errorMsg);
throw new Error(errorMsg);
}
}
}, [platform.metadata.isTauri]);
// Return a live capture stream, reusing the warm one when available so the
// hot path (chord-down → record) never waits on getUserMedia.
const acquireStream = useCallback(async (): Promise<MediaStream> => {
// Captured separately so it stays typed as the full stream after the live
// check narrows ``warmStreamRef.current`` itself.
const existing = warmStreamRef.current;
if (streamHasLiveAudio(warmStreamRef.current)) {
return warmStreamRef.current;
}
// Coalesce concurrent acquirers onto one getUserMedia call so prewarm and
// an immediate chord can't open two streams.
if (acquiringRef.current) return acquiringRef.current;
// A dead warm stream (device unplugged / tracks ended) — drop it and reopen.
if (existing) {
existing.getTracks().forEach((track) => {
track.stop();
});
warmStreamRef.current = null;
}
const gen = acquireGenRef.current;
const acquisition = (async () => {
await assertMediaDevices();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Try waiting a bit for Tauri webview to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('MediaDevices check:', {
hasNavigator: typeof navigator !== 'undefined',
hasMediaDevices: !!navigator?.mediaDevices,
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
isTauri: platform.metadata.isTauri,
});
const errorMsg = platform.metadata.isTauri
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
setError(errorMsg);
throw new Error(errorMsg);
}
}
// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
audio: AUDIO_CONSTRAINTS,
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// Released / disabled / unmounted while acquiring — this stream is stale,
// so stop it instead of leaving a live mic open, and abort the caller.
if (gen !== acquireGenRef.current) {
stream.getTracks().forEach((track) => {
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
}
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Stop all tracks now that we have the data
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
throw new Error('microphone acquisition aborted');
}
if (keepWarm) warmStreamRef.current = stream;
return stream;
})();
acquiringRef.current = acquisition;
try {
return await acquisition;
} finally {
if (acquiringRef.current === acquisition) acquiringRef.current = null;
}
}, [assertMediaDevices, keepWarm]);
streamRef.current = null;
/**
* Open the microphone ahead of the first recording so the initial dictation
* doesn't clip. No-op unless ``keepWarm`` is set. Safe to call repeatedly and
* safe to fail (e.g. permission not yet granted) — ``startRecording`` still
* surfaces a real error if capture is genuinely unavailable.
*/
const prewarm = useCallback(async () => {
if (!keepWarm) return;
try {
await acquireStream();
} catch {
// Permission missing / device busy / aborted — recording will report a
// real error if capture is genuinely unavailable.
}
}, [keepWarm, acquireStream]);
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
const startRecording = useCallback(
async (context?: unknown) => {
// A second chord can arrive while the first one is still waiting on
// getUserMedia. Never create overlapping MediaRecorders on the same
// coalesced stream; the original take will honor any deferred stop.
if (
startingRef.current ||
finishingRef.current ||
mediaRecorderRef.current?.state === 'recording'
)
return;
startingRef.current = true;
pendingStopRef.current = false;
// A new recording supersedes any release deferred from a prior take.
releaseAfterStopRef.current = false;
const recordingId = ++recordingCounterRef.current;
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
// Reuse the warm stream when present (instant); otherwise open one now.
const stream = await acquireStream();
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration);
}
};
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setIsRecording(true);
startTimeRef.current = Date.now();
mediaRecorder.onstop = async () => {
// Whether this recorder is still the active one. A stale onstop (an
// older recorder stopping after a newer startRecording) must not touch
// the shared stream refs.
const isCurrent = recordingCounterRef.current === recordingId;
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Release the device unless we're keeping it warm for the next capture.
// Act on this recorder's own stream; only touch the shared refs when
// this is still the current recording.
if (keepWarm) {
if (isCurrent) {
streamRef.current = null;
// A release requested mid-recording (dictation disabled) is
// honored now that capture has finished; otherwise the warm
// stream stays open for the next take.
if (releaseAfterStopRef.current) {
releaseAfterStopRef.current = false;
releaseWarmStream();
}
}
} else {
stream.getTracks().forEach((track) => {
track.stop();
});
if (isCurrent) streamRef.current = null;
}
// All shared per-take refs have now been snapshotted and stream
// cleanup is complete. A new take may begin while WAV conversion and
// upload continue using the local values above.
finishingRef.current = false;
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration, context);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration, context);
}
};
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
// started with a timeslice, so concatenated blobs fail to parse in
// both AudioContext and ffmpeg. Starting with no timeslice produces
// exactly one dataavailable on stop() with a valid container.
mediaRecorder.start();
setRecording(true);
startTimeRef.current = Date.now();
startingRef.current = false;
// A stop (chord release) that landed while the mic was still opening —
// honor it now that capture has actually begun.
if (pendingStopRef.current) {
pendingStopRef.current = false;
finishingRef.current = true;
mediaRecorder.stop();
setRecording(false);
return;
}
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration when the caller opts in — dictation
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to
// keep reference clips short.
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
finishingRef.current = true;
mediaRecorderRef.current.stop();
setRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Auto-stop at max duration
if (elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
// A fresh (non-warm) stream opened before the failure must be released
// so the mic doesn't stay lit; a warm stream is reusable, so it's kept.
if (!keepWarm) {
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
}
startingRef.current = false;
finishingRef.current = false;
pendingStopRef.current = false;
setError(errorMessage);
setRecording(false);
}
},
[
maxDurationSeconds,
onRecordingComplete,
acquireStream,
keepWarm,
releaseWarmStream,
setRecording,
],
);
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
setError(errorMessage);
setIsRecording(false);
}
}, [maxDurationSeconds, onRecordingComplete]);
const stopRecording = useCallback(() => {
// The recorder's own state is the lifecycle authority — React ``isRecording``
// lags a render behind ``mediaRecorder.start()``, so a chord release in that
// window would otherwise be dropped.
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state === 'recording') {
finishingRef.current = true;
recorder.stop();
setRecording(false);
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
} else if (startingRef.current) {
// Stop arrived before capture began (mic still opening) — defer it so
// startRecording stops as soon as the recorder goes live.
pendingStopRef.current = true;
}
}, [setRecording]);
}, [isRecording]);
const cancelRecording = useCallback(() => {
cancelledRef.current = true; // Must be set before stop() triggers onstop
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
if (mediaRecorderRef.current) {
cancelledRef.current = true; // Must be set before stop() triggers onstop
chunksRef.current = [];
finishingRef.current = true;
recorder.stop();
setRecording(false);
mediaRecorderRef.current.stop();
setIsRecording(false);
setDuration(0);
} else if (startingRef.current) {
// Cancel during mic acquisition — stop as soon as capture begins; the
// cancelled flag suppresses the completion callback.
pendingStopRef.current = true;
}
// Keep the device warm for the next capture when opted in; otherwise stop
// the tracks so the mic is released immediately.
if (keepWarm) {
streamRef.current = null;
if (releaseAfterStopRef.current) {
releaseAfterStopRef.current = false;
releaseWarmStream();
}
} else {
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
}
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [keepWarm, releaseWarmStream, setRecording]);
}, []);
// Cleanup on unmount — always fully release the device, warm or not.
// Cleanup on unmount
useEffect(() => {
return () => {
// Invalidate any in-flight acquisition so a stream resolving after unmount
// stops itself instead of leaking a live mic.
acquireGenRef.current += 1;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
warmStreamRef.current?.getTracks().forEach((track) => {
track.stop();
});
};
}, []);
@@ -446,7 +210,5 @@ export function useAudioRecording({
startRecording,
stopRecording,
cancelRecording,
prewarm,
releaseWarm: releaseWarmStream,
};
}
@@ -1,365 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { emit as tauriEmit } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PillState } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
} from '@/lib/api/types';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
/**
* Broadcast to sibling Tauri webviews that the captures list has changed.
* The main CapturesTab listens, seeds its React Query cache, and focuses the
* new row, so uploads from the floating dictate window show up live.
*
* ``capture:created`` carries the full response so the sibling can seed its
* cache before the refetch lands — otherwise the selection-guard effect
* would snap back to ``captures[0]`` in the race window between
* ``setSelectedId(new)`` and the list actually containing the new row.
*
* No-op in web mode — there are no siblings to notify.
*/
function broadcastCreated(capture: CaptureResponse) {
tauriEmit('capture:created', { capture }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
function broadcastUpdated(id: string) {
tauriEmit('capture:updated', { id }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
const REST_FADE_MS = 900;
// How long the green "Done" pill stays visible after refine (or transcribe,
// when auto-refine is off) completes, before the fade-out begins.
const COMPLETED_DWELL_MS = 2000;
// Long enough to read a full backend stack message and click-to-copy.
const ERROR_PILL_VISIBLE_MS = 6000;
// Short self-explanatory notices (e.g. "Recording too short, canceled") —
// there's nothing to read or copy, so clear out quickly.
const BRIEF_NOTICE_MS = 2000;
// MediaRecorder.start(100) emits its first chunk ~100ms in, but the webm
// container header isn't guaranteed to be finalised that quickly — anything
// under half a second tends to produce a blob neither AudioContext.decode
// nor ffmpeg will accept. Caught client-side and surfaced as a friendly
// "Recording too short, canceled" pill instead of bubbling up a 400.
const MIN_RECORDING_DURATION_S = 0.5;
const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
export type CapturePillState = PillState | 'hidden';
export interface UseCaptureRecordingSessionOptions {
/** Keep the microphone stream open between dictations when explicitly
* enabled. Off by default so normal recorders release the device. */
keepMicWarm?: boolean;
/**
* Fired after a capture row is created on the server. Callers can use this
* to select the new capture or emit a Tauri event to a sibling window.
* ``context`` is whatever was passed to ``startRecording`` for this take.
*/
onCaptureCreated?: (capture: CaptureResponse, context?: unknown) => void;
/**
* Fired with the final delivered text — refined if ``auto_refine`` was on
* for this capture, raw transcript otherwise. Used by the floating
* dictate window to hand the text off to the Rust auto-paste pipeline.
*
* ``allowAutoPaste`` snapshots the setting at chord-start so a refine that
* lands after the user flips the toggle still uses the value the capture
* was created under. ``context`` is the value passed to ``startRecording``
* for this take, so overlapping dictations can't cross their targets.
*/
onFinalText?: (
text: string,
capture: CaptureResponse,
allowAutoPaste: boolean,
context?: unknown,
) => void;
}
export interface UseCaptureRecordingSessionResult {
pillState: CapturePillState;
pillElapsedMs: number;
errorMessage: string | null;
isRecording: boolean;
isUploading: boolean;
isRefining: boolean;
startRecording: (context?: unknown) => void;
stopRecording: () => void;
toggleRecording: () => void;
dismissError: () => void;
uploadFile: (file: File, source: CaptureSource) => void;
refine: (captureId: string) => void;
prewarm: () => Promise<void>;
releaseWarm: () => void;
}
/**
* Owns the full record → transcribe → refine → rest lifecycle behind the
* capture pill. The pill component and the Dictate/Stop button are the only
* consumers; everything else (cache seeding, error toasts, settings reads) is
* internal so the hook can be reused from a floating Tauri window without the
* containing tab.
*/
export function useCaptureRecordingSession(
options: UseCaptureRecordingSessionOptions = {},
): UseCaptureRecordingSessionResult {
const queryClient = useQueryClient();
// Every capture setting is resolved server-side. ``stt_model``,
// ``llm_model`` and refine flags are read from the capture_settings table
// inside POST /captures and /captures/*/refine, and ``auto_refine`` comes
// back on the create response so the client decides whether to chain a
// refine call using a value that can't go stale across sibling webviews.
const [pillState, setPillState] = useState<CapturePillState>('hidden');
const [frozenElapsedMs, setFrozenElapsedMs] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const restTimerRef = useRef<number | null>(null);
const errorTimerRef = useRef<number | null>(null);
// Mutation callbacks close over stale pillState otherwise.
const pillStateRef = useRef<CapturePillState>('hidden');
pillStateRef.current = pillState;
const onCaptureCreatedRef = useRef(options.onCaptureCreated);
onCaptureCreatedRef.current = options.onCaptureCreated;
const onFinalTextRef = useRef(options.onFinalText);
onFinalTextRef.current = options.onFinalText;
// Per-capture recording context and its ``allow_auto_paste`` snapshot, keyed
// by capture id so a refine that resolves after another dictation started
// still delivers to the right target with the setting the capture was created
// under. Populated on capture-create and consumed once the final text lands.
const captureDeliveryRef = useRef<Map<string, { context: unknown; allowAutoPaste: boolean }>>(
new Map(),
);
const clearRestTimer = useCallback(() => {
if (restTimerRef.current !== null) {
window.clearTimeout(restTimerRef.current);
restTimerRef.current = null;
}
}, []);
const clearErrorTimer = useCallback(() => {
if (errorTimerRef.current !== null) {
window.clearTimeout(errorTimerRef.current);
errorTimerRef.current = null;
}
}, []);
const scheduleHidePill = useCallback(() => {
clearRestTimer();
setPillState('completed');
// Two-hop timer: show the green "Done" pill for COMPLETED_DWELL_MS,
// then hand off to the existing rest-fade before unmounting.
restTimerRef.current = window.setTimeout(() => {
setPillState('rest');
restTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
restTimerRef.current = null;
}, REST_FADE_MS);
}, COMPLETED_DWELL_MS);
}, [clearRestTimer]);
const showError = useCallback(
(message: string, durationMs: number = ERROR_PILL_VISIBLE_MS) => {
clearRestTimer();
clearErrorTimer();
setErrorMessage(message || 'Something went wrong');
setPillState('error');
errorTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
setErrorMessage(null);
errorTimerRef.current = null;
}, durationMs);
},
[clearRestTimer, clearErrorTimer],
);
const dismissError = useCallback(() => {
clearErrorTimer();
setPillState('hidden');
setErrorMessage(null);
}, [clearErrorTimer]);
useEffect(
() => () => {
clearRestTimer();
clearErrorTimer();
},
[clearRestTimer, clearErrorTimer],
);
const refineMutation = useMutation({
// Empty body — backend resolves flags and model from capture_settings.
mutationFn: async (captureId: string) => apiClient.refineCapture(captureId, {}),
onSuccess: (data, captureId) => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastUpdated(captureId);
if (pillStateRef.current === 'refining') scheduleHidePill();
const delivery = captureDeliveryRef.current.get(captureId);
captureDeliveryRef.current.delete(captureId);
const finalText = data.transcript_refined ?? data.transcript_raw;
if (finalText) {
onFinalTextRef.current?.(
finalText,
data,
delivery?.allowAutoPaste ?? true,
delivery?.context,
);
}
},
onError: (err: Error, captureId) => {
captureDeliveryRef.current.delete(captureId);
showError(err.message || 'Refinement failed');
},
});
const uploadMutation = useMutation({
mutationFn: async ({
file,
source,
}: {
file: File;
source: CaptureSource;
context?: unknown;
}) => apiClient.createCapture(file, { source }),
onSuccess: (capture, { context }) => {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
});
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastCreated(capture);
onCaptureCreatedRef.current?.(capture, context);
if (capture.auto_refine) {
captureDeliveryRef.current.set(capture.id, {
context,
allowAutoPaste: capture.allow_auto_paste,
});
setPillState('refining');
refineMutation.mutate(capture.id);
} else {
if (pillStateRef.current === 'transcribing') scheduleHidePill();
if (capture.transcript_raw) {
onFinalTextRef.current?.(
capture.transcript_raw,
capture,
capture.allow_auto_paste,
context,
);
}
}
},
onError: (err: Error) => {
// Backend's librosa-audioread fallback returns a 400 with this shape
// for tiny/corrupt webm blobs that slip past the client guard —
// translate it to the same friendly message so the user sees one
// consistent cause, not an opaque decode error.
const msg = err.message || '';
if (/could not decode/i.test(msg) || /empty or corrupt/i.test(msg)) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
} else {
showError(msg || 'Upload failed');
}
},
});
const {
isRecording,
duration,
startRecording: beginAudioRecording,
stopRecording,
error: recordError,
prewarm,
releaseWarm,
} = useAudioRecording({
keepWarm: options.keepMicWarm ?? false,
onRecordingComplete: (blob, recordedDuration, context) => {
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
// so the blob is empty or unparseable. Surface it as a transient pill
// so the user sees their recording was recognised and canceled.
if (!blob.size || (recordedDuration ?? 0) < MIN_RECORDING_DURATION_S) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
return;
}
setFrozenElapsedMs(Math.round((recordedDuration ?? 0) * 1000));
setPillState('transcribing');
const extension = blob.type.includes('wav')
? 'wav'
: blob.type.includes('webm')
? 'webm'
: 'bin';
const file = new File([blob], `dictation-${Date.now()}.${extension}`, {
type: blob.type,
});
uploadMutation.mutate({ file, source: 'dictation', context });
},
});
useEffect(() => {
if (recordError) {
showError(recordError);
}
}, [recordError, showError]);
const startRecording = useCallback(
(context?: unknown) => {
if (isRecording) return;
clearRestTimer();
setFrozenElapsedMs(0);
setPillState('recording');
beginAudioRecording(context);
},
[isRecording, beginAudioRecording, clearRestTimer],
);
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording();
return;
}
startRecording();
}, [isRecording, startRecording, stopRecording]);
const uploadFile = useCallback(
(file: File, source: CaptureSource) => {
uploadMutation.mutate({ file, source });
},
[uploadMutation],
);
const refine = useCallback(
(captureId: string) => {
refineMutation.mutate(captureId);
},
[refineMutation],
);
const pillElapsedMs =
pillState === 'recording' ? Math.round(duration * 1000) : frozenElapsedMs;
return {
pillState,
pillElapsedMs,
errorMessage,
isRecording,
isUploading: uploadMutation.isPending,
isRefining: refineMutation.isPending,
startRecording,
stopRecording,
toggleRecording,
dismissError,
uploadFile,
refine,
prewarm,
releaseWarm,
};
}
-79
View File
@@ -1,79 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event';
import { useEffect, useRef } from 'react';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Spawn (or quiet) the global hotkey monitor based on the saved
* `capture_settings.hotkey_enabled` flag and the recording readiness gates,
* and keep its bindings in sync with the user's chord choices.
*
* Boot sequence:
* - hotkey_enabled = false OR a recording gate is missing → call
* `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do
* *not* call `enable_hotkey` in this state, so the macOS Input Monitoring
* TCC prompt is never triggered for users who haven't opted in, AND the
* chord physically can't fire when models aren't downloaded — preventing
* the "stuck pill" failure mode where dictation triggers but has nowhere
* to land.
* - hotkey_enabled = true AND recording gates green → call `enable_hotkey` with
* the saved chords. This creates the CGEventTap and triggers the TCC
* prompt on first opt-in. Re-runs whenever a gate flips green (e.g. the
* user finishes downloading Whisper in another tab) so the chord
* auto-arms without making the user toggle off/on.
*
* Call once from the main app shell.
*/
export function useChordSync() {
const platform = usePlatform();
const { settings } = useCaptureSettings();
const { canRecord } = useDictationReadiness();
const enabled = settings?.hotkey_enabled;
const keepMicWarm = settings?.keep_mic_warm;
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
// Latest warm state, so the dictate window's mount-time request can be
// answered even between the dep-driven emits below.
const shouldWarmRef = useRef(false);
// The floating dictate window holds the mic warm ahead of the first chord to
// avoid clipping, but it's a separate webview with no view of settings. Mirror
// the decision to it: warm only when dictation is armed AND the user enabled
// "keep microphone ready". Gating here is what stops the always-mounted pill
// from opening the mic — or prompting for access — when the user hasn't asked.
useEffect(() => {
if (!platform.metadata.isTauri) return;
const unlisten = listen('dictate:warm-request', () => {
emit('dictate:warm', shouldWarmRef.current).catch(() => {});
});
return () => {
unlisten.then((fn) => fn()).catch(() => {});
};
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return;
const shouldArm = enabled && canRecord;
const shouldWarm = shouldArm && (keepMicWarm ?? false);
shouldWarmRef.current = shouldWarm;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err);
});
emit('dictate:warm', shouldWarm).catch(() => {});
}, [
platform.metadata.isTauri,
enabled,
keepMicWarm,
canRecord,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','),
toggleKeys?.join(','),
]);
}
-109
View File
@@ -1,109 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useAccessibilityPermission } from '@/components/AccessibilityGate/AccessibilityGate';
import { useInputMonitoringPermission } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { apiClient } from '@/lib/api/client';
import type { ModelReadiness } from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
const READINESS_POLL_INTERVAL_MS = 5_000;
export type ReadinessGate = 'stt' | 'llm' | 'input_monitoring' | 'accessibility';
export interface DictationReadiness {
isLoading: boolean;
canRecord: boolean;
allReady: boolean;
/** Subset of gates that are NOT yet satisfied — what the checklist renders. */
missing: ReadinessGate[];
stt: ModelReadiness | undefined;
llm: ModelReadiness | undefined;
inputMonitoring: boolean;
accessibility: boolean;
refetch: () => void;
openInputMonitoringSettings: () => Promise<void>;
openAccessibilitySettings: () => Promise<void>;
recheckInputMonitoring: () => Promise<boolean>;
recheckAccessibility: () => Promise<boolean>;
}
/**
* Single source of truth for dictation readiness.
*
* ``canRecord`` covers the gates that must be green before the chord can
* start recording. ``allReady`` also includes Accessibility, which only gates
* synthetic paste — dictation still records and lands in Captures without it.
*
* Gates:
* - stt / llm: backend ``/capture/readiness`` (polled, since downloads
* finish out-of-band — e.g. user kicks off a download in another tab and
* expects the toggle to auto-unlock when it lands)
* - input_monitoring / accessibility: macOS TCC checks via Tauri commands
* (rechecked on window focus by the underlying hooks)
*
* Hotkey-enabled is the user's intent toggle and is intentionally *not*
* a gate here — that's `useChordSync`'s concern.
*/
export function useDictationReadiness(): DictationReadiness {
const platform = usePlatform();
const isTauri = platform.metadata.isTauri;
const {
needsPermission: inputMonNeeds,
recheck: recheckInputMon,
openSettings: openInputMon,
} = useInputMonitoringPermission();
const {
needsPermission: a11yNeeds,
recheck: recheckA11y,
openSettings: openA11y,
} = useAccessibilityPermission();
const { data, isLoading, refetch } = useQuery({
queryKey: ['capture-readiness'],
queryFn: () => apiClient.getCaptureReadiness(),
// Poll only while a model is still missing/downloading. Once both are
// green the endpoint's answer can't change until the user swaps models
// in settings, and that path invalidates the query explicitly from
// useSettings. refetchOnWindowFocus stays gated to the same condition.
refetchInterval: (query) => {
const d = query.state.data;
return d && d.stt.ready && d.llm.ready ? false : READINESS_POLL_INTERVAL_MS;
},
refetchOnWindowFocus: (query) => {
const d = query.state.data;
return !(d && d.stt.ready && d.llm.ready);
},
});
// On the web build there's no TCC layer — treat both as granted so the
// checklist doesn't block users who can't even open System Settings.
const inputMonitoring = isTauri ? !inputMonNeeds : true;
const accessibility = isTauri ? !a11yNeeds : true;
const sttReady = data?.stt.ready ?? false;
const llmReady = data?.llm.ready ?? false;
const missing: ReadinessGate[] = [];
if (!sttReady) missing.push('stt');
if (!llmReady) missing.push('llm');
if (!inputMonitoring) missing.push('input_monitoring');
if (!accessibility) missing.push('accessibility');
const canRecord = sttReady && llmReady && inputMonitoring;
return {
isLoading,
canRecord,
allReady: missing.length === 0,
missing,
stt: data?.stt,
llm: data?.llm,
inputMonitoring,
accessibility,
refetch: () => {
refetch();
},
openInputMonitoringSettings: openInputMon,
openAccessibilitySettings: openA11y,
recheckInputMonitoring: recheckInputMon,
recheckAccessibility: recheckA11y,
};
}
+4 -9
View File
@@ -8,8 +8,8 @@ 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';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
@@ -29,7 +29,6 @@ const generationSchema = z.object({
'kokoro',
])
.optional(),
personality: z.boolean().optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -44,10 +43,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const { settings: genSettings } = useGenerationSettings();
const maxChunkChars = genSettings?.max_chunk_chars ?? 800;
const crossfadeMs = genSettings?.crossfade_ms ?? 50;
const normalizeAudio = genSettings?.normalize_audio ?? true;
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -67,7 +65,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: '1.7B',
instruct: '',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
personality: false,
...options.defaultValues,
},
});
@@ -152,7 +149,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct: supportsInstruct ? data.instruct || undefined : undefined,
personality: data.personality || undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
@@ -170,7 +166,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
personality: data.personality,
});
options.onSuccess?.(result.id);
} catch (error) {
+4 -13
View File
@@ -2,22 +2,17 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
interface GenerationStatusEvent {
id: string;
status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found';
duration?: number;
error?: string;
source?: string;
}
// Agent-initiated generations are played by the floating pill, not the
// main-window AudioPlayer. Skip autoplay here to avoid double-playback.
const AGENT_SOURCES = new Set(['mcp', 'rest']);
/**
* Subscribes to SSE for all pending generations. When a generation completes,
* invalidates the history query, removes it from pending, and auto-plays
@@ -31,8 +26,7 @@ export function useGenerationProgress() {
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const { settings: genSettings } = useGenerationSettings();
const autoplayOnGenerate = genSettings?.autoplay_on_generate ?? true;
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
// Keep refs to avoid stale closures in EventSource handlers
const isPlayingRef = useRef(isPlaying);
@@ -115,11 +109,8 @@ export function useGenerationProgress() {
// });
}
// Auto-play if enabled and nothing is currently playing.
// Skip agent-initiated sources — the floating pill window
// plays those itself.
const isAgentSpeak = data.source ? AGENT_SOURCES.has(data.source) : false;
if (autoplayRef.current && !isPlayingRef.current && !isAgentSpeak) {
// Auto-play if enabled and nothing is currently playing
if (autoplayRef.current && !isPlayingRef.current) {
const genAudioUrl = apiClient.getAudioUrl(id);
setAudioWithAutoPlay(genAudioUrl, id, '', '');
}
-60
View File
@@ -1,60 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
MCPClientBindingListResponse,
MCPClientBindingUpsert,
} from '@/lib/api/types';
const MCP_BINDINGS_KEY = ['settings', 'mcp', 'bindings'] as const;
/** Manage per-MCP-client voice bindings (Claude Code → Morgan, etc.). */
export function useMCPBindings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: MCP_BINDINGS_KEY,
queryFn: () => apiClient.listMCPBindings(),
// Keep fresh while the Settings page is open — the ``last_seen_at``
// timestamp is useful for confirming an install works, and we want it
// to tick forward when a client connects.
refetchInterval: 10_000,
});
const upsertMutation = useMutation({
mutationFn: (data: MCPClientBindingUpsert) =>
apiClient.upsertMCPBinding(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
const deleteMutation = useMutation({
mutationFn: (clientId: string) => apiClient.deleteMCPBinding(clientId),
onMutate: async (clientId) => {
await queryClient.cancelQueries({ queryKey: MCP_BINDINGS_KEY });
const prev =
queryClient.getQueryData<MCPClientBindingListResponse>(MCP_BINDINGS_KEY);
if (prev) {
queryClient.setQueryData<MCPClientBindingListResponse>(
MCP_BINDINGS_KEY,
{ items: prev.items.filter((b) => b.client_id !== clientId) },
);
}
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) queryClient.setQueryData(MCP_BINDINGS_KEY, ctx.prev);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
return {
bindings: query.data?.items ?? [],
isLoading: query.isLoading,
upsert: upsertMutation.mutate,
upsertAsync: upsertMutation.mutateAsync,
remove: deleteMutation.mutate,
};
}
-106
View File
@@ -1,106 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
CaptureSettings,
CaptureSettingsUpdate,
GenerationSettings,
GenerationSettingsUpdate,
} from '@/lib/api/types';
const CAPTURE_SETTINGS_KEY = ['settings', 'captures'] as const;
const GENERATION_SETTINGS_KEY = ['settings', 'generation'] as const;
/**
* Hook for capture/refine defaults. Reads from the server and writes partial
* updates with optimistic cache mutation so toggles stay snappy while the
* PUT round-trip settles.
*/
export function useCaptureSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: CAPTURE_SETTINGS_KEY,
queryFn: () => apiClient.getCaptureSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: CaptureSettingsUpdate) => apiClient.updateCaptureSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: CAPTURE_SETTINGS_KEY });
const previous = queryClient.getQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(CAPTURE_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data, _err, patch) => {
if (data) queryClient.setQueryData(CAPTURE_SETTINGS_KEY, data);
// /capture/readiness resolves stt_model / llm_model live on each
// call, but its cached response keeps serving the previous
// model's state until the next 5 s poll. Invalidate on model
// swaps so the readiness checklist re-checks immediately.
if (patch.stt_model !== undefined || patch.llm_model !== undefined) {
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
}
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
/**
* Hook for long-form TTS generation defaults. Same optimistic pattern as
* ``useCaptureSettings``.
*/
export function useGenerationSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: GENERATION_SETTINGS_KEY,
queryFn: () => apiClient.getGenerationSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: GenerationSettingsUpdate) =>
apiClient.updateGenerationSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: GENERATION_SETTINGS_KEY });
const previous = queryClient.getQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(GENERATION_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data) => {
if (data) queryClient.setQueryData(GENERATION_SETTINGS_KEY, data);
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
-21
View File
@@ -9,7 +9,6 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
} from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
@@ -155,26 +154,6 @@ export function useTrimStoryItem() {
});
}
export function useUpdateStoryItemVolume() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemVolumeUpdate;
}) => apiClient.updateStoryItemVolume(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useSplitStoryItem() {
const queryClient = useQueryClient();
+1 -27
View File
@@ -5,7 +5,6 @@ import { useStoryStore } from '@/stores/storyStore';
interface ActiveSource {
source: AudioBufferSourceNode;
clipGain: GainNode;
itemId: string;
generationId: string;
startTimeMs: number;
@@ -62,29 +61,11 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const stopSource = useCallback((itemId: string) => {
const activeSource = activeSourcesRef.current.get(itemId);
if (activeSource) {
// Detach onended first so the natural-end handler doesn't race with
// the explicit teardown below and re-delete a fresh entry that has
// already been re-scheduled at this id.
activeSource.source.onended = null;
try {
activeSource.source.stop();
} catch {
// Source may have already stopped
}
// Hard-cut the audio graph regardless of whether stop() actually
// halted the buffer. Long imports were leaking audio when stop()
// was called on a source that was scheduled with a multi-minute
// duration; disconnecting from the destination guarantees silence.
try {
activeSource.source.disconnect();
} catch {
// already disconnected
}
try {
activeSource.clipGain.disconnect();
} catch {
// already disconnected
}
activeSourcesRef.current.delete(itemId);
}
}, []);
@@ -283,17 +264,10 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const source = audioContext.createBufferSource();
source.buffer = buffer;
// Per-clip gain so each item can override its level independently
// of the master volume. Falls through 1.0 for any item without a
// saved value (older rows pre-migration).
const clipGain = audioContext.createGain();
clipGain.gain.value = typeof item.volume === 'number' ? item.volume : 1;
source.connect(clipGain);
clipGain.connect(masterGainRef.current || audioContext.destination);
source.connect(masterGainRef.current || audioContext.destination);
const activeSource: ActiveSource = {
source,
clipGain,
itemId: item.id,
generationId: item.generation_id,
startTimeMs: item.start_time_ms,
+5 -34
View File
@@ -1,6 +1,4 @@
import { formatDistance } from 'date-fns';
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
@@ -8,29 +6,15 @@ export function formatDuration(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getDateLocale() {
switch (i18n.language) {
case 'es':
return es;
case 'ja':
return ja;
case 'zh-CN':
return zhCN;
case 'zh-TW':
return zhTW;
case 'fr':
return fr;
default:
return undefined;
}
}
export function formatDate(date: string | Date): string {
// Parse the date string - if it doesn't have timezone info, treat it as UTC
let dateObj: Date;
if (typeof date === 'string') {
// If the string doesn't end with Z or have timezone offset, assume it's UTC
const dateStr = date.trim();
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
dateObj = new Date(`${dateStr}Z`);
// No timezone info, treat as UTC
dateObj = new Date(dateStr + 'Z');
} else {
dateObj = new Date(dateStr);
}
@@ -38,20 +22,7 @@ export function formatDate(date: string | Date): string {
dateObj = date;
}
return formatDistance(dateObj, new Date(), {
addSuffix: true,
locale: getDateLocale(),
}).replace(/^about /i, '');
}
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
}
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
-168
View File
@@ -1,168 +0,0 @@
/**
* Stable key-name vocabulary shared with the Rust `key_codes` module.
*
* The chord persistence layer stores keytap `Key` variant names ("MetaRight",
* "AltGr", "KeyA", …) so the same array round-trips losslessly between
* the picker UI, the SQLite settings row, and the global hotkey listener.
*
* This module owns the conversions between three vocabularies:
* - browser `KeyboardEvent` (`event.code` like "MetaRight" / "AltRight")
* - canonical chord key names (matches keytap variants)
* - human display labels ("⌘", "⌥", "A", …)
*/
/**
* Map a `KeyboardEvent` to the canonical key name we persist. Returns
* `null` for keys we don't support in chords (dead keys, IME composition,
* etc.).
*
* Browser quirk: right-Option on macOS is reported as `"AltRight"`; keytap
* calls it `"AltGr"`. Normalize to keytap's name so the Rust side recognizes
* it without an aliasing layer.
*/
export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
const code = event.code;
if (!code) return null;
switch (code) {
case 'AltLeft':
return 'Alt';
case 'AltRight':
return 'AltGr';
case 'BracketLeft':
return 'LeftBracket';
case 'BracketRight':
return 'RightBracket';
case 'Semicolon':
return 'SemiColon';
case 'Backslash':
return 'BackSlash';
case 'Backquote':
return 'BackQuote';
case 'Period':
return 'Dot';
case 'Enter':
return 'Return';
case 'ArrowUp':
return 'UpArrow';
case 'ArrowDown':
return 'DownArrow';
case 'ArrowLeft':
return 'LeftArrow';
case 'ArrowRight':
return 'RightArrow';
default:
// Browser names like "MetaRight", "MetaLeft", "ControlLeft",
// "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the
// keytap variant names directly.
if (
/^(Meta|Control|Shift)(Left|Right)$/.test(code) ||
/^Key[A-Z]$/.test(code) ||
/^Digit[0-9]$/.test(code) ||
/^F([1-9]|1[0-2])$/.test(code) ||
['Space', 'Tab', 'Backspace', 'Delete', 'Escape', 'Insert',
'Home', 'End', 'PageUp', 'PageDown', 'CapsLock', 'Function',
'Minus', 'Equal', 'Quote', 'Comma', 'Slash'].includes(code)
) {
return code;
}
return null;
}
}
const PLATFORM_IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
export function defaultChordKeys(mode: 'push' | 'toggle'): string[] {
const base = PLATFORM_IS_MAC
? ['MetaRight', 'AltGr']
: ['ControlRight', 'ShiftRight'];
return mode === 'toggle' ? [...base, 'Space'] : base;
}
/**
* Pretty label for a canonical key name. Picks platform-appropriate
* modifier glyphs so macOS users see ⌘ and Windows/Linux users see Win.
*/
export function displayLabelForKey(name: string): string {
switch (name) {
case 'MetaLeft':
case 'MetaRight':
return PLATFORM_IS_MAC ? '⌘' : 'Win';
case 'Alt':
return PLATFORM_IS_MAC ? '⌥' : 'Alt';
case 'AltGr':
return PLATFORM_IS_MAC ? '⌥' : 'AltGr';
case 'ControlLeft':
case 'ControlRight':
return PLATFORM_IS_MAC ? '⌃' : 'Ctrl';
case 'ShiftLeft':
case 'ShiftRight':
return PLATFORM_IS_MAC ? '⇧' : 'Shift';
case 'CapsLock':
return '⇪';
case 'Function':
return 'fn';
case 'Space':
return 'Space';
case 'Tab':
return '⇥';
case 'Return':
return '↵';
case 'Backspace':
return '⌫';
case 'Delete':
return '⌦';
case 'Escape':
return 'Esc';
case 'UpArrow':
return '↑';
case 'DownArrow':
return '↓';
case 'LeftArrow':
return '←';
case 'RightArrow':
return '→';
}
if (/^Key([A-Z])$/.test(name)) return name.slice(3);
if (/^Num([0-9])$/.test(name)) return name.slice(3);
if (/^F([1-9]|1[0-2])$/.test(name)) return name;
return name;
}
/**
* Side-aware suffix to disambiguate left vs right modifier variants
* — the tiny "R" badge that lets a user see the chord defaults to the
* right-hand keys.
*/
export function modifierSideHint(name: string): 'L' | 'R' | null {
if (name === 'MetaRight' || name === 'AltGr' || name === 'ControlRight' || name === 'ShiftRight') {
return 'R';
}
if (name === 'MetaLeft' || name === 'Alt' || name === 'ControlLeft' || name === 'ShiftLeft') {
return 'L';
}
return null;
}
/**
* Sort a chord's keys so the kbd pills always render in a predictable
* order: modifiers first (Ctrl, Opt, Shift, Cmd), main key last. Matches
* how every macOS shortcut docs list the keys.
*/
const SORT_ORDER: Record<string, number> = {
ControlLeft: 0, ControlRight: 0,
Alt: 1, AltGr: 1,
ShiftLeft: 2, ShiftRight: 2,
MetaLeft: 3, MetaRight: 3,
Function: 4,
CapsLock: 5,
};
export function sortChordKeys(keys: string[]): string[] {
return [...keys].sort((a, b) => {
const sa = SORT_ORDER[a] ?? 99;
const sb = SORT_ORDER[b] ?? 99;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
}
-1
View File
@@ -3,7 +3,6 @@ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import { queryClient } from './lib/queryClient';
-1
View File
@@ -60,7 +60,6 @@ export interface PlatformLifecycle {
stopServer(): Promise<void>;
restartServer(modelsDir?: string | null): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>;
setBackendOverride(backend?: string | null): Promise<void>;
setupWindowCloseHandler(): Promise<void>;
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void;
onServerReady?: () => void;
+6 -22
View File
@@ -6,18 +6,16 @@ import {
redirect,
} from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { CapturesTab } from '@/components/CapturesTab/CapturesTab';
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 { AboutPage } from '@/components/ServerTab/AboutPage';
import { CapturesPage } from '@/components/ServerTab/CapturesPage';
import { ChangelogPage } from '@/components/ServerTab/ChangelogPage';
import { GeneralPage } from '@/components/ServerTab/GeneralPage';
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
import { GpuPage } from '@/components/ServerTab/GpuPage';
import { LogsPage } from '@/components/ServerTab/LogsPage';
import { MCPPage } from '@/components/ServerTab/MCPPage';
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
@@ -113,11 +111,11 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
// Captures route (prototype — will replace AudioTab once the new flow is ready)
const capturesRoute = createRoute({
// Audio route
const audioRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/captures',
component: CapturesTab,
path: '/audio',
component: AudioTab,
});
// Effects route
@@ -154,18 +152,6 @@ const settingsGenerationRoute = createRoute({
component: GenerationPage,
});
const settingsCapturesRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/captures',
component: CapturesPage,
});
const settingsMCPRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/mcp',
component: MCPPage,
});
const settingsGpuRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/gpu',
@@ -203,15 +189,13 @@ const serverRedirectRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
voicesRoute,
audioRoute,
effectsRoute,
modelsRoute,
settingsRoute.addChildren([
settingsGeneralRoute,
settingsGenerationRoute,
settingsCapturesRoute,
settingsMCPRoute,
settingsGpuRoute,
settingsLogsRoute,
settingsChangelogRoute,
+25 -35
View File
@@ -15,6 +15,18 @@ interface ServerStore {
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
maxChunkChars: number;
setMaxChunkChars: (value: number) => void;
crossfadeMs: number;
setCrossfadeMs: (value: number) => void;
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
@@ -27,44 +39,10 @@ function invalidateAllServerData() {
queryClient.invalidateQueries();
}
export function getDefaultServerUrl(): string {
const fallback = 'http://127.0.0.1:17493';
if (!import.meta.env.PROD || typeof window === 'undefined') {
return fallback;
}
const { protocol, origin, hostname } = window.location;
if (
(protocol === 'http:' || protocol === 'https:') &&
origin &&
hostname !== 'tauri.localhost'
) {
return origin;
}
return fallback;
}
export function isLoopbackVoiceboxServerUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.port === '17493' &&
(parsed.hostname === '127.0.0.1' ||
parsed.hostname === 'localhost' ||
parsed.hostname === '[::1]' ||
parsed.hostname === '::1')
);
} catch {
return false;
}
}
export const useServerStore = create<ServerStore>()(
persist(
(set, get) => ({
serverUrl: getDefaultServerUrl(),
serverUrl: 'http://127.0.0.1:17493',
setServerUrl: (url) => {
const prev = get().serverUrl;
set({ serverUrl: url });
@@ -82,6 +60,18 @@ export const useServerStore = create<ServerStore>()(
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
maxChunkChars: 800,
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
+25 -54
View File
@@ -1,25 +1,10 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Theme = 'light' | 'dark' | 'system';
function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: Theme) {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', resolveTheme(theme) === 'dark');
}
// Draft state for the create voice profile form
export interface ProfileFormDraft {
name: string;
description: string;
language: string;
personality: string;
referenceText: string;
sampleMode: 'upload' | 'record' | 'system';
// Note: File objects can't be persisted, so we store metadata
@@ -59,51 +44,37 @@ interface UIStore {
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
// Theme
theme: Theme;
setTheme: (theme: Theme) => void;
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
sidebarOpen: true,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
profileDialogOpen: false,
setProfileDialogOpen: (open) => set({ profileDialogOpen: open }),
editingProfileId: null,
setEditingProfileId: (id) => set({ editingProfileId: id }),
profileDialogOpen: false,
setProfileDialogOpen: (open) => set({ profileDialogOpen: open }),
editingProfileId: null,
setEditingProfileId: (id) => set({ editingProfileId: id }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedEngine: 'qwen',
setSelectedEngine: (engine) => set({ selectedEngine: engine }),
selectedEngine: 'qwen',
setSelectedEngine: (engine) => set({ selectedEngine: engine }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
theme: 'system',
setTheme: (theme) => {
set({ theme });
applyTheme(theme);
},
}),
{
name: 'voicebox-ui',
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
theme: state.theme,
}),
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.theme);
},
},
),
);
theme: 'light',
setTheme: (theme) => {
set({ theme });
document.documentElement.classList.toggle('dark', theme === 'dark');
},
}));
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.5.0"
__version__ = "0.4.1"

Some files were not shown because too many files have changed in this diff Show More