mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f49dea19 | ||
|
|
80610d880e | ||
|
|
397051ba44 | ||
|
|
1ba935e83b | ||
|
|
6a6f4643da | ||
|
|
44ef8daba3 | ||
|
|
68ece25a80 | ||
|
|
1db0fdf645 | ||
|
|
2a001fd63f | ||
|
|
e5813304ef | ||
|
|
a5773807a5 | ||
|
|
ed54347e81 | ||
|
|
624f6a2140 | ||
|
|
669f85024f | ||
|
|
52f8d8dd38 | ||
|
|
fb1e16d2ce | ||
|
|
f750596364 | ||
|
|
91cd6df108 | ||
|
|
484a39ad9f | ||
|
|
3bfcbdc819 | ||
|
|
190bc5e8a8 | ||
|
|
80af641b61 | ||
|
|
6936789a88 | ||
|
|
f3eca34d33 | ||
|
|
30db291b01 | ||
|
|
71b51366bc | ||
|
|
258b92c9c0 | ||
|
|
e6cf50c7f7 | ||
|
|
2dc3b075d5 | ||
|
|
05d90790f8 | ||
|
|
4a6b5da793 | ||
|
|
2c9d02af62 | ||
|
|
b680097dfb | ||
|
|
f2cf2a729d | ||
|
|
b542768429 | ||
|
|
e766c7cbfb | ||
|
|
c2282b256a | ||
|
|
cabef1bfe0 | ||
|
|
3835b63bd8 | ||
|
|
da79e37ef5 | ||
|
|
6e4989313c | ||
|
|
42b9cae216 | ||
|
|
b9bb2f075c | ||
|
|
e294b9c8f0 | ||
|
|
c1814a2870 | ||
|
|
7d9a384ee4 | ||
|
|
c6a59f4477 | ||
|
|
4f13123b95 | ||
|
|
21c7e373d3 | ||
|
|
45b64e0233 | ||
|
|
b35b90961d | ||
|
|
7df366d0c8 | ||
|
|
627d40b42d |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.4.5
|
||||
current_version = 0.5.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ tauri/
|
||||
landing/
|
||||
docs/
|
||||
mlx-test/
|
||||
scripts/
|
||||
scripts/*
|
||||
!scripts/rocm-entrypoint.sh
|
||||
|
||||
# Dependencies & build artifacts (rebuilt in Docker)
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package.json text eol=lf
|
||||
scripts/*.sh text eol=lf
|
||||
@@ -29,11 +29,14 @@ 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
|
||||
|
||||
@@ -114,6 +114,7 @@ jobs:
|
||||
run: |
|
||||
cd backend
|
||||
python build_binary.py
|
||||
python build_binary.py --shim
|
||||
|
||||
# Get platform tuple
|
||||
PLATFORM=$(rustc --print host-tuple)
|
||||
@@ -123,7 +124,9 @@ 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
|
||||
@@ -337,3 +340,64 @@ 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
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:17493/mcp",
|
||||
"headers": {
|
||||
"X-Voicebox-Client-Id": "claude-code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
-1
@@ -7,6 +7,100 @@
|
||||
|
||||
## [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.
|
||||
@@ -657,7 +751,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
|
||||
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.5...HEAD
|
||||
[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
|
||||
|
||||
+1
-1
@@ -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%/com.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%/sh.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/`.
|
||||
|
||||
|
||||
+39
-9
@@ -1,8 +1,15 @@
|
||||
# ============================================================
|
||||
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||
# Voicebox — Local TTS Server with Web UI
|
||||
# 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
|
||||
|
||||
@@ -13,8 +20,11 @@ COPY package.json bun.lock CHANGELOG.md ./
|
||||
COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
# Normalize line endings first (a Windows CRLF checkout would otherwise
|
||||
# defeat the `-z 's/,\n ]/…/'` match below, since it's LF-anchored), then
|
||||
# strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i 's/\r$//' package.json && \
|
||||
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)
|
||||
@@ -24,6 +34,9 @@ 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 \
|
||||
@@ -34,6 +47,19 @@ 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
|
||||
@@ -44,16 +70,17 @@ RUN pip install --no-cache-dir --prefix=/install \
|
||||
# === Stage 3: Runtime ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user for security
|
||||
# Create non-root user; the entrypoint joins GPU device groups at runtime.
|
||||
RUN groupadd -r voicebox && \
|
||||
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime system dependencies
|
||||
# Install only runtime system dependencies (gosu drops root in the entrypoint)
|
||||
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
|
||||
@@ -69,9 +96,6 @@ 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
|
||||
|
||||
@@ -79,5 +103,11 @@ EXPOSE 17493
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||
CMD curl -f http://localhost:17493/health || exit 1
|
||||
|
||||
# Start the FastAPI server
|
||||
# Entrypoint joins GPU groups then drops to the voicebox user.
|
||||
# Normalize CRLF (a Windows checkout otherwise leaves the shebang as
|
||||
# `#!/bin/sh\r`, which Linux can't resolve — reported as a misleading
|
||||
# "no such file or directory" even though the file exists).
|
||||
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<h1 align="center">Voicebox</h1>
|
||||
|
||||
<p align="center">
|
||||
<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.
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,6 +28,10 @@
|
||||
</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> •
|
||||
@@ -63,17 +67,22 @@
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
- **Complete privacy** — models and voice data stay on your machine
|
||||
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
|
||||
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
|
||||
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
|
||||
- **Voice cloning and preset voices** — zero-shot cloning from a reference sample, or 50+ curated preset voices via Kokoro and Qwen CustomVoice
|
||||
- **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
|
||||
- **API-first** — REST API for integrating voice synthesis into your own projects
|
||||
- **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
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
|
||||
|
||||
@@ -185,12 +194,69 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
- Auto-playback with synchronized playhead
|
||||
- Version pinning per track clip
|
||||
|
||||
### Recording & Transcription
|
||||
### Global Dictation & Voice Input
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
### Model Management
|
||||
|
||||
@@ -204,7 +270,8 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
| Platform | Backend | Notes |
|
||||
| ------------------------ | -------------- | ---------------------------------------------- |
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -214,55 +281,121 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
|
||||
## API
|
||||
|
||||
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
|
||||
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
|
||||
|
||||
```bash
|
||||
# Generate speech
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
curl -X POST http://127.0.0.1:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||
|
||||
# List voice profiles
|
||||
curl http://localhost:17493/profiles
|
||||
|
||||
# Create a profile
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
# 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" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
-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
|
||||
```
|
||||
|
||||
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
||||
`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`.
|
||||
|
||||
Full API documentation available at `http://localhost:17493/docs`.
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
@@ -286,6 +419,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
+17
-1
@@ -1,10 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+35
-1
@@ -1,17 +1,29 @@
|
||||
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 { useServerStore } from '@/stores/serverStore';
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a health response has the expected Voicebox-specific shape.
|
||||
@@ -64,6 +76,19 @@ 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);
|
||||
@@ -73,6 +98,10 @@ function App() {
|
||||
// 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) {
|
||||
@@ -105,6 +134,11 @@ function App() {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,126 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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);
|
||||
@@ -0,0 +1,209 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Snapshot of the focused UI element at chord-start, shipped over from
|
||||
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
|
||||
// the 1–2 s transcribe + refine window — the paste only fires once the
|
||||
// final text comes back.
|
||||
const focusRef = useRef<FocusSnapshot | null>(null);
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
onFinalText: async (text, _capture, allowAutoPaste) => {
|
||||
const focus = focusRef.current;
|
||||
// Consume-once: a second chord before this fires would overwrite
|
||||
// focusRef, but nulling it here guards against the late-arriving
|
||||
// refine-result firing a paste after the user has moved on.
|
||||
focusRef.current = 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(() => {
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
unlistens.push(
|
||||
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
|
||||
focusRef.current = event.payload?.focus ?? null;
|
||||
sessionRef.current.startRecording();
|
||||
}),
|
||||
);
|
||||
unlistens.push(
|
||||
listen('dictate:stop', () => {
|
||||
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- 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>
|
||||
);
|
||||
}
|
||||
@@ -350,7 +350,7 @@ function SortableEffectItem({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||
className="p-0.5 text-muted-foreground "
|
||||
onClick={onRemove}
|
||||
title={t('effects.chain.remove')}
|
||||
>
|
||||
|
||||
@@ -279,7 +279,7 @@ export function EffectsDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
||||
className="h-8 text-destructive gap-1.5"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
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';
|
||||
@@ -43,73 +51,74 @@ export function EffectsList() {
|
||||
}
|
||||
|
||||
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">{t('effects.title')}</h2>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User presets */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New preset placeholder */}
|
||||
{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>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
|
||||
|
||||
export function EffectsTab() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
<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">
|
||||
<div className="flex-1 min-h-0 flex flex-col pr-8">
|
||||
<EffectsDetail />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent side={compact ? 'top' : undefined}>
|
||||
{availableOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||
{opt.label}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -52,6 +53,21 @@ 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({
|
||||
@@ -175,6 +191,10 @@ 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)
|
||||
@@ -235,10 +255,10 @@ export function FloatingGenerateBox({
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed right-auto',
|
||||
'fixed',
|
||||
isStoriesRoute
|
||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||
'left-[calc(5rem+2rem)] w-[360px]'
|
||||
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
|
||||
'left-[calc(5rem+360px+1.5rem)] right-8'
|
||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
@@ -331,35 +351,90 @@ export function FloatingGenerateBox({
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<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
|
||||
? 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 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>
|
||||
|
||||
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
|
||||
<AnimatePresence>
|
||||
@@ -369,7 +444,6 @@ 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
|
||||
@@ -399,6 +473,35 @@ export function FloatingGenerateBox({
|
||||
</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>
|
||||
|
||||
@@ -452,7 +555,7 @@ export function FloatingGenerateBox({
|
||||
<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')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
@@ -478,7 +582,7 @@ export function FloatingGenerateBox({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
@@ -506,7 +610,7 @@ 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')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
<SelectItem value="none" className="text-xs">
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
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 {
|
||||
@@ -57,37 +58,6 @@ 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);
|
||||
@@ -462,7 +432,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">
|
||||
No voice generations, yet...
|
||||
{t('history.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -474,7 +444,7 @@ export function HistoryTable() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground hover:text-destructive"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setClearFailedDialogOpen(true)}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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 } from '@/lib/api/types';
|
||||
import type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
@@ -21,6 +21,9 @@ 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
|
||||
@@ -36,10 +39,26 @@ 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(() => {
|
||||
@@ -51,7 +70,7 @@ export function GpuAcceleration() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSE progress tracking during download
|
||||
// SSE progress tracking during CUDA download
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) {
|
||||
return;
|
||||
@@ -88,6 +107,43 @@ 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;
|
||||
@@ -113,7 +169,7 @@ export function GpuAcceleration() {
|
||||
}, 1000);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownloadCuda = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
@@ -128,6 +184,21 @@ 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');
|
||||
@@ -154,18 +225,17 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
const handleSwitchToCpuFromCuda = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
// 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();
|
||||
// Invoke resolved — server is likely ready
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
@@ -184,7 +254,36 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
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 () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
@@ -194,6 +293,16 @@ 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;
|
||||
@@ -205,7 +314,7 @@ export function GpuAcceleration() {
|
||||
// Don't render until health data is available
|
||||
if (!health) return null;
|
||||
|
||||
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
|
||||
// If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
@@ -241,8 +350,6 @@ export function GpuAcceleration() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Native GPU detected - no CUDA download needed */}
|
||||
|
||||
{/* Currently running CUDA - show switch back to CPU */}
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<>
|
||||
@@ -261,7 +368,12 @@ export function GpuAcceleration() {
|
||||
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
||||
re-download later).
|
||||
</p>
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
|
||||
<Button
|
||||
onClick={handleSwitchToCpuFromCuda}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to CPU Backend
|
||||
</Button>
|
||||
@@ -276,39 +388,207 @@ export function GpuAcceleration() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
{/* Currently running ROCm - show switch back to CPU */}
|
||||
{isCurrentlyRocm && platform.metadata.isTauri && (
|
||||
<>
|
||||
{/* 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 && (
|
||||
<span className="text-muted-foreground">
|
||||
{downloadProgress.progress.toFixed(1)}%
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
@@ -329,52 +609,6 @@ 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>
|
||||
|
||||
@@ -83,6 +83,12 @@ 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 {
|
||||
@@ -411,11 +417,13 @@ 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 },
|
||||
];
|
||||
|
||||
// Get detail modal state for selected model
|
||||
|
||||
@@ -116,7 +116,7 @@ export function AboutPage() {
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={400}>
|
||||
<FadeIn delay={480}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
<Trans
|
||||
i18nKey="settings.about.license"
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
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 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.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,10 @@ 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({
|
||||
@@ -198,8 +200,16 @@ export function GeneralPage() {
|
||||
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 />}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { FolderOpen, Languages, Mic, Zap } 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';
|
||||
@@ -12,14 +13,18 @@ export function GenerationPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
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 { 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 [opening, setOpening] = useState(false);
|
||||
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
|
||||
|
||||
@@ -48,7 +53,8 @@ export function GenerationPage() {
|
||||
}, [platform, generationsPath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<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.generation.title')}
|
||||
description={t('settings.generation.description')}
|
||||
@@ -66,6 +72,7 @@ export function GenerationPage() {
|
||||
id="maxChunkChars"
|
||||
value={[maxChunkChars]}
|
||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||
onValueCommit={([value]) => update({ max_chunk_chars: value })}
|
||||
min={100}
|
||||
max={5000}
|
||||
step={50}
|
||||
@@ -88,6 +95,7 @@ export function GenerationPage() {
|
||||
id="crossfadeMs"
|
||||
value={[crossfadeMs]}
|
||||
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||
onValueCommit={([value]) => update({ crossfade_ms: value })}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
@@ -103,7 +111,7 @@ export function GenerationPage() {
|
||||
<Toggle
|
||||
id="normalizeAudio"
|
||||
checked={normalizeAudio}
|
||||
onCheckedChange={setNormalizeAudio}
|
||||
onCheckedChange={(v) => update({ normalize_audio: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -116,7 +124,7 @@ export function GenerationPage() {
|
||||
<Toggle
|
||||
id="autoplayOnGenerate"
|
||||
checked={autoplayOnGenerate}
|
||||
onCheckedChange={setAutoplayOnGenerate}
|
||||
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -137,6 +145,47 @@ export function GenerationPage() {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, HealthResponse } from '@/lib/api/types';
|
||||
import type { CudaDownloadProgress, RocmDownloadProgress, HealthResponse } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
@@ -50,7 +50,10 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
: 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';
|
||||
const showBackendVariant =
|
||||
health.backend_variant &&
|
||||
health.backend_variant !== 'cpu' &&
|
||||
health.backend_variant.toLowerCase() !== gpuBackend?.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
@@ -115,10 +118,14 @@ 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);
|
||||
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
|
||||
// tear down and reconnect the EventSource every time the language changes.
|
||||
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
@@ -136,9 +143,27 @@ 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 () => {
|
||||
@@ -150,7 +175,7 @@ export function GpuPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) return;
|
||||
if ((!cudaDownloading && !cudaStreaming) || !serverUrl) return;
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||
|
||||
@@ -162,11 +187,13 @@ 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'));
|
||||
setDownloadProgress(null);
|
||||
setCudaStreaming(false);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -176,12 +203,50 @@ export function GpuPage() {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
setCudaStreaming(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
}, [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]);
|
||||
|
||||
const clearHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) {
|
||||
@@ -224,10 +289,11 @@ export function GpuPage() {
|
||||
[platform, startHealthPolling, clearHealthPolling],
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownloadCuda = 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');
|
||||
@@ -239,28 +305,64 @@ export function GpuPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
const handleDownloadRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
await apiClient.downloadRocmBackend();
|
||||
setRocmStreaming(true);
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchRocmStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSwitchToCpu = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await platform.lifecycle.setBackendOverride('cpu');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
|
||||
} 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'));
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
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 () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
@@ -270,6 +372,16 @@ export function GpuPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteRocmBackend();
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteRocm'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -283,6 +395,7 @@ export function GpuPage() {
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
!isCurrentlyRocm &&
|
||||
health.gpu_type &&
|
||||
!health.gpu_type.includes('CUDA');
|
||||
|
||||
@@ -290,33 +403,188 @@ export function GpuPage() {
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<GpuInfoCard health={health} />
|
||||
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<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>
|
||||
{!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>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{restartPhase !== 'idle' && (
|
||||
{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 && (
|
||||
<SettingSection
|
||||
title={isCurrentlyCuda ? t('settings.gpu.cuda.activeTitle') : t('settings.gpu.rocm.activeTitle')}
|
||||
description={t('settings.gpu.activeBackend.description')}
|
||||
>
|
||||
{restartPhase !== 'idle' ? (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
@@ -327,8 +595,18 @@ export function GpuPage() {
|
||||
}
|
||||
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 && (
|
||||
<SettingRow title={t('common.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
@@ -337,67 +615,6 @@ export function GpuPage() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.download.title')}
|
||||
description={t('settings.gpu.download.description')}
|
||||
action={
|
||||
<Button onClick={handleDownload} 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={handleRestart} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToCuda.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.remove.title')}
|
||||
description={t('settings.gpu.remove.description')}
|
||||
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" />
|
||||
{t('settings.gpu.remove.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface SettingsTab {
|
||||
labelKey: string;
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
| '/settings/captures'
|
||||
| '/settings/mcp'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
@@ -20,6 +23,8 @@ 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' },
|
||||
@@ -54,7 +59,7 @@ export function SettingsLayout() {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SettingSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
||||
{title && <h3 className="text-lg font-semibold">{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}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
@@ -13,12 +13,18 @@ interface SidebarProps {
|
||||
isMacOS?: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
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: 'audio', path: '/audio', icon: Speaker, labelKey: 'nav.audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
@@ -41,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<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))',
|
||||
}}
|
||||
/>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
@@ -74,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={t(tab.labelKey)}
|
||||
aria-label={t(tab.labelKey)}
|
||||
title={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
>
|
||||
{isActive && (
|
||||
<div
|
||||
|
||||
@@ -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">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
{/* 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">
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +21,7 @@ interface StoryChatItemProps {
|
||||
storyId: string;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onRegenerate?: () => void;
|
||||
currentTimeMs: number;
|
||||
isPlaying: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
@@ -30,6 +31,7 @@ interface StoryChatItemProps {
|
||||
export function StoryChatItem({
|
||||
item,
|
||||
onRemove,
|
||||
onRegenerate,
|
||||
currentTimeMs,
|
||||
isPlaying,
|
||||
dragHandleProps,
|
||||
@@ -83,7 +85,9 @@ 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">
|
||||
{!avatarError ? (
|
||||
{item.engine === 'import' ? (
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
) : !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
@@ -102,18 +106,24 @@ 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">{item.profile_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||
<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="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||
{formatTime(itemStartMs)}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||
readOnly
|
||||
onDoubleClick={handlePlay}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -134,6 +144,12 @@ export function StoryChatItem({
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.playFromHere')}
|
||||
</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"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { Download, Music, Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -46,7 +47,12 @@ 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('');
|
||||
@@ -72,8 +78,12 @@ export function StoryContent() {
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Calculate dynamic bottom padding: track editor + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 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;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
@@ -138,6 +148,19 @@ 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;
|
||||
|
||||
@@ -210,6 +233,33 @@ 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;
|
||||
|
||||
@@ -265,9 +315,54 @@ export function StoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<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" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
@@ -307,13 +402,23 @@ export function StoryContent() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<div className="p-2 border-b space-y-2">
|
||||
<Input
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
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 ? (
|
||||
@@ -357,7 +462,7 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
@@ -392,6 +497,11 @@ 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}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -28,6 +29,15 @@ 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 {
|
||||
@@ -62,6 +72,7 @@ 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
|
||||
@@ -178,6 +189,19 @@ 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">
|
||||
@@ -186,77 +210,90 @@ export function StoryList() {
|
||||
);
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
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" />
|
||||
<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>
|
||||
|
||||
{/* 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">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
<ListPaneScroll
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<div className="mx-4 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>
|
||||
</div>
|
||||
) : (
|
||||
<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={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: 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>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
<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)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 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 })}
|
||||
>
|
||||
@@ -278,11 +315,11 @@ export function StoryList() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
|
||||
{/* Create Story Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
@@ -393,6 +430,6 @@ export function StoryList() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ 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';
|
||||
@@ -20,6 +23,8 @@ 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';
|
||||
@@ -30,8 +35,10 @@ 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
|
||||
@@ -75,8 +82,19 @@ 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,
|
||||
@@ -118,6 +136,66 @@ 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[];
|
||||
@@ -125,15 +203,21 @@ interface StoryTrackEditorProps {
|
||||
|
||||
const TRACK_HEIGHT = 48;
|
||||
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||
const MIN_PIXELS_PER_SECOND = 10;
|
||||
const MAX_PIXELS_PER_SECOND = 200;
|
||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
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 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(DEFAULT_PIXELS_PER_SECOND);
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND);
|
||||
const hasAppliedDefaultZoomRef = useRef(false);
|
||||
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
@@ -149,7 +233,13 @@ 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);
|
||||
@@ -258,10 +348,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
stop();
|
||||
};
|
||||
|
||||
// Calculate unique tracks from items, always showing at least 3 default tracks
|
||||
// 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.
|
||||
const tracks = useMemo(() => {
|
||||
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
|
||||
const trackSet = new Set([
|
||||
...DEFAULT_TRACKS,
|
||||
...items.map((item) => item.track),
|
||||
...extraTracks,
|
||||
]);
|
||||
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
|
||||
@@ -282,6 +394,44 @@ 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);
|
||||
@@ -293,6 +443,41 @@ 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);
|
||||
@@ -324,11 +509,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, MAX_PIXELS_PER_SECOND));
|
||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
|
||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps));
|
||||
};
|
||||
|
||||
// Resize handlers
|
||||
@@ -374,7 +559,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;
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
|
||||
const timeMs = Math.max(0, pixelsToMs(x));
|
||||
seek(timeMs);
|
||||
// Deselect clip when clicking on timeline
|
||||
@@ -505,7 +690,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const item = items.find((i) => i.id === selectedClipId);
|
||||
if (!item) return;
|
||||
|
||||
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
||||
// 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 effectiveDuration = getEffectiveDuration(item);
|
||||
|
||||
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
||||
@@ -590,6 +778,20 @@ 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) => {
|
||||
@@ -654,7 +856,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
setDragPosition({
|
||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||
// 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,
|
||||
// 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,
|
||||
});
|
||||
@@ -666,7 +874,12 @@ 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;
|
||||
const x =
|
||||
e.clientX -
|
||||
rect.left +
|
||||
tracksRef.current.scrollLeft -
|
||||
dragOffset.x -
|
||||
LABEL_COL_WIDTH;
|
||||
// Subtract ruler height since clips are positioned relative to tracks area
|
||||
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||
|
||||
@@ -762,7 +975,106 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
|
||||
// Calculate tracks area height
|
||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar 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]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
@@ -836,6 +1148,31 @@ 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"
|
||||
@@ -846,6 +1183,18 @@ 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" />
|
||||
@@ -916,44 +1265,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{/* 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 */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative flex-1"
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
className="flex sticky top-0 z-30"
|
||||
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
|
||||
>
|
||||
{/* Time ruler - clickable to seek */}
|
||||
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||
className="h-6 border-b bg-muted/20 cursor-pointer text-left relative"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
aria-label="Seek timeline"
|
||||
@@ -971,27 +1301,72 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
{/* 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 (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 border-b pointer-events-none',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
className="absolute left-0 right-0 flex"
|
||||
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"
|
||||
@@ -1052,7 +1427,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.profile_name}
|
||||
{item.engine === 'import' ? item.text : item.profile_name}
|
||||
</p>
|
||||
</div>
|
||||
{/* Waveform */}
|
||||
@@ -1101,6 +1476,55 @@ 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,4 +1,4 @@
|
||||
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -91,7 +91,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 ring-accent shadow-md',
|
||||
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
@@ -126,6 +126,9 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
{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
|
||||
|
||||
@@ -77,6 +77,7 @@ function makeProfileSchema(t: (key: string) => string) {
|
||||
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(),
|
||||
@@ -100,6 +101,7 @@ type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
personality?: string;
|
||||
sampleFile?: File;
|
||||
referenceText?: string;
|
||||
avatarFile?: File;
|
||||
@@ -166,6 +168,7 @@ export function ProfileForm() {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
personality: '',
|
||||
sampleFile: undefined,
|
||||
referenceText: '',
|
||||
avatarFile: undefined,
|
||||
@@ -331,6 +334,7 @@ export function ProfileForm() {
|
||||
name: editingProfile.name,
|
||||
description: editingProfile.description || '',
|
||||
language: editingProfile.language as LanguageCode,
|
||||
personality: editingProfile.personality || '',
|
||||
sampleFile: undefined,
|
||||
referenceText: undefined,
|
||||
avatarFile: undefined,
|
||||
@@ -344,6 +348,7 @@ export function ProfileForm() {
|
||||
name: profileFormDraft.name,
|
||||
description: profileFormDraft.description,
|
||||
language: profileFormDraft.language as LanguageCode,
|
||||
personality: profileFormDraft.personality || '',
|
||||
referenceText: profileFormDraft.referenceText,
|
||||
sampleFile: undefined,
|
||||
avatarFile: undefined,
|
||||
@@ -368,6 +373,7 @@ export function ProfileForm() {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
personality: '',
|
||||
sampleFile: undefined,
|
||||
referenceText: undefined,
|
||||
avatarFile: undefined,
|
||||
@@ -493,6 +499,7 @@ export function ProfileForm() {
|
||||
description: data.description,
|
||||
language: data.language,
|
||||
default_engine: defaultEngine || undefined,
|
||||
personality: data.personality?.trim() ? data.personality.trim() : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -558,6 +565,7 @@ 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
|
||||
@@ -654,6 +662,7 @@ 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.
|
||||
@@ -756,6 +765,7 @@ export function ProfileForm() {
|
||||
name: values.name || '',
|
||||
description: values.description || '',
|
||||
language: values.language || 'en',
|
||||
personality: values.personality || '',
|
||||
referenceText: values.referenceText || '',
|
||||
sampleMode,
|
||||
};
|
||||
@@ -818,8 +828,10 @@ export function ProfileForm() {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
personality: '',
|
||||
sampleFile: undefined,
|
||||
referenceText: '',
|
||||
avatarFile: undefined,
|
||||
});
|
||||
setSampleMode('record');
|
||||
}}
|
||||
@@ -1182,6 +1194,27 @@ export function ProfileForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<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"
|
||||
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-border 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,14 +3,18 @@ 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:text-accent-foreground',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:border-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',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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]);
|
||||
}
|
||||
@@ -2,15 +2,25 @@ 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'];
|
||||
@@ -21,9 +31,14 @@ i18n
|
||||
.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),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"nav": {
|
||||
"generate": "Generate",
|
||||
"stories": "Stories",
|
||||
"captures": "Captures",
|
||||
"voices": "Voices",
|
||||
"effects": "Effects",
|
||||
"audio": "Audio",
|
||||
@@ -21,6 +22,149 @@
|
||||
"settings": "Settings",
|
||||
"updateBadge": "Update"
|
||||
},
|
||||
"captures": {
|
||||
"title": "Captures",
|
||||
"beta": "Beta",
|
||||
"searchPlaceholder": "Search transcripts…",
|
||||
"snippetEmpty": "(no transcript)",
|
||||
"noTranscriptError": "Capture has no transcript yet",
|
||||
"captureCardLabel": "Capture · {{when}}",
|
||||
"header": {
|
||||
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
|
||||
},
|
||||
"source": {
|
||||
"dictation": "Dictation",
|
||||
"recording": "Recording",
|
||||
"file": "File"
|
||||
},
|
||||
"transcript": {
|
||||
"refined": "Refined",
|
||||
"raw": "Raw",
|
||||
"refinedHint": "Refined with Qwen3 · {{model}}",
|
||||
"rawHint": "Transcribed with Whisper {{model}}"
|
||||
},
|
||||
"actions": {
|
||||
"configure": "Configure",
|
||||
"import": "Import",
|
||||
"importing": "Uploading…",
|
||||
"dictate": "Dictate",
|
||||
"stop": "Stop",
|
||||
"copy": "Copy",
|
||||
"refine": "Refine",
|
||||
"reRefine": "Re-refine",
|
||||
"export": "Export",
|
||||
"exportDropdownLabel": "Export capture as",
|
||||
"exportAudio": "Audio (WAV)",
|
||||
"exportTranscript": "Transcript (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "Delete",
|
||||
"playAs": "Play as {{name}}",
|
||||
"playAsFallback": "Play as…",
|
||||
"playAsGenerating": "Generating…",
|
||||
"playAsStop": "Stop · {{name}}",
|
||||
"playAsStopFallback": "Stop · Voice",
|
||||
"playAsDropdownLabel": "Play transcript as"
|
||||
},
|
||||
"empty": {
|
||||
"noMatches": "No captures match \"{{query}}\"",
|
||||
"none": "No captures yet.",
|
||||
"loading": "Loading captures…",
|
||||
"pickOne": "Pick a capture to see the transcript.",
|
||||
"holdToRecord": "Hold to record",
|
||||
"toggleHandsFree": "Toggle hands-free",
|
||||
"pressShortcut": "Press the shortcut anywhere on your machine to start your first capture.",
|
||||
"turnOnShortcut": "Turn on the global shortcut to dictate from anywhere — or click Dictate above for an in-app capture.",
|
||||
"openSettings": "Open Captures settings"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete capture",
|
||||
"description": "This will permanently delete the capture, its audio, and its transcript. This cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "Delete failed",
|
||||
"playAsFailed": "Play-as failed",
|
||||
"noVoice": "No voice profile",
|
||||
"noVoiceDescription": "Create a voice profile before using Play as.",
|
||||
"transcriptCopied": "Transcript copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"exportSuccess": "Exported to {{path}}",
|
||||
"exportFailed": "Export failed",
|
||||
"exportEmpty": "Nothing to export",
|
||||
"shortcutNotArmed": "Shortcut on, but not yet armed",
|
||||
"shortcutNotArmedDescription_one": "{{names}} still needs to download. Open the Captures tab to start.",
|
||||
"shortcutNotArmedDescription_other": "{{names}} still need to download. Open the Captures tab to start."
|
||||
},
|
||||
"pill": {
|
||||
"recording": "Recording",
|
||||
"transcribing": "Transcribing",
|
||||
"refining": "Refining",
|
||||
"speaking": "Speaking",
|
||||
"completed": "Done",
|
||||
"stopAria": "Stop recording",
|
||||
"errorFallback": "Something went wrong",
|
||||
"errorCopyTooltip": "Click to copy error"
|
||||
},
|
||||
"chord": {
|
||||
"capturing": "Capturing…",
|
||||
"pressShortcut": "Press your shortcut",
|
||||
"noKeys": "No keys yet",
|
||||
"unsupported": "\"{{key}}\" isn't supported in chords. Try a modifier or letter key.",
|
||||
"notSet": "Not set"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "A few things before you can dictate",
|
||||
"subheading": "The shortcut stays off until everything below is ready.",
|
||||
"downloadButton": "Download",
|
||||
"downloading": "Downloading…",
|
||||
"downloadingPercent": "Downloading… {{pct}}%",
|
||||
"downloadStarted": "Download started",
|
||||
"downloadStartedDescription": "{{name}} is downloading. The shortcut will arm itself when it finishes.",
|
||||
"downloadFailed": "Download failed",
|
||||
"stt": {
|
||||
"label": "{{name}} (speech-to-text)",
|
||||
"ready": "Model downloaded.",
|
||||
"missing": "Needed to transcribe your audio",
|
||||
"missingWithSize": "Needed to transcribe your audio · {{size}}"
|
||||
},
|
||||
"llm": {
|
||||
"label": "{{name}} (refinement)",
|
||||
"ready": "Model downloaded.",
|
||||
"missing": "Cleans up the raw transcript before paste",
|
||||
"missingWithSize": "Cleans up the raw transcript before paste · {{size}}"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"label": "Input Monitoring permission",
|
||||
"ready": "macOS allows Voicebox to detect your global shortcut.",
|
||||
"missing": "macOS needs to allow Voicebox to detect the global shortcut.",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"accessibility": {
|
||||
"label": "Accessibility permission",
|
||||
"ready": "Voicebox can paste transcriptions into other apps.",
|
||||
"missing": "Required so transcriptions can paste into the focused app.",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"accessibility": {
|
||||
"title": "Grant Accessibility permission to enable auto-paste",
|
||||
"body": "Voicebox needs <path>System Settings → Privacy & Security → Accessibility</path> to paste transcriptions into other apps. Your dictation still lands in the Captures tab without it.",
|
||||
"openSettings": "Open Settings",
|
||||
"recheck": "I've enabled it",
|
||||
"rechecking": "Checking…",
|
||||
"stillMissing": "Still not detected. macOS usually requires quitting and reopening Voicebox after toggling the permission."
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"title": "Grant Input Monitoring to enable the global shortcut",
|
||||
"body": "Voicebox needs <path>System Settings → Privacy & Security → Input Monitoring</path> to detect your dictation chord. The toggle is on, but macOS is blocking key events until you allow it.",
|
||||
"openSettings": "Open Settings",
|
||||
"recheck": "I've enabled it",
|
||||
"rechecking": "Checking…",
|
||||
"stillMissing": "Still not detected. macOS usually requires quitting and reopening Voicebox after toggling the permission."
|
||||
}
|
||||
}
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "Voices",
|
||||
"loading": "Loading voices…",
|
||||
@@ -125,7 +269,10 @@
|
||||
"noPreference": "No preference",
|
||||
"defaultEngineHint": "Auto-selects this engine when the profile is chosen.",
|
||||
"defaultEffects": "Default Effects",
|
||||
"defaultEffectsHint": "Effects applied automatically to all new generations with this voice."
|
||||
"defaultEffectsHint": "Effects applied automatically to all new generations with this voice.",
|
||||
"personalityLabel": "Personality",
|
||||
"personalityPlaceholder": "e.g. \"a grumpy pirate who only speaks in nautical metaphors\"",
|
||||
"personalityHint": "Who this voice is and how they talk. Drives the Compose button and the in-character rewrite toggle on the generate page. Leave blank to hide both."
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "Avatar preview"
|
||||
@@ -415,9 +562,11 @@
|
||||
"title": "Stories",
|
||||
"newStory": "New Story",
|
||||
"loading": "Loading stories…",
|
||||
"searchPlaceholder": "Search stories…",
|
||||
"empty": {
|
||||
"title": "No stories yet",
|
||||
"hint": "Create your first story to get started"
|
||||
"hint": "Create your first story to get started",
|
||||
"noMatches": "No stories match \"{{query}}\""
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} item",
|
||||
@@ -480,16 +629,23 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "Play from here",
|
||||
"regenerate": "Regenerate",
|
||||
"removeFromStory": "Remove from Story"
|
||||
},
|
||||
"importAudio": "Import audio…",
|
||||
"importing": "Importing…",
|
||||
"dropToImport": "Drop audio to import",
|
||||
"toast": {
|
||||
"removeFailed": "Failed to remove item",
|
||||
"reorderFailed": "Failed to reorder items",
|
||||
"exportFailed": "Failed to export audio",
|
||||
"addFailed": "Failed to add generation"
|
||||
"addFailed": "Failed to add generation",
|
||||
"regenerateFailed": "Failed to regenerate",
|
||||
"importFailed": "Failed to import audio"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "No voice generations, yet…",
|
||||
"actions": {
|
||||
"menu": "Actions",
|
||||
"play": "Play",
|
||||
@@ -550,6 +706,18 @@
|
||||
"effects": {
|
||||
"none": "No effects",
|
||||
"profileDefault": "Profile default"
|
||||
},
|
||||
"compose": {
|
||||
"tooltip": "Compose",
|
||||
"ariaLabel": "Compose a line in character",
|
||||
"failedTitle": "Compose failed",
|
||||
"failedDescription": "Could not generate text from this personality."
|
||||
},
|
||||
"persona": {
|
||||
"tooltipActive": "Speaking in character",
|
||||
"tooltipInactive": "Speak in character",
|
||||
"ariaLabelActive": "Speaking in character",
|
||||
"ariaLabelInactive": "Speak in character"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -571,6 +739,8 @@
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"generation": "Generation",
|
||||
"captures": "Captures",
|
||||
"mcp": "MCP",
|
||||
"gpu": "GPU",
|
||||
"logs": "Logs",
|
||||
"changelog": "Changelog",
|
||||
@@ -580,9 +750,23 @@
|
||||
"label": "Language",
|
||||
"description": "Choose the display language for Voicebox."
|
||||
},
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"description": "Match your system, or pick a fixed light or dark appearance.",
|
||||
"options": {
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "Read the Docs" },
|
||||
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
|
||||
"docs": {
|
||||
"title": "Read the Docs"
|
||||
},
|
||||
"discord": {
|
||||
"title": "Join the Discord",
|
||||
"subtitle": "Get help & share voices"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "The address of your voicebox backend server.",
|
||||
@@ -676,6 +860,233 @@
|
||||
"title": "Generations folder",
|
||||
"description": "Where generated audio files are stored on disk.",
|
||||
"open": "Open"
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "About voice generation",
|
||||
"aboutBody": "Clone a voice from a short sample, then generate speech in any voice across any language. Ship TTS into AI agents, games, podcasts, or long-form narration.",
|
||||
"differencesTitle": "What's different",
|
||||
"clone": {
|
||||
"title": "Clone any voice in seconds.",
|
||||
"body": "A few seconds of reference audio is enough. Multi-sample support for higher quality when you want it."
|
||||
},
|
||||
"engines": {
|
||||
"title": "Seven engines, 23 languages.",
|
||||
"body": "Pick the tradeoff that fits — quality, speed, or multilingual coverage."
|
||||
},
|
||||
"agentReady": {
|
||||
"title": "Agent-ready.",
|
||||
"body": "REST API with per-profile control — give any AI a voice you've cloned."
|
||||
}
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"dictation": {
|
||||
"title": "Dictation",
|
||||
"description": "Capture from anywhere on your machine with a global shortcut.",
|
||||
"globalShortcut": {
|
||||
"title": "Global shortcut",
|
||||
"description": "Hold the shortcut to record from anywhere on your machine. Release to transcribe."
|
||||
},
|
||||
"pushToTalk": {
|
||||
"title": "Push-to-talk shortcut",
|
||||
"description": "Hold these keys anywhere on your system to record. Release to stop and transcribe.",
|
||||
"change": "Change"
|
||||
},
|
||||
"toggle": {
|
||||
"title": "Toggle shortcut",
|
||||
"description": "Press once to start a hands-free recording. Press again to stop. Usually push-to-talk plus Space.",
|
||||
"change": "Change"
|
||||
},
|
||||
"chordPicker": {
|
||||
"pttTitle": "Set push-to-talk shortcut",
|
||||
"pttDescription": "Hold the keys you want to use, then release and click Save. The right-hand modifier badge shows whether a key is the left or right variant.",
|
||||
"toggleTitle": "Set toggle shortcut",
|
||||
"toggleDescription": "Hold the keys you want to use, then release and click Save. Pick something distinct from your push-to-talk chord."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"description": "What appears on screen while you're holding the shortcut."
|
||||
},
|
||||
"copyToClipboard": {
|
||||
"title": "Copy transcript to clipboard",
|
||||
"description": "The cleaned transcript lands on your clipboard when the capture finishes."
|
||||
},
|
||||
"autoPaste": {
|
||||
"title": "Auto-paste into focused text field",
|
||||
"description": "If a text input is focused in another app, paste directly into it. Voicebox saves and restores whatever was on your clipboard."
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
"title": "Transcription",
|
||||
"description": "Pick which speech-to-text model runs on your captures.",
|
||||
"model": {
|
||||
"title": "Transcription model",
|
||||
"description": "Whisper ships with Voicebox and runs entirely on your machine.",
|
||||
"base": "Whisper Base · 74M · {{tail}}",
|
||||
"small": "Whisper Small · 244M · {{tail}}",
|
||||
"medium": "Whisper Medium · 769M · {{tail}}",
|
||||
"large": "Whisper Large · 1.5B · {{tail}}",
|
||||
"turbo": "Whisper Turbo · Pruned Large v3 · {{tail}}",
|
||||
"tail": {
|
||||
"fast": "Fast",
|
||||
"balanced": "Balanced",
|
||||
"higher": "Higher accuracy",
|
||||
"best": "Best accuracy",
|
||||
"nearBest": "Near-best, fast"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "Auto-detect works for most captures. Lock it if you're always speaking the same language.",
|
||||
"auto": "Auto-detect",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"de": "German",
|
||||
"ja": "Japanese",
|
||||
"zh": "Chinese",
|
||||
"hi": "Hindi"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archive audio",
|
||||
"description": "Keep the original recording alongside every transcript."
|
||||
}
|
||||
},
|
||||
"refinement": {
|
||||
"title": "Refinement",
|
||||
"description": "Optionally run a local LLM over transcripts to clean filler words, punctuation, and self-corrections.",
|
||||
"auto": {
|
||||
"title": "Refine transcripts automatically",
|
||||
"description": "Runs after every capture. You can still toggle between raw and refined in the Captures tab."
|
||||
},
|
||||
"model": {
|
||||
"title": "Refinement model",
|
||||
"description": "Larger models are slower but handle subtle self-corrections and technical vocabulary better.",
|
||||
"size06": "Qwen3 · 0.6B · 400 MB · {{tail}}",
|
||||
"size17": "Qwen3 · 1.7B · 1.1 GB · {{tail}}",
|
||||
"size40": "Qwen3 · 4B · 2.5 GB · {{tail}}",
|
||||
"tail": {
|
||||
"veryFast": "Very fast",
|
||||
"fast": "Fast",
|
||||
"fullQuality": "Full quality"
|
||||
}
|
||||
},
|
||||
"smartCleanup": {
|
||||
"title": "Smart cleanup",
|
||||
"description": "Remove filler words (um, uh, like), restore punctuation, and fix capitalization without rephrasing."
|
||||
},
|
||||
"selfCorrection": {
|
||||
"title": "Remove self-corrections",
|
||||
"description": "When you change your mind mid-sentence (\"actually, no...\", \"wait, I meant...\"), drop the retracted part and keep the final intent."
|
||||
},
|
||||
"preserveTechnical": {
|
||||
"title": "Preserve technical terms",
|
||||
"description": "Keep code identifiers, command names, and acronyms exactly as spoken. Turn on when you dictate into a code prompt."
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"title": "Playback",
|
||||
"description": "Default voice for the \"Play as\" action in the Captures tab.",
|
||||
"defaultVoice": {
|
||||
"title": "Default voice",
|
||||
"description": "Used when you click Play as without picking a voice first. You can change it per capture.",
|
||||
"noClonedVoices": "No cloned voices yet",
|
||||
"noneSelected": "None selected",
|
||||
"clonedVoices": "Cloned voices"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"description": "Captures are saved as paired audio and transcript files in your Voicebox data directory.",
|
||||
"retention": {
|
||||
"title": "Retention",
|
||||
"description": "How long to keep captures. Applies to both audio and transcripts.",
|
||||
"forever": "Keep forever",
|
||||
"d90": "90 days",
|
||||
"d30": "30 days",
|
||||
"d7": "7 days"
|
||||
},
|
||||
"folder": {
|
||||
"title": "Captures folder",
|
||||
"description": "Where capture audio and transcripts are stored on disk.",
|
||||
"open": "Open"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "About Captures",
|
||||
"aboutBody": "Hold a shortcut anywhere on your machine, speak, and Voicebox turns your voice into text. Replay it in any cloned voice, paste it into any app, or pipe it into your coding agent.",
|
||||
"differencesTitle": "What's different",
|
||||
"local": {
|
||||
"title": "Fully local.",
|
||||
"body": "Whisper and the refinement LLM run on your hardware. No cloud, no accounts, your voice never leaves the machine."
|
||||
},
|
||||
"playAs": {
|
||||
"title": "Play as any voice.",
|
||||
"body": "Transcripts can be read back in any profile you've cloned."
|
||||
},
|
||||
"crossPlatform": {
|
||||
"title": "Cross-platform.",
|
||||
"body": "Same shortcut, same flow on macOS, Windows, and Linux."
|
||||
},
|
||||
"windowsCaveat": {
|
||||
"title": "Heads-up on Windows",
|
||||
"body": "The shortcut won't fire while Voicebox itself or any app running as administrator is focused. Working on it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"install": {
|
||||
"title": "Install into your agent",
|
||||
"description": "Voicebox exposes a local MCP server whenever the app is open. Paste one of these snippets into your agent's MCP config.",
|
||||
"http": {
|
||||
"title": "HTTP (recommended)",
|
||||
"description": "For clients that speak HTTP MCP — Claude Code, Cursor, Windsurf, VS Code."
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code one-liner",
|
||||
"description": "Registers via the Claude Code CLI."
|
||||
},
|
||||
"stdio": {
|
||||
"title": "Stdio (fallback)",
|
||||
"description": "For clients that only spawn stdio processes. The shim binary ships with the app."
|
||||
},
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"defaultVoice": {
|
||||
"title": "Default voice",
|
||||
"description": "Used when an agent calls voicebox.speak without a specific profile and has no per-client binding.",
|
||||
"label": "Default playback voice",
|
||||
"labelHint": "Shared with the Captures-tab 'Play as voice' dropdown — one default voice for passive playback.",
|
||||
"none": "(none)"
|
||||
},
|
||||
"bindings": {
|
||||
"title": "Per-agent voice",
|
||||
"description": "Bind specific agents to specific voices so you can tell who's speaking without looking. The agent identifies itself by the X-Voicebox-Client-Id header (or VOICEBOX_CLIENT_ID env for stdio).",
|
||||
"empty": "No bindings yet. Add one below, then configure your MCP client to send the matching <code>X-Voicebox-Client-Id</code>.",
|
||||
"lastSeen": "last seen {{when}}",
|
||||
"lastSeenTitle": "Last seen {{when}}",
|
||||
"neverConnected": "never connected",
|
||||
"defaultOption": "(default)",
|
||||
"removeAria": "Remove binding for {{client}}",
|
||||
"add": {
|
||||
"title": "Add a binding",
|
||||
"clientIdPlaceholder": "client id (e.g. claude-code)",
|
||||
"labelPlaceholder": "label (optional)",
|
||||
"action": "Add binding"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "About MCP",
|
||||
"aboutBody": "Model Context Protocol lets your AI coding agent — Claude Code, Cursor, Windsurf — call Voicebox tools. Speak in a cloned voice, transcribe audio, browse captures.",
|
||||
"toolsTitle": "Available tools",
|
||||
"tools": {
|
||||
"speak": "Speak text in a voice profile.",
|
||||
"transcribe": "Whisper STT on a clip.",
|
||||
"listCaptures": "Recent dictations / recordings.",
|
||||
"listProfiles": "Available voice profiles."
|
||||
},
|
||||
"postSpeak": "Also exposed as <code>POST /speak</code> for shell scripts, ACP, A2A."
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
@@ -685,11 +1096,15 @@
|
||||
"active": "Active",
|
||||
"cuda": {
|
||||
"title": "CUDA Backend",
|
||||
"activeTitle": "CUDA Backend Active",
|
||||
"description": "NVIDIA GPU acceleration via a downloadable CUDA backend.",
|
||||
"downloading": "Downloading CUDA backend…",
|
||||
"downloadingShort": "Downloading…",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"activeBackend": {
|
||||
"description": "GPU acceleration is currently enabled."
|
||||
},
|
||||
"restart": {
|
||||
"ready": "Server restarted successfully",
|
||||
"waiting": "Restarting server…",
|
||||
@@ -707,10 +1122,9 @@
|
||||
},
|
||||
"switchToCpu": {
|
||||
"title": "Switch to CPU backend",
|
||||
"description": "Disable GPU acceleration. You can re-download CUDA later.",
|
||||
"description": "Disable GPU acceleration. You can re-download the GPU backend later.",
|
||||
"button": "Switch"
|
||||
},
|
||||
"remove": {
|
||||
}, "remove": {
|
||||
"title": "Remove CUDA backend",
|
||||
"description": "Delete the downloaded CUDA binary to free disk space.",
|
||||
"button": "Remove"
|
||||
@@ -720,9 +1134,33 @@
|
||||
"downloadStart": "Failed to start download",
|
||||
"restartFailed": "Restart failed",
|
||||
"switchCpu": "Failed to switch to CPU",
|
||||
"deleteCuda": "Failed to delete CUDA backend"
|
||||
"deleteCuda": "Failed to delete CUDA backend",
|
||||
"deleteRocm": "Failed to delete ROCm backend"
|
||||
},
|
||||
"footer": "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."
|
||||
"footer": "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, you can download optional CUDA (NVIDIA) or ROCm (AMD) backends for hardware-accelerated inference. 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.",
|
||||
"rocm": {
|
||||
"title": "AMD ROCm Backend",
|
||||
"activeTitle": "ROCm Backend Active",
|
||||
"description": "AMD GPU acceleration via a downloadable ROCm backend.",
|
||||
"downloading": "Downloading ROCm backend…",
|
||||
"downloadingShort": "Downloading…",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"downloadRocm": {
|
||||
"title": "Download AMD ROCm backend",
|
||||
"description": "~2-3 GB download. Requires an AMD Radeon GPU with ROCm support.",
|
||||
"button": "Download"
|
||||
},
|
||||
"switchToRocm": {
|
||||
"title": "Switch to ROCm backend",
|
||||
"description": "ROCm backend is downloaded and ready. Restart to enable.",
|
||||
"button": "Restart"
|
||||
},
|
||||
"removeRocm": {
|
||||
"title": "Remove ROCm backend",
|
||||
"description": "Delete the downloaded ROCm binary to free disk space.",
|
||||
"button": "Remove"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Server Logs",
|
||||
@@ -752,7 +1190,8 @@
|
||||
"unknownSize": "Unknown size",
|
||||
"sections": {
|
||||
"voiceGeneration": "Voice Generation",
|
||||
"transcription": "Transcription"
|
||||
"transcription": "Transcription",
|
||||
"languageModels": "Language Models"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "Loaded"
|
||||
|
||||
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
@@ -14,6 +14,7 @@
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "ストーリー",
|
||||
"captures": "キャプチャ",
|
||||
"voices": "ボイス",
|
||||
"effects": "エフェクト",
|
||||
"audio": "オーディオ",
|
||||
@@ -21,6 +22,149 @@
|
||||
"settings": "設定",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"captures": {
|
||||
"title": "キャプチャ",
|
||||
"beta": "ベータ",
|
||||
"searchPlaceholder": "文字起こしを検索…",
|
||||
"snippetEmpty": "(文字起こしなし)",
|
||||
"noTranscriptError": "このキャプチャにはまだ文字起こしがありません",
|
||||
"captureCardLabel": "キャプチャ · {{when}}",
|
||||
"header": {
|
||||
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
|
||||
},
|
||||
"source": {
|
||||
"dictation": "ディクテーション",
|
||||
"recording": "録音",
|
||||
"file": "ファイル"
|
||||
},
|
||||
"transcript": {
|
||||
"refined": "整形済み",
|
||||
"raw": "生テキスト",
|
||||
"refinedHint": "Qwen3 · {{model}} で整形",
|
||||
"rawHint": "Whisper {{model}} で文字起こし"
|
||||
},
|
||||
"actions": {
|
||||
"configure": "設定",
|
||||
"import": "インポート",
|
||||
"importing": "アップロード中…",
|
||||
"dictate": "ディクテーション",
|
||||
"stop": "停止",
|
||||
"copy": "コピー",
|
||||
"refine": "整形",
|
||||
"reRefine": "再整形",
|
||||
"export": "エクスポート",
|
||||
"exportDropdownLabel": "形式を選択",
|
||||
"exportAudio": "音声 (WAV)",
|
||||
"exportTranscript": "文字起こし (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "削除",
|
||||
"playAs": "{{name}} で再生",
|
||||
"playAsFallback": "ボイスで再生…",
|
||||
"playAsGenerating": "生成中…",
|
||||
"playAsStop": "停止 · {{name}}",
|
||||
"playAsStopFallback": "停止 · ボイス",
|
||||
"playAsDropdownLabel": "文字起こしを次のボイスで再生"
|
||||
},
|
||||
"empty": {
|
||||
"noMatches": "「{{query}}」に一致するキャプチャはありません",
|
||||
"none": "キャプチャはまだありません。",
|
||||
"loading": "キャプチャを読み込み中…",
|
||||
"pickOne": "キャプチャを選択して文字起こしを表示します。",
|
||||
"holdToRecord": "押し続けて録音",
|
||||
"toggleHandsFree": "ハンズフリーを切り替え",
|
||||
"pressShortcut": "マシン上のどこからでもショートカットを押すと、最初のキャプチャを開始できます。",
|
||||
"turnOnShortcut": "グローバルショートカットを有効にしてどこからでもディクテーション — または上の「ディクテーション」をクリックしてアプリ内でキャプチャします。",
|
||||
"openSettings": "キャプチャ設定を開く"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "キャプチャを削除",
|
||||
"description": "このキャプチャと、その音声・文字起こしを完全に削除します。元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"playAsFailed": "ボイスでの再生に失敗しました",
|
||||
"noVoice": "ボイスプロファイルがありません",
|
||||
"noVoiceDescription": "「ボイスで再生」を使う前にボイスプロファイルを作成してください。",
|
||||
"transcriptCopied": "文字起こしをコピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"exportSuccess": "{{path}} に書き出しました",
|
||||
"exportFailed": "書き出しに失敗しました",
|
||||
"exportEmpty": "書き出す内容がありません",
|
||||
"shortcutNotArmed": "ショートカットは有効ですが、まだ準備が完了していません",
|
||||
"shortcutNotArmedDescription_one": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。"
|
||||
},
|
||||
"pill": {
|
||||
"recording": "録音中",
|
||||
"transcribing": "文字起こし中",
|
||||
"refining": "整形中",
|
||||
"speaking": "発話中",
|
||||
"completed": "完了",
|
||||
"stopAria": "録音を停止",
|
||||
"errorFallback": "問題が発生しました",
|
||||
"errorCopyTooltip": "クリックでエラーをコピー"
|
||||
},
|
||||
"chord": {
|
||||
"capturing": "取得中…",
|
||||
"pressShortcut": "ショートカットを押してください",
|
||||
"noKeys": "まだキーがありません",
|
||||
"unsupported": "「{{key}}」はコードに対応していません。修飾キーまたは文字キーを試してください。",
|
||||
"notSet": "未設定"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "ディクテーションを使う前にいくつか準備があります",
|
||||
"subheading": "下の項目がすべて整うまでショートカットは無効のままです。",
|
||||
"downloadButton": "ダウンロード",
|
||||
"downloading": "ダウンロード中…",
|
||||
"downloadingPercent": "ダウンロード中… {{pct}}%",
|
||||
"downloadStarted": "ダウンロードを開始しました",
|
||||
"downloadStartedDescription": "{{name}} をダウンロード中です。完了するとショートカットが自動的に有効になります。",
|
||||
"downloadFailed": "ダウンロードに失敗しました",
|
||||
"stt": {
|
||||
"label": "{{name}}(音声認識)",
|
||||
"ready": "モデルをダウンロード済みです。",
|
||||
"missing": "音声を文字起こしするために必要です",
|
||||
"missingWithSize": "音声を文字起こしするために必要です · {{size}}"
|
||||
},
|
||||
"llm": {
|
||||
"label": "{{name}}(整形)",
|
||||
"ready": "モデルをダウンロード済みです。",
|
||||
"missing": "貼り付け前に生の文字起こしを整形します",
|
||||
"missingWithSize": "貼り付け前に生の文字起こしを整形します · {{size}}"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"label": "入力監視の権限",
|
||||
"ready": "macOS が Voicebox にグローバルショートカットの検出を許可しています。",
|
||||
"missing": "macOS で Voicebox にグローバルショートカットの検出を許可する必要があります。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"accessibility": {
|
||||
"label": "アクセシビリティの権限",
|
||||
"ready": "Voicebox が他のアプリに文字起こしを貼り付けできます。",
|
||||
"missing": "フォーカス中のアプリに文字起こしを貼り付けるために必要です。",
|
||||
"openSettings": "設定を開く"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"accessibility": {
|
||||
"title": "自動貼り付けを有効にするためアクセシビリティの権限を付与してください",
|
||||
"body": "他のアプリに文字起こしを貼り付けるには、Voicebox に <path>「システム設定」→「プライバシーとセキュリティ」→「アクセシビリティ」</path> の許可が必要です。許可がなくてもディクテーションはキャプチャタブに保存されます。",
|
||||
"openSettings": "設定を開く",
|
||||
"recheck": "有効にしました",
|
||||
"rechecking": "確認中…",
|
||||
"stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"title": "グローバルショートカットを有効にするため入力監視の権限を付与してください",
|
||||
"body": "ディクテーションのコードを検出するには、Voicebox に <path>「システム設定」→「プライバシーとセキュリティ」→「入力監視」</path> の許可が必要です。トグルは有効ですが、許可されるまで macOS がキーイベントをブロックしています。",
|
||||
"openSettings": "設定を開く",
|
||||
"recheck": "有効にしました",
|
||||
"rechecking": "確認中…",
|
||||
"stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "ボイス",
|
||||
"loading": "ボイスを読み込み中…",
|
||||
@@ -125,7 +269,10 @@
|
||||
"noPreference": "指定なし",
|
||||
"defaultEngineHint": "このプロファイルが選ばれたとき、このエンジンを自動で選択します。",
|
||||
"defaultEffects": "デフォルトエフェクト",
|
||||
"defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。"
|
||||
"defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。",
|
||||
"personalityLabel": "パーソナリティ",
|
||||
"personalityPlaceholder": "例:「航海の比喩でしか話さない不機嫌な海賊」",
|
||||
"personalityHint": "このボイスがどんな人物で、どのように話すか。生成ページの「Compose」ボタンとキャラクター書き換えトグルに反映されます。空欄にすると両方とも表示されません。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "アバタープレビュー"
|
||||
@@ -415,9 +562,11 @@
|
||||
"title": "ストーリー",
|
||||
"newStory": "新しいストーリー",
|
||||
"loading": "ストーリーを読み込み中…",
|
||||
"searchPlaceholder": "ストーリーを検索…",
|
||||
"empty": {
|
||||
"title": "ストーリーがまだありません",
|
||||
"hint": "最初のストーリーを作成して始めましょう"
|
||||
"hint": "最初のストーリーを作成して始めましょう",
|
||||
"noMatches": "「{{query}}」に一致するストーリーはありません"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項目",
|
||||
@@ -480,16 +629,23 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "ここから再生",
|
||||
"regenerate": "再生成",
|
||||
"removeFromStory": "ストーリーから削除"
|
||||
},
|
||||
"importAudio": "オーディオをインポート…",
|
||||
"importing": "インポート中…",
|
||||
"dropToImport": "ドロップしてオーディオをインポート",
|
||||
"toast": {
|
||||
"removeFailed": "項目の削除に失敗しました",
|
||||
"reorderFailed": "項目の並び替えに失敗しました",
|
||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||
"addFailed": "生成の追加に失敗しました"
|
||||
"addFailed": "生成の追加に失敗しました",
|
||||
"regenerateFailed": "再生成に失敗しました",
|
||||
"importFailed": "オーディオのインポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "音声生成はまだありません…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "再生",
|
||||
@@ -550,6 +706,18 @@
|
||||
"effects": {
|
||||
"none": "エフェクトなし",
|
||||
"profileDefault": "プロファイルのデフォルト"
|
||||
},
|
||||
"compose": {
|
||||
"tooltip": "Compose",
|
||||
"ariaLabel": "キャラクターになりきって一文を生成",
|
||||
"failedTitle": "Compose に失敗しました",
|
||||
"failedDescription": "このパーソナリティからテキストを生成できませんでした。"
|
||||
},
|
||||
"persona": {
|
||||
"tooltipActive": "キャラクターとして発話中",
|
||||
"tooltipInactive": "キャラクターとして発話",
|
||||
"ariaLabelActive": "キャラクターとして発話中",
|
||||
"ariaLabelInactive": "キャラクターとして発話"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -571,6 +739,8 @@
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
"generation": "生成",
|
||||
"captures": "キャプチャ",
|
||||
"mcp": "MCP",
|
||||
"gpu": "GPU",
|
||||
"logs": "ログ",
|
||||
"changelog": "変更履歴",
|
||||
@@ -580,6 +750,15 @@
|
||||
"label": "言語",
|
||||
"description": "Voicebox の表示言語を選択します。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "テーマ",
|
||||
"description": "システム設定に合わせるか、ライト / ダークを固定します。",
|
||||
"options": {
|
||||
"system": "システム",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "ドキュメントを読む" },
|
||||
"discord": { "title": "Discord に参加", "subtitle": "ヘルプやボイスの共有" },
|
||||
@@ -676,6 +855,233 @@
|
||||
"title": "生成物の保存先フォルダ",
|
||||
"description": "生成されたオーディオファイルをディスク上に保存する場所。",
|
||||
"open": "開く"
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "音声生成について",
|
||||
"aboutBody": "短いサンプルからボイスをクローンし、あらゆる言語のあらゆるボイスで音声を生成できます。TTS を AI エージェント、ゲーム、ポッドキャスト、長尺ナレーションに組み込めます。",
|
||||
"differencesTitle": "ここが違います",
|
||||
"clone": {
|
||||
"title": "数秒でどのボイスでもクローン。",
|
||||
"body": "数秒のリファレンス音声があれば十分です。より高い品質を求めるときは複数サンプルにも対応します。"
|
||||
},
|
||||
"engines": {
|
||||
"title": "7 つのエンジン、23 言語。",
|
||||
"body": "品質、速度、多言語対応 — 用途に合ったトレードオフを選べます。"
|
||||
},
|
||||
"agentReady": {
|
||||
"title": "エージェント対応。",
|
||||
"body": "プロファイル単位で制御できる REST API — クローンしたボイスをどの AI にも渡せます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"dictation": {
|
||||
"title": "ディクテーション",
|
||||
"description": "グローバルショートカットでマシン上のどこからでもキャプチャできます。",
|
||||
"globalShortcut": {
|
||||
"title": "グローバルショートカット",
|
||||
"description": "ショートカットを押し続けるとマシン上のどこからでも録音できます。離すと文字起こしが行われます。"
|
||||
},
|
||||
"pushToTalk": {
|
||||
"title": "プッシュトゥトーク用ショートカット",
|
||||
"description": "システム上のどこからでもこれらのキーを押し続けると録音します。離すと録音を停止し、文字起こしが行われます。",
|
||||
"change": "変更"
|
||||
},
|
||||
"toggle": {
|
||||
"title": "トグル用ショートカット",
|
||||
"description": "一度押すとハンズフリー録音を開始します。もう一度押すと停止します。通常はプッシュトゥトーク + Space を使います。",
|
||||
"change": "変更"
|
||||
},
|
||||
"chordPicker": {
|
||||
"pttTitle": "プッシュトゥトーク用ショートカットを設定",
|
||||
"pttDescription": "使いたいキーを押し続け、離してから「保存」をクリックします。右側の修飾キーバッジは、左右どちらの変種かを示します。",
|
||||
"toggleTitle": "トグル用ショートカットを設定",
|
||||
"toggleDescription": "使いたいキーを押し続け、離してから「保存」をクリックします。プッシュトゥトークのコードと区別できるものを選んでください。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "プレビュー",
|
||||
"description": "ショートカットを押している間、画面に表示される内容です。"
|
||||
},
|
||||
"copyToClipboard": {
|
||||
"title": "文字起こしをクリップボードにコピー",
|
||||
"description": "キャプチャが終わると、整形済みの文字起こしがクリップボードに保存されます。"
|
||||
},
|
||||
"autoPaste": {
|
||||
"title": "フォーカス中のテキストフィールドに自動貼り付け",
|
||||
"description": "他のアプリでテキスト入力欄がフォーカスされている場合、直接そこに貼り付けます。Voicebox はクリップボードの内容を一旦保存し、後で復元します。"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
"title": "文字起こし",
|
||||
"description": "キャプチャに使う音声認識モデルを選びます。",
|
||||
"model": {
|
||||
"title": "文字起こしモデル",
|
||||
"description": "Whisper は Voicebox に同梱されており、すべてマシン上で動作します。",
|
||||
"base": "Whisper Base · 74M · {{tail}}",
|
||||
"small": "Whisper Small · 244M · {{tail}}",
|
||||
"medium": "Whisper Medium · 769M · {{tail}}",
|
||||
"large": "Whisper Large · 1.5B · {{tail}}",
|
||||
"turbo": "Whisper Turbo · Pruned Large v3 · {{tail}}",
|
||||
"tail": {
|
||||
"fast": "高速",
|
||||
"balanced": "バランス",
|
||||
"higher": "高精度",
|
||||
"best": "最高精度",
|
||||
"nearBest": "ほぼ最高精度かつ高速"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "言語",
|
||||
"description": "ほとんどのキャプチャでは自動検出が機能します。常に同じ言語で話すなら固定してください。",
|
||||
"auto": "自動検出",
|
||||
"en": "英語",
|
||||
"es": "スペイン語",
|
||||
"fr": "フランス語",
|
||||
"de": "ドイツ語",
|
||||
"ja": "日本語",
|
||||
"zh": "中国語",
|
||||
"hi": "ヒンディー語"
|
||||
},
|
||||
"archive": {
|
||||
"title": "音声をアーカイブ",
|
||||
"description": "文字起こしと一緒に元の録音も保持します。"
|
||||
}
|
||||
},
|
||||
"refinement": {
|
||||
"title": "整形",
|
||||
"description": "ローカル LLM を任意で実行し、フィラー語、句読点、自己修正を文字起こしから整理します。",
|
||||
"auto": {
|
||||
"title": "文字起こしを自動で整形",
|
||||
"description": "キャプチャごとに実行されます。キャプチャタブで生テキストと整形済みを切り替えることもできます。"
|
||||
},
|
||||
"model": {
|
||||
"title": "整形モデル",
|
||||
"description": "大きなモデルは遅くなりますが、微妙な自己修正や専門用語をより適切に処理します。",
|
||||
"size06": "Qwen3 · 0.6B · 400 MB · {{tail}}",
|
||||
"size17": "Qwen3 · 1.7B · 1.1 GB · {{tail}}",
|
||||
"size40": "Qwen3 · 4B · 2.5 GB · {{tail}}",
|
||||
"tail": {
|
||||
"veryFast": "超高速",
|
||||
"fast": "高速",
|
||||
"fullQuality": "高品質"
|
||||
}
|
||||
},
|
||||
"smartCleanup": {
|
||||
"title": "スマートクリーンアップ",
|
||||
"description": "言い回しを変えずに、フィラー語(えーと、あの、みたいな)を削除し、句読点を補い、大文字小文字を整えます。"
|
||||
},
|
||||
"selfCorrection": {
|
||||
"title": "自己修正を削除",
|
||||
"description": "途中で言い直したとき(「やっぱり違う…」「いや、こうじゃなくて…」)、撤回した部分を削除して最終的な意図のみを残します。"
|
||||
},
|
||||
"preserveTechnical": {
|
||||
"title": "専門用語を保持",
|
||||
"description": "コードの識別子、コマンド名、頭字語を発話どおりに保持します。コード入力欄にディクテーションするときに有効にしてください。"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"title": "再生",
|
||||
"description": "キャプチャタブの「ボイスで再生」アクションで使うデフォルトのボイス。",
|
||||
"defaultVoice": {
|
||||
"title": "デフォルトボイス",
|
||||
"description": "ボイスを選ばずに「ボイスで再生」をクリックしたときに使われます。キャプチャごとに変更できます。",
|
||||
"noClonedVoices": "クローンしたボイスはまだありません",
|
||||
"noneSelected": "未選択",
|
||||
"clonedVoices": "クローンしたボイス"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "ストレージ",
|
||||
"description": "キャプチャは Voicebox のデータディレクトリに、音声と文字起こしのペアファイルとして保存されます。",
|
||||
"retention": {
|
||||
"title": "保持期間",
|
||||
"description": "キャプチャを保持する期間です。音声と文字起こしの両方に適用されます。",
|
||||
"forever": "永久に保持",
|
||||
"d90": "90 日",
|
||||
"d30": "30 日",
|
||||
"d7": "7 日"
|
||||
},
|
||||
"folder": {
|
||||
"title": "キャプチャフォルダ",
|
||||
"description": "キャプチャの音声と文字起こしをディスクに保存する場所。",
|
||||
"open": "開く"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "キャプチャについて",
|
||||
"aboutBody": "マシン上のどこからでもショートカットを押し続けて話すと、Voicebox があなたの声をテキストに変換します。クローンしたどのボイスでも再生でき、任意のアプリに貼り付けたり、コーディングエージェントに渡したりできます。",
|
||||
"differencesTitle": "ここが違います",
|
||||
"local": {
|
||||
"title": "完全にローカル。",
|
||||
"body": "Whisper と整形用 LLM はあなたのハードウェア上で動作します。クラウドもアカウントも不要で、声がマシンの外に出ることはありません。"
|
||||
},
|
||||
"playAs": {
|
||||
"title": "どのボイスでも再生。",
|
||||
"body": "クローンしたどのプロファイルでも文字起こしを読み上げできます。"
|
||||
},
|
||||
"crossPlatform": {
|
||||
"title": "クロスプラットフォーム。",
|
||||
"body": "macOS、Windows、Linux で同じショートカットと同じフローを利用できます。"
|
||||
},
|
||||
"windowsCaveat": {
|
||||
"title": "Windows での注意点",
|
||||
"body": "Voicebox 自体や管理者として実行中のアプリにフォーカスがあるあいだは、ショートカットが反応しません。現在対応中です。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"install": {
|
||||
"title": "エージェントにインストール",
|
||||
"description": "アプリが開いている間、Voicebox はローカルで MCP サーバーを公開します。以下のスニペットを、お使いのエージェントの MCP 設定に貼り付けてください。",
|
||||
"http": {
|
||||
"title": "HTTP(推奨)",
|
||||
"description": "HTTP MCP に対応するクライアント向け — Claude Code、Cursor、Windsurf、VS Code。"
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code 用ワンライナー",
|
||||
"description": "Claude Code CLI 経由で登録します。"
|
||||
},
|
||||
"stdio": {
|
||||
"title": "Stdio(フォールバック)",
|
||||
"description": "stdio プロセスのみを起動するクライアント向け。シムバイナリはアプリに同梱されています。"
|
||||
},
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました"
|
||||
},
|
||||
"defaultVoice": {
|
||||
"title": "デフォルトボイス",
|
||||
"description": "エージェントが特定のプロファイルを指定せず、クライアントごとのバインディングもない状態で voicebox.speak を呼び出したときに使われます。",
|
||||
"label": "デフォルトの再生ボイス",
|
||||
"labelHint": "キャプチャタブの「ボイスで再生」ドロップダウンと共有 — パッシブ再生用に 1 つのデフォルトボイスを設定します。",
|
||||
"none": "(なし)"
|
||||
},
|
||||
"bindings": {
|
||||
"title": "エージェントごとのボイス",
|
||||
"description": "特定のエージェントに特定のボイスを割り当てて、見なくても誰が話しているか分かるようにします。エージェントは X-Voicebox-Client-Id ヘッダー(stdio の場合は VOICEBOX_CLIENT_ID 環境変数)で自身を識別します。",
|
||||
"empty": "バインディングはまだありません。下から追加し、対応する <code>X-Voicebox-Client-Id</code> を送信するように MCP クライアントを設定してください。",
|
||||
"lastSeen": "最終接続 {{when}}",
|
||||
"lastSeenTitle": "最終接続 {{when}}",
|
||||
"neverConnected": "未接続",
|
||||
"defaultOption": "(デフォルト)",
|
||||
"removeAria": "{{client}} のバインディングを削除",
|
||||
"add": {
|
||||
"title": "バインディングを追加",
|
||||
"clientIdPlaceholder": "クライアント ID(例:claude-code)",
|
||||
"labelPlaceholder": "ラベル(任意)",
|
||||
"action": "バインディングを追加"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "MCP について",
|
||||
"aboutBody": "Model Context Protocol を使うと、Claude Code、Cursor、Windsurf などの AI コーディングエージェントから Voicebox のツールを呼び出せます。クローンしたボイスで発話したり、音声を文字起こししたり、キャプチャを参照したりできます。",
|
||||
"toolsTitle": "利用可能なツール",
|
||||
"tools": {
|
||||
"speak": "ボイスプロファイルでテキストを発話します。",
|
||||
"transcribe": "クリップに対して Whisper STT を実行します。",
|
||||
"listCaptures": "最近のディクテーション/録音。",
|
||||
"listProfiles": "利用可能なボイスプロファイル。"
|
||||
},
|
||||
"postSpeak": "シェルスクリプト、ACP、A2A 用に <code>POST /speak</code> としても公開されています。"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
@@ -752,7 +1158,8 @@
|
||||
"unknownSize": "サイズ不明",
|
||||
"sections": {
|
||||
"voiceGeneration": "音声生成",
|
||||
"transcription": "文字起こし"
|
||||
"transcription": "文字起こし",
|
||||
"languageModels": "言語モデル"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "読み込み済み"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "故事",
|
||||
"captures": "捕获",
|
||||
"voices": "声音",
|
||||
"effects": "效果",
|
||||
"audio": "音频",
|
||||
@@ -21,6 +22,149 @@
|
||||
"settings": "设置",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"captures": {
|
||||
"title": "捕获",
|
||||
"beta": "Beta",
|
||||
"searchPlaceholder": "搜索转录文本……",
|
||||
"snippetEmpty": "(暂无转录)",
|
||||
"noTranscriptError": "此次捕获尚无转录文本",
|
||||
"captureCardLabel": "捕获 · {{when}}",
|
||||
"header": {
|
||||
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
|
||||
},
|
||||
"source": {
|
||||
"dictation": "听写",
|
||||
"recording": "录制",
|
||||
"file": "文件"
|
||||
},
|
||||
"transcript": {
|
||||
"refined": "精修",
|
||||
"raw": "原始",
|
||||
"refinedHint": "由 Qwen3 · {{model}} 精修",
|
||||
"rawHint": "由 Whisper {{model}} 转录"
|
||||
},
|
||||
"actions": {
|
||||
"configure": "配置",
|
||||
"import": "导入",
|
||||
"importing": "上传中…",
|
||||
"dictate": "听写",
|
||||
"stop": "停止",
|
||||
"copy": "复制",
|
||||
"refine": "精修",
|
||||
"reRefine": "重新精修",
|
||||
"export": "导出",
|
||||
"exportDropdownLabel": "导出格式",
|
||||
"exportAudio": "音频 (WAV)",
|
||||
"exportTranscript": "文字稿 (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "删除",
|
||||
"playAs": "以 {{name}} 播放",
|
||||
"playAsFallback": "播放为……",
|
||||
"playAsGenerating": "生成中…",
|
||||
"playAsStop": "停止 · {{name}}",
|
||||
"playAsStopFallback": "停止 · 声音",
|
||||
"playAsDropdownLabel": "将转录播放为"
|
||||
},
|
||||
"empty": {
|
||||
"noMatches": "没有捕获匹配 \"{{query}}\"",
|
||||
"none": "暂无捕获。",
|
||||
"loading": "加载捕获中…",
|
||||
"pickOne": "选择一项捕获以查看转录。",
|
||||
"holdToRecord": "按住以录制",
|
||||
"toggleHandsFree": "切换免提模式",
|
||||
"pressShortcut": "在系统的任何位置按下快捷键以开始第一次捕获。",
|
||||
"turnOnShortcut": "开启全局快捷键以在任何位置进行听写——或点击上方的「听写」在应用内进行捕获。",
|
||||
"openSettings": "打开「捕获」设置"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除捕获",
|
||||
"description": "这将永久删除该捕获及其音频和转录。此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "删除失败",
|
||||
"playAsFailed": "播放失败",
|
||||
"noVoice": "暂无声音档案",
|
||||
"noVoiceDescription": "使用「播放为」之前请先创建声音档案。",
|
||||
"transcriptCopied": "转录已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"exportSuccess": "已导出到 {{path}}",
|
||||
"exportFailed": "导出失败",
|
||||
"exportEmpty": "无可导出的内容",
|
||||
"shortcutNotArmed": "快捷键已开启,但尚未就绪",
|
||||
"shortcutNotArmedDescription_one": "{{names}} 仍需下载。打开「捕获」标签页开始下载。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} 仍需下载。打开「捕获」标签页开始下载。"
|
||||
},
|
||||
"pill": {
|
||||
"recording": "录制中",
|
||||
"transcribing": "转录中",
|
||||
"refining": "精修中",
|
||||
"speaking": "朗读中",
|
||||
"completed": "完成",
|
||||
"stopAria": "停止录制",
|
||||
"errorFallback": "出现了错误",
|
||||
"errorCopyTooltip": "点击以复制错误"
|
||||
},
|
||||
"chord": {
|
||||
"capturing": "捕获中…",
|
||||
"pressShortcut": "按下您的快捷键",
|
||||
"noKeys": "尚无按键",
|
||||
"unsupported": "「{{key}}」不支持用于组合键。请尝试修饰键或字母键。",
|
||||
"notSet": "未设置"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "听写前还需准备几项",
|
||||
"subheading": "在以下所有项目就绪之前,快捷键将保持关闭。",
|
||||
"downloadButton": "下载",
|
||||
"downloading": "下载中…",
|
||||
"downloadingPercent": "下载中… {{pct}}%",
|
||||
"downloadStarted": "下载已开始",
|
||||
"downloadStartedDescription": "{{name}} 正在下载。下载完成后快捷键会自动就绪。",
|
||||
"downloadFailed": "下载失败",
|
||||
"stt": {
|
||||
"label": "{{name}}(语音转文本)",
|
||||
"ready": "模型已下载。",
|
||||
"missing": "用于转录您的音频",
|
||||
"missingWithSize": "用于转录您的音频 · {{size}}"
|
||||
},
|
||||
"llm": {
|
||||
"label": "{{name}}(精修)",
|
||||
"ready": "模型已下载。",
|
||||
"missing": "在粘贴前清理原始转录文本",
|
||||
"missingWithSize": "在粘贴前清理原始转录文本 · {{size}}"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"label": "「输入监控」权限",
|
||||
"ready": "macOS 允许 Voicebox 检测您的全局快捷键。",
|
||||
"missing": "macOS 需要允许 Voicebox 检测全局快捷键。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"accessibility": {
|
||||
"label": "「辅助功能」权限",
|
||||
"ready": "Voicebox 可以将转录粘贴到其他应用中。",
|
||||
"missing": "需要此权限,转录才能粘贴到当前焦点应用。",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"accessibility": {
|
||||
"title": "授予「辅助功能」权限以启用自动粘贴",
|
||||
"body": "Voicebox 需要在 <path>系统设置 → 隐私与安全性 → 辅助功能</path> 中获得权限,才能将转录粘贴到其他应用。即使没有此权限,听写仍会保存到「捕获」标签页。",
|
||||
"openSettings": "打开设置",
|
||||
"recheck": "我已启用",
|
||||
"rechecking": "检查中…",
|
||||
"stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"title": "授予「输入监控」权限以启用全局快捷键",
|
||||
"body": "Voicebox 需要在 <path>系统设置 → 隐私与安全性 → 输入监控</path> 中获得权限,才能检测您的听写组合键。开关已开启,但在您允许之前 macOS 会拦截按键事件。",
|
||||
"openSettings": "打开设置",
|
||||
"recheck": "我已启用",
|
||||
"rechecking": "检查中…",
|
||||
"stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "声音",
|
||||
"loading": "加载声音中…",
|
||||
@@ -125,7 +269,10 @@
|
||||
"noPreference": "无偏好",
|
||||
"defaultEngineHint": "选择该档案时自动使用此引擎。",
|
||||
"defaultEffects": "默认效果",
|
||||
"defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。"
|
||||
"defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。",
|
||||
"personalityLabel": "人物设定",
|
||||
"personalityPlaceholder": "例如:「一位脾气暴躁的海盗,只会用航海比喻说话」",
|
||||
"personalityHint": "这个声音是谁、说话方式如何。会驱动生成页面上的「撰写」按钮和入戏改写开关。留空则两者都隐藏。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "头像预览"
|
||||
@@ -415,9 +562,11 @@
|
||||
"title": "故事",
|
||||
"newStory": "新建故事",
|
||||
"loading": "加载故事中…",
|
||||
"searchPlaceholder": "搜索故事…",
|
||||
"empty": {
|
||||
"title": "暂无故事",
|
||||
"hint": "创建您的第一个故事以开始"
|
||||
"hint": "创建您的第一个故事以开始",
|
||||
"noMatches": "没有故事匹配 “{{query}}”"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 项",
|
||||
@@ -480,16 +629,23 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "从此处播放",
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "从故事中移除"
|
||||
},
|
||||
"importAudio": "导入音频…",
|
||||
"importing": "正在导入…",
|
||||
"dropToImport": "拖放以导入音频",
|
||||
"toast": {
|
||||
"removeFailed": "移除项目失败",
|
||||
"reorderFailed": "重新排序项目失败",
|
||||
"exportFailed": "导出音频失败",
|
||||
"addFailed": "添加生成失败"
|
||||
"addFailed": "添加生成失败",
|
||||
"regenerateFailed": "重新生成失败",
|
||||
"importFailed": "导入音频失败"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "暂无语音生成…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
@@ -550,6 +706,18 @@
|
||||
"effects": {
|
||||
"none": "无效果",
|
||||
"profileDefault": "档案默认"
|
||||
},
|
||||
"compose": {
|
||||
"tooltip": "撰写",
|
||||
"ariaLabel": "以人物设定撰写一句台词",
|
||||
"failedTitle": "撰写失败",
|
||||
"failedDescription": "无法根据此人物设定生成文本。"
|
||||
},
|
||||
"persona": {
|
||||
"tooltipActive": "正以人物设定朗读",
|
||||
"tooltipInactive": "以人物设定朗读",
|
||||
"ariaLabelActive": "正以人物设定朗读",
|
||||
"ariaLabelInactive": "以人物设定朗读"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -571,6 +739,8 @@
|
||||
"tabs": {
|
||||
"general": "常规",
|
||||
"generation": "生成",
|
||||
"captures": "捕获",
|
||||
"mcp": "MCP",
|
||||
"gpu": "GPU",
|
||||
"logs": "日志",
|
||||
"changelog": "更新日志",
|
||||
@@ -580,6 +750,15 @@
|
||||
"label": "语言",
|
||||
"description": "选择 Voicebox 的显示语言。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "主题",
|
||||
"description": "跟随系统外观,或固定为浅色 / 深色模式。",
|
||||
"options": {
|
||||
"system": "跟随系统",
|
||||
"light": "浅色",
|
||||
"dark": "深色"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "阅读文档" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" },
|
||||
@@ -676,6 +855,233 @@
|
||||
"title": "生成文件夹",
|
||||
"description": "生成的音频文件在磁盘上的存储位置。",
|
||||
"open": "打开"
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "关于语音生成",
|
||||
"aboutBody": "用一段简短样本克隆声音,然后用任意声音、任意语言生成语音。把 TTS 接入 AI 代理、游戏、播客或长篇旁白。",
|
||||
"differencesTitle": "不同之处",
|
||||
"clone": {
|
||||
"title": "几秒内克隆任意声音。",
|
||||
"body": "几秒钟的参考音频就足够了。需要更高质量时,支持多样本克隆。"
|
||||
},
|
||||
"engines": {
|
||||
"title": "七种引擎,23 种语言。",
|
||||
"body": "选择最合适的取舍——质量、速度,或多语言覆盖。"
|
||||
},
|
||||
"agentReady": {
|
||||
"title": "面向代理。",
|
||||
"body": "REST API 支持按档案控制——给任何 AI 一个您克隆的声音。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"dictation": {
|
||||
"title": "听写",
|
||||
"description": "使用全局快捷键在系统的任何位置进行捕获。",
|
||||
"globalShortcut": {
|
||||
"title": "全局快捷键",
|
||||
"description": "按住快捷键即可在系统的任何位置录制。松开后进行转录。"
|
||||
},
|
||||
"pushToTalk": {
|
||||
"title": "按住说话快捷键",
|
||||
"description": "在系统任何位置按住这些键以录制。松开即可停止并转录。",
|
||||
"change": "更改"
|
||||
},
|
||||
"toggle": {
|
||||
"title": "切换快捷键",
|
||||
"description": "按一次开始免提录制,再按一次停止。通常是按住说话的快捷键加上空格。",
|
||||
"change": "更改"
|
||||
},
|
||||
"chordPicker": {
|
||||
"pttTitle": "设置按住说话快捷键",
|
||||
"pttDescription": "按住您要使用的按键,然后松开并点击「保存」。右侧的修饰键徽章会显示按键是左侧还是右侧的变体。",
|
||||
"toggleTitle": "设置切换快捷键",
|
||||
"toggleDescription": "按住您要使用的按键,然后松开并点击「保存」。请选择与按住说话组合键不同的按键。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "预览",
|
||||
"description": "按住快捷键时屏幕上显示的内容。"
|
||||
},
|
||||
"copyToClipboard": {
|
||||
"title": "将转录复制到剪贴板",
|
||||
"description": "捕获完成后,清理过的转录会出现在剪贴板上。"
|
||||
},
|
||||
"autoPaste": {
|
||||
"title": "自动粘贴到当前焦点的文本字段",
|
||||
"description": "如果其他应用中有焦点输入框,则直接粘贴进去。Voicebox 会保存并恢复您剪贴板原有的内容。"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
"title": "转录",
|
||||
"description": "选择捕获时使用哪个语音转文本模型。",
|
||||
"model": {
|
||||
"title": "转录模型",
|
||||
"description": "Whisper 随 Voicebox 一同发布,完全在您的设备上运行。",
|
||||
"base": "Whisper Base · 74M · {{tail}}",
|
||||
"small": "Whisper Small · 244M · {{tail}}",
|
||||
"medium": "Whisper Medium · 769M · {{tail}}",
|
||||
"large": "Whisper Large · 1.5B · {{tail}}",
|
||||
"turbo": "Whisper Turbo · 精简版 Large v3 · {{tail}}",
|
||||
"tail": {
|
||||
"fast": "快速",
|
||||
"balanced": "均衡",
|
||||
"higher": "更高准确度",
|
||||
"best": "最佳准确度",
|
||||
"nearBest": "接近最佳,速度快"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "语言",
|
||||
"description": "自动检测适用于大多数捕获。如果您总是说同一种语言,可以将其锁定。",
|
||||
"auto": "自动检测",
|
||||
"en": "英语",
|
||||
"es": "西班牙语",
|
||||
"fr": "法语",
|
||||
"de": "德语",
|
||||
"ja": "日语",
|
||||
"zh": "中文",
|
||||
"hi": "印地语"
|
||||
},
|
||||
"archive": {
|
||||
"title": "归档音频",
|
||||
"description": "在每次转录旁保留原始录音。"
|
||||
}
|
||||
},
|
||||
"refinement": {
|
||||
"title": "精修",
|
||||
"description": "可选择在转录上运行本地 LLM,以清理填充词、标点和自我纠正。",
|
||||
"auto": {
|
||||
"title": "自动精修转录",
|
||||
"description": "每次捕获后运行。您仍可以在「捕获」标签页中切换原始和精修视图。"
|
||||
},
|
||||
"model": {
|
||||
"title": "精修模型",
|
||||
"description": "更大的模型速度较慢,但能更好地处理细微的自我纠正和技术词汇。",
|
||||
"size06": "Qwen3 · 0.6B · 400 MB · {{tail}}",
|
||||
"size17": "Qwen3 · 1.7B · 1.1 GB · {{tail}}",
|
||||
"size40": "Qwen3 · 4B · 2.5 GB · {{tail}}",
|
||||
"tail": {
|
||||
"veryFast": "非常快",
|
||||
"fast": "快速",
|
||||
"fullQuality": "完整质量"
|
||||
}
|
||||
},
|
||||
"smartCleanup": {
|
||||
"title": "智能清理",
|
||||
"description": "去除填充词(嗯、呃、那个),还原标点和大小写,但不重新措辞。"
|
||||
},
|
||||
"selfCorrection": {
|
||||
"title": "去除自我纠正",
|
||||
"description": "当您说到一半改变想法时(「其实不对……」「等等,我是说……」),丢弃被收回的部分,只保留最终意图。"
|
||||
},
|
||||
"preserveTechnical": {
|
||||
"title": "保留技术术语",
|
||||
"description": "完全按原样保留代码标识符、命令名称和缩写。在向代码提示词中听写时建议开启。"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"title": "播放",
|
||||
"description": "「捕获」标签页中「播放为」操作的默认声音。",
|
||||
"defaultVoice": {
|
||||
"title": "默认声音",
|
||||
"description": "未选择声音直接点击「播放为」时使用。可对每次捕获单独更改。",
|
||||
"noClonedVoices": "暂无克隆的声音",
|
||||
"noneSelected": "未选择",
|
||||
"clonedVoices": "克隆的声音"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储",
|
||||
"description": "捕获以配对的音频和转录文件保存在您的 Voicebox 数据目录中。",
|
||||
"retention": {
|
||||
"title": "保留",
|
||||
"description": "捕获保留多久。同时适用于音频和转录。",
|
||||
"forever": "永久保留",
|
||||
"d90": "90 天",
|
||||
"d30": "30 天",
|
||||
"d7": "7 天"
|
||||
},
|
||||
"folder": {
|
||||
"title": "捕获文件夹",
|
||||
"description": "捕获的音频和转录在磁盘上的存储位置。",
|
||||
"open": "打开"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "关于「捕获」",
|
||||
"aboutBody": "在系统的任何位置按住快捷键说话,Voicebox 就会把您的声音转换成文本。可用任何克隆的声音回放、粘贴到任何应用,或导入到您的编程代理中。",
|
||||
"differencesTitle": "不同之处",
|
||||
"local": {
|
||||
"title": "完全本地。",
|
||||
"body": "Whisper 和精修 LLM 都在您的硬件上运行。无云端、无账号,您的声音不会离开本机。"
|
||||
},
|
||||
"playAs": {
|
||||
"title": "以任何声音播放。",
|
||||
"body": "转录可以用您克隆的任何档案朗读出来。"
|
||||
},
|
||||
"crossPlatform": {
|
||||
"title": "跨平台。",
|
||||
"body": "在 macOS、Windows 和 Linux 上使用相同的快捷键和流程。"
|
||||
},
|
||||
"windowsCaveat": {
|
||||
"title": "Windows 上的提示",
|
||||
"body": "当 Voicebox 自身或任何以管理员身份运行的应用处于焦点时,快捷键不会触发。我们正在解决这个问题。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"install": {
|
||||
"title": "安装到您的代理",
|
||||
"description": "只要应用打开,Voicebox 就会暴露一个本地 MCP 服务器。将以下任一片段粘贴到您的代理 MCP 配置中。",
|
||||
"http": {
|
||||
"title": "HTTP(推荐)",
|
||||
"description": "适用于支持 HTTP MCP 的客户端——Claude Code、Cursor、Windsurf、VS Code。"
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code 一行命令",
|
||||
"description": "通过 Claude Code CLI 注册。"
|
||||
},
|
||||
"stdio": {
|
||||
"title": "Stdio(备选)",
|
||||
"description": "适用于仅启动 stdio 进程的客户端。垫片二进制随应用一同发布。"
|
||||
},
|
||||
"copy": "复制",
|
||||
"copied": "已复制"
|
||||
},
|
||||
"defaultVoice": {
|
||||
"title": "默认声音",
|
||||
"description": "当代理调用 voicebox.speak 但未指定具体档案、且没有按客户端绑定时使用。",
|
||||
"label": "默认播放声音",
|
||||
"labelHint": "与「捕获」标签页的「播放为」下拉菜单共享——被动播放的统一默认声音。",
|
||||
"none": "(无)"
|
||||
},
|
||||
"bindings": {
|
||||
"title": "按代理设置声音",
|
||||
"description": "将特定代理绑定到特定声音,这样不用看也能分辨谁在说话。代理通过 X-Voicebox-Client-Id 请求头(stdio 则用 VOICEBOX_CLIENT_ID 环境变量)来标识自己。",
|
||||
"empty": "暂无绑定。在下方添加一个,然后将您的 MCP 客户端配置为发送匹配的 <code>X-Voicebox-Client-Id</code>。",
|
||||
"lastSeen": "最后活跃 {{when}}",
|
||||
"lastSeenTitle": "最后活跃 {{when}}",
|
||||
"neverConnected": "从未连接",
|
||||
"defaultOption": "(默认)",
|
||||
"removeAria": "移除 {{client}} 的绑定",
|
||||
"add": {
|
||||
"title": "添加绑定",
|
||||
"clientIdPlaceholder": "客户端 ID(例如 claude-code)",
|
||||
"labelPlaceholder": "标签(可选)",
|
||||
"action": "添加绑定"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "关于 MCP",
|
||||
"aboutBody": "Model Context Protocol 让您的 AI 编程代理——Claude Code、Cursor、Windsurf——可以调用 Voicebox 工具。以克隆的声音朗读、转录音频、浏览捕获。",
|
||||
"toolsTitle": "可用工具",
|
||||
"tools": {
|
||||
"speak": "用声音档案朗读文本。",
|
||||
"transcribe": "对音频片段运行 Whisper 转录。",
|
||||
"listCaptures": "最近的听写 / 录制。",
|
||||
"listProfiles": "可用的声音档案。"
|
||||
},
|
||||
"postSpeak": "也以 <code>POST /speak</code> 暴露,可用于 shell 脚本、ACP、A2A。"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
@@ -752,7 +1158,8 @@
|
||||
"unknownSize": "未知大小",
|
||||
"sections": {
|
||||
"voiceGeneration": "语音生成",
|
||||
"transcription": "语音转录"
|
||||
"transcription": "语音转录",
|
||||
"languageModels": "语言模型"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "已加载"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "故事",
|
||||
"captures": "擷取",
|
||||
"voices": "聲音",
|
||||
"effects": "效果",
|
||||
"audio": "音訊",
|
||||
@@ -21,6 +22,149 @@
|
||||
"settings": "設定",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"captures": {
|
||||
"title": "擷取",
|
||||
"beta": "Beta",
|
||||
"searchPlaceholder": "搜尋轉錄文字……",
|
||||
"snippetEmpty": "(無轉錄文字)",
|
||||
"noTranscriptError": "此擷取尚無轉錄文字",
|
||||
"captureCardLabel": "擷取 · {{when}}",
|
||||
"header": {
|
||||
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
|
||||
},
|
||||
"source": {
|
||||
"dictation": "口述",
|
||||
"recording": "錄音",
|
||||
"file": "檔案"
|
||||
},
|
||||
"transcript": {
|
||||
"refined": "精修",
|
||||
"raw": "原始",
|
||||
"refinedHint": "由 Qwen3 · {{model}} 精修",
|
||||
"rawHint": "由 Whisper {{model}} 轉錄"
|
||||
},
|
||||
"actions": {
|
||||
"configure": "設定",
|
||||
"import": "匯入",
|
||||
"importing": "上傳中…",
|
||||
"dictate": "口述",
|
||||
"stop": "停止",
|
||||
"copy": "複製",
|
||||
"refine": "精修",
|
||||
"reRefine": "重新精修",
|
||||
"export": "匯出",
|
||||
"exportDropdownLabel": "匯出格式",
|
||||
"exportAudio": "音訊 (WAV)",
|
||||
"exportTranscript": "文字稿 (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "刪除",
|
||||
"playAs": "以 {{name}} 播放",
|
||||
"playAsFallback": "以聲音播放……",
|
||||
"playAsGenerating": "生成中…",
|
||||
"playAsStop": "停止 · {{name}}",
|
||||
"playAsStopFallback": "停止 · 聲音",
|
||||
"playAsDropdownLabel": "以聲音播放轉錄文字"
|
||||
},
|
||||
"empty": {
|
||||
"noMatches": "找不到符合 \"{{query}}\" 的擷取",
|
||||
"none": "尚無擷取。",
|
||||
"loading": "載入擷取中…",
|
||||
"pickOne": "選擇一個擷取以檢視其轉錄文字。",
|
||||
"holdToRecord": "按住以錄音",
|
||||
"toggleHandsFree": "切換免持模式",
|
||||
"pressShortcut": "在您的電腦上任何位置按下快捷鍵以開始第一次擷取。",
|
||||
"turnOnShortcut": "開啟全域快捷鍵以從任何地方口述——或點選上方的「口述」進行 App 內擷取。",
|
||||
"openSettings": "開啟擷取設定"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "刪除擷取",
|
||||
"description": "這將永久刪除該擷取及其音訊與轉錄。此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "刪除失敗",
|
||||
"playAsFailed": "以聲音播放失敗",
|
||||
"noVoice": "無聲音檔案",
|
||||
"noVoiceDescription": "使用「以聲音播放」前請先建立聲音檔案。",
|
||||
"transcriptCopied": "已複製轉錄文字",
|
||||
"copyFailed": "複製失敗",
|
||||
"exportSuccess": "已匯出至 {{path}}",
|
||||
"exportFailed": "匯出失敗",
|
||||
"exportEmpty": "沒有可匯出的內容",
|
||||
"shortcutNotArmed": "快捷鍵已開啟,但尚未就緒",
|
||||
"shortcutNotArmedDescription_one": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。"
|
||||
},
|
||||
"pill": {
|
||||
"recording": "錄音中",
|
||||
"transcribing": "轉錄中",
|
||||
"refining": "精修中",
|
||||
"speaking": "發話中",
|
||||
"completed": "完成",
|
||||
"stopAria": "停止錄音",
|
||||
"errorFallback": "發生錯誤",
|
||||
"errorCopyTooltip": "點選複製錯誤訊息"
|
||||
},
|
||||
"chord": {
|
||||
"capturing": "擷取中…",
|
||||
"pressShortcut": "請按下您的快捷鍵",
|
||||
"noKeys": "尚未設定按鍵",
|
||||
"unsupported": "「{{key}}」無法用於組合鍵。請改用修飾鍵或字母鍵。",
|
||||
"notSet": "未設定"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "口述前還需要幾項準備",
|
||||
"subheading": "在下列項目全部就緒前,快捷鍵將維持關閉。",
|
||||
"downloadButton": "下載",
|
||||
"downloading": "下載中…",
|
||||
"downloadingPercent": "下載中… {{pct}}%",
|
||||
"downloadStarted": "已開始下載",
|
||||
"downloadStartedDescription": "{{name}} 正在下載。下載完成後快捷鍵會自動就緒。",
|
||||
"downloadFailed": "下載失敗",
|
||||
"stt": {
|
||||
"label": "{{name}}(語音轉文字)",
|
||||
"ready": "模型已下載。",
|
||||
"missing": "用於轉錄您的音訊",
|
||||
"missingWithSize": "用於轉錄您的音訊 · {{size}}"
|
||||
},
|
||||
"llm": {
|
||||
"label": "{{name}}(精修)",
|
||||
"ready": "模型已下載。",
|
||||
"missing": "在貼上前清理原始轉錄文字",
|
||||
"missingWithSize": "在貼上前清理原始轉錄文字 · {{size}}"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"label": "輸入監控權限",
|
||||
"ready": "macOS 允許 Voicebox 偵測您的全域快捷鍵。",
|
||||
"missing": "macOS 需要允許 Voicebox 偵測全域快捷鍵。",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"accessibility": {
|
||||
"label": "輔助使用權限",
|
||||
"ready": "Voicebox 可將轉錄文字貼到其他 App。",
|
||||
"missing": "需要此權限才能將轉錄文字貼到目前作用中的 App。",
|
||||
"openSettings": "開啟設定"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"accessibility": {
|
||||
"title": "授予輔助使用權限以啟用自動貼上",
|
||||
"body": "Voicebox 需要 <path>系統設定 → 私隱與安全性 → 輔助使用</path> 才能將轉錄文字貼到其他 App。即使沒有此權限,口述內容仍會出現在「擷取」分頁。",
|
||||
"openSettings": "開啟設定",
|
||||
"recheck": "我已啟用",
|
||||
"rechecking": "檢查中…",
|
||||
"stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
|
||||
},
|
||||
"inputMonitoring": {
|
||||
"title": "授予輸入監控權限以啟用全域快捷鍵",
|
||||
"body": "Voicebox 需要 <path>系統設定 → 私隱與安全性 → 輸入監控</path> 才能偵測您的口述組合鍵。功能已開啟,但 macOS 在您允許前會封鎖按鍵事件。",
|
||||
"openSettings": "開啟設定",
|
||||
"recheck": "我已啟用",
|
||||
"rechecking": "檢查中…",
|
||||
"stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "聲音",
|
||||
"loading": "載入聲音中…",
|
||||
@@ -125,7 +269,10 @@
|
||||
"noPreference": "無偏好",
|
||||
"defaultEngineHint": "選擇此檔案時自動使用此引擎。",
|
||||
"defaultEffects": "預設效果",
|
||||
"defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。"
|
||||
"defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。",
|
||||
"personalityLabel": "個性",
|
||||
"personalityPlaceholder": "例如:「一位脾氣暴躁的海盜,只會用航海比喻說話」",
|
||||
"personalityHint": "這個聲音是誰以及他們如何說話。會驅動生成頁面上的「撰寫」按鈕和角色化重寫切換。留空則兩者都隱藏。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "頭像預覽"
|
||||
@@ -415,9 +562,11 @@
|
||||
"title": "故事",
|
||||
"newStory": "新增故事",
|
||||
"loading": "載入故事中…",
|
||||
"searchPlaceholder": "搜尋故事…",
|
||||
"empty": {
|
||||
"title": "尚無故事",
|
||||
"hint": "建立您的第一個故事以開始"
|
||||
"hint": "建立您的第一個故事以開始",
|
||||
"noMatches": "沒有故事符合「{{query}}」"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項",
|
||||
@@ -480,16 +629,23 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "從此處播放",
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "從故事中移除"
|
||||
},
|
||||
"importAudio": "匯入音訊…",
|
||||
"importing": "匯入中…",
|
||||
"dropToImport": "拖放以匯入音訊",
|
||||
"toast": {
|
||||
"removeFailed": "移除項目失敗",
|
||||
"reorderFailed": "重新排序項目失敗",
|
||||
"exportFailed": "匯出音訊失敗",
|
||||
"addFailed": "新增生成失敗"
|
||||
"addFailed": "新增生成失敗",
|
||||
"regenerateFailed": "重新生成失敗",
|
||||
"importFailed": "匯入音訊失敗"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "尚無語音生成…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
@@ -550,6 +706,18 @@
|
||||
"effects": {
|
||||
"none": "無效果",
|
||||
"profileDefault": "檔案預設"
|
||||
},
|
||||
"compose": {
|
||||
"tooltip": "撰寫",
|
||||
"ariaLabel": "以角色撰寫一句台詞",
|
||||
"failedTitle": "撰寫失敗",
|
||||
"failedDescription": "無法從此個性生成文字。"
|
||||
},
|
||||
"persona": {
|
||||
"tooltipActive": "以角色發話中",
|
||||
"tooltipInactive": "以角色發話",
|
||||
"ariaLabelActive": "以角色發話中",
|
||||
"ariaLabelInactive": "以角色發話"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -571,6 +739,8 @@
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
"generation": "生成",
|
||||
"captures": "擷取",
|
||||
"mcp": "MCP",
|
||||
"gpu": "GPU",
|
||||
"logs": "日誌",
|
||||
"changelog": "更新日誌",
|
||||
@@ -580,6 +750,15 @@
|
||||
"label": "語言",
|
||||
"description": "選擇 Voicebox 的顯示語言。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "佈景主題",
|
||||
"description": "跟隨系統外觀,或固定為淺色 / 深色模式。",
|
||||
"options": {
|
||||
"system": "跟隨系統",
|
||||
"light": "淺色",
|
||||
"dark": "深色"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "閱讀文件" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "取得協助與分享聲音" },
|
||||
@@ -676,6 +855,233 @@
|
||||
"title": "生成資料夾",
|
||||
"description": "生成的音訊檔案在磁碟上的儲存位置。",
|
||||
"open": "開啟"
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "關於語音生成",
|
||||
"aboutBody": "從一段簡短的樣本複製聲音,然後以任何聲音、跨任何語言生成語音。將 TTS 送進 AI 代理、遊戲、Podcast 或長篇旁白。",
|
||||
"differencesTitle": "有何不同",
|
||||
"clone": {
|
||||
"title": "幾秒內複製任何聲音。",
|
||||
"body": "幾秒鐘的參考音訊就夠了。需要更高品質時也支援多樣本。"
|
||||
},
|
||||
"engines": {
|
||||
"title": "七種引擎、23 種語言。",
|
||||
"body": "選擇最符合需求的取捨——品質、速度,或多語言覆蓋。"
|
||||
},
|
||||
"agentReady": {
|
||||
"title": "代理就緒。",
|
||||
"body": "REST API 提供逐一聲音檔案的控制——讓任何 AI 擁有您複製過的聲音。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"captures": {
|
||||
"dictation": {
|
||||
"title": "口述",
|
||||
"description": "使用全域快捷鍵從電腦上任何位置進行擷取。",
|
||||
"globalShortcut": {
|
||||
"title": "全域快捷鍵",
|
||||
"description": "按住快捷鍵以從電腦上任何位置錄音。放開後進行轉錄。"
|
||||
},
|
||||
"pushToTalk": {
|
||||
"title": "按住說話快捷鍵",
|
||||
"description": "在系統任何位置按住這些按鍵即可錄音。放開後停止並轉錄。",
|
||||
"change": "變更"
|
||||
},
|
||||
"toggle": {
|
||||
"title": "切換快捷鍵",
|
||||
"description": "按一次開始免持錄音。再按一次停止。通常為按住說話加上 Space。",
|
||||
"change": "變更"
|
||||
},
|
||||
"chordPicker": {
|
||||
"pttTitle": "設定按住說話快捷鍵",
|
||||
"pttDescription": "按住您要使用的按鍵,然後放開並點選「儲存」。右側修飾鍵徽章會顯示按鍵是左側或右側的變體。",
|
||||
"toggleTitle": "設定切換快捷鍵",
|
||||
"toggleDescription": "按住您要使用的按鍵,然後放開並點選「儲存」。請選擇與按住說話組合鍵不同的按鍵。"
|
||||
},
|
||||
"preview": {
|
||||
"title": "預覽",
|
||||
"description": "按住快捷鍵時螢幕上顯示的內容。"
|
||||
},
|
||||
"copyToClipboard": {
|
||||
"title": "將轉錄文字複製到剪貼簿",
|
||||
"description": "擷取完成時,清理過的轉錄文字會出現在您的剪貼簿。"
|
||||
},
|
||||
"autoPaste": {
|
||||
"title": "自動貼到目前作用中的文字欄位",
|
||||
"description": "若另一個 App 中已聚焦於文字輸入,直接貼進去。Voicebox 會儲存並還原您原本剪貼簿上的內容。"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
"title": "轉錄",
|
||||
"description": "選擇用於擷取的語音轉文字模型。",
|
||||
"model": {
|
||||
"title": "轉錄模型",
|
||||
"description": "Whisper 隨 Voicebox 提供,完全在您的電腦上執行。",
|
||||
"base": "Whisper Base · 74M · {{tail}}",
|
||||
"small": "Whisper Small · 244M · {{tail}}",
|
||||
"medium": "Whisper Medium · 769M · {{tail}}",
|
||||
"large": "Whisper Large · 1.5B · {{tail}}",
|
||||
"turbo": "Whisper Turbo · 精簡版 Large v3 · {{tail}}",
|
||||
"tail": {
|
||||
"fast": "快速",
|
||||
"balanced": "平衡",
|
||||
"higher": "較高準確度",
|
||||
"best": "最高準確度",
|
||||
"nearBest": "接近最佳,快速"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "語言",
|
||||
"description": "自動偵測適用於大多數擷取。若您總是說同一種語言,可以鎖定它。",
|
||||
"auto": "自動偵測",
|
||||
"en": "英文",
|
||||
"es": "西班牙文",
|
||||
"fr": "法文",
|
||||
"de": "德文",
|
||||
"ja": "日文",
|
||||
"zh": "中文",
|
||||
"hi": "印地文"
|
||||
},
|
||||
"archive": {
|
||||
"title": "封存音訊",
|
||||
"description": "在每筆轉錄文字旁保留原始錄音。"
|
||||
}
|
||||
},
|
||||
"refinement": {
|
||||
"title": "精修",
|
||||
"description": "可選擇在轉錄文字上執行本地 LLM,以清除贅詞、補上標點與修正自我更正。",
|
||||
"auto": {
|
||||
"title": "自動精修轉錄文字",
|
||||
"description": "每次擷取後執行。您仍可在「擷取」分頁中切換原始與精修版本。"
|
||||
},
|
||||
"model": {
|
||||
"title": "精修模型",
|
||||
"description": "較大的模型較慢,但對於細微的自我更正與專業詞彙處理得更好。",
|
||||
"size06": "Qwen3 · 0.6B · 400 MB · {{tail}}",
|
||||
"size17": "Qwen3 · 1.7B · 1.1 GB · {{tail}}",
|
||||
"size40": "Qwen3 · 4B · 2.5 GB · {{tail}}",
|
||||
"tail": {
|
||||
"veryFast": "非常快",
|
||||
"fast": "快速",
|
||||
"fullQuality": "完整品質"
|
||||
}
|
||||
},
|
||||
"smartCleanup": {
|
||||
"title": "智慧清理",
|
||||
"description": "移除贅詞(嗯、呃、那個之類),還原標點符號,修正大小寫,且不重新改寫。"
|
||||
},
|
||||
"selfCorrection": {
|
||||
"title": "移除自我更正",
|
||||
"description": "當您說到一半改變想法時(「其實不對……」、「等等,我是想說……」),刪掉收回的部分,只保留最終意圖。"
|
||||
},
|
||||
"preserveTechnical": {
|
||||
"title": "保留技術術語",
|
||||
"description": "完整保留所說的程式碼識別字、指令名稱與縮寫。當您要對程式碼提示進行口述時請開啟。"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"title": "播放",
|
||||
"description": "「擷取」分頁中「以聲音播放」動作的預設聲音。",
|
||||
"defaultVoice": {
|
||||
"title": "預設聲音",
|
||||
"description": "當您點選「以聲音播放」但未先選擇聲音時使用。每筆擷取仍可個別變更。",
|
||||
"noClonedVoices": "尚無複製聲音",
|
||||
"noneSelected": "未選擇",
|
||||
"clonedVoices": "複製聲音"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "儲存",
|
||||
"description": "擷取會以成對的音訊與轉錄文字檔形式,儲存在您的 Voicebox 資料目錄中。",
|
||||
"retention": {
|
||||
"title": "保留期限",
|
||||
"description": "擷取保留的時間長度。同時適用於音訊與轉錄文字。",
|
||||
"forever": "永久保留",
|
||||
"d90": "90 天",
|
||||
"d30": "30 天",
|
||||
"d7": "7 天"
|
||||
},
|
||||
"folder": {
|
||||
"title": "擷取資料夾",
|
||||
"description": "擷取的音訊與轉錄在磁碟上的儲存位置。",
|
||||
"open": "開啟"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "關於擷取",
|
||||
"aboutBody": "在電腦上任何位置按住快捷鍵說話,Voicebox 會將您的聲音轉成文字。可以用任何複製的聲音重播、貼到任何 App,或送進您的程式碼代理。",
|
||||
"differencesTitle": "有何不同",
|
||||
"local": {
|
||||
"title": "完全在本機。",
|
||||
"body": "Whisper 與精修 LLM 都在您的硬體上執行。沒有雲端、沒有帳號,您的聲音永遠不會離開電腦。"
|
||||
},
|
||||
"playAs": {
|
||||
"title": "以任何聲音播放。",
|
||||
"body": "轉錄文字可以用您複製過的任何聲音檔案讀回。"
|
||||
},
|
||||
"crossPlatform": {
|
||||
"title": "跨平台。",
|
||||
"body": "在 macOS、Windows 與 Linux 上享有相同的快捷鍵與相同的流程。"
|
||||
},
|
||||
"windowsCaveat": {
|
||||
"title": "Windows 上的提醒",
|
||||
"body": "當 Voicebox 本身或任何以系統管理員身分執行的應用程式取得焦點時,快捷鍵不會觸發。我們正在處理中。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"install": {
|
||||
"title": "安裝到您的代理",
|
||||
"description": "App 開啟時 Voicebox 會提供本地 MCP 伺服器。將以下其中一段程式碼貼到您的代理 MCP 設定中。",
|
||||
"http": {
|
||||
"title": "HTTP(建議)",
|
||||
"description": "適用於支援 HTTP MCP 的客戶端——Claude Code、Cursor、Windsurf、VS Code。"
|
||||
},
|
||||
"claudeCode": {
|
||||
"title": "Claude Code 一行指令",
|
||||
"description": "透過 Claude Code CLI 註冊。"
|
||||
},
|
||||
"stdio": {
|
||||
"title": "Stdio(備用)",
|
||||
"description": "適用於只能啟動 stdio 程序的客戶端。Shim 二進位檔隨 App 提供。"
|
||||
},
|
||||
"copy": "複製",
|
||||
"copied": "已複製"
|
||||
},
|
||||
"defaultVoice": {
|
||||
"title": "預設聲音",
|
||||
"description": "當代理呼叫 voicebox.speak 卻未指定聲音檔案,且沒有對應客戶端綁定時使用。",
|
||||
"label": "預設播放聲音",
|
||||
"labelHint": "與「擷取」分頁的「以聲音播放」下拉選單共用——一個用於被動播放的預設聲音。",
|
||||
"none": "(無)"
|
||||
},
|
||||
"bindings": {
|
||||
"title": "個別代理聲音",
|
||||
"description": "將特定代理綁定到特定聲音,讓您不用看就能聽出是誰在說話。代理透過 X-Voicebox-Client-Id 標頭(stdio 則用 VOICEBOX_CLIENT_ID 環境變數)識別自己。",
|
||||
"empty": "尚無綁定。請在下方新增,然後將您的 MCP 客戶端設定為傳送對應的 <code>X-Voicebox-Client-Id</code>。",
|
||||
"lastSeen": "最後出現於 {{when}}",
|
||||
"lastSeenTitle": "最後出現於 {{when}}",
|
||||
"neverConnected": "從未連線",
|
||||
"defaultOption": "(預設)",
|
||||
"removeAria": "移除 {{client}} 的綁定",
|
||||
"add": {
|
||||
"title": "新增綁定",
|
||||
"clientIdPlaceholder": "客戶端 ID(例如 claude-code)",
|
||||
"labelPlaceholder": "標籤(選填)",
|
||||
"action": "新增綁定"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"aboutTitle": "關於 MCP",
|
||||
"aboutBody": "Model Context Protocol 讓您的 AI 程式碼代理——Claude Code、Cursor、Windsurf——可以呼叫 Voicebox 工具。以複製的聲音說話、轉錄音訊、瀏覽擷取。",
|
||||
"toolsTitle": "可用工具",
|
||||
"tools": {
|
||||
"speak": "以聲音檔案說出文字。",
|
||||
"transcribe": "對片段執行 Whisper STT。",
|
||||
"listCaptures": "近期口述 / 錄音。",
|
||||
"listProfiles": "可用的聲音檔案。"
|
||||
},
|
||||
"postSpeak": "也提供 <code>POST /speak</code> 介面,供 shell 指令稿、ACP、A2A 使用。"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
@@ -752,7 +1158,8 @@
|
||||
"unknownSize": "未知大小",
|
||||
"sections": {
|
||||
"voiceGeneration": "語音生成",
|
||||
"transcription": "語音轉錄"
|
||||
"transcription": "語音轉錄",
|
||||
"languageModels": "語言模型"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "已載入"
|
||||
|
||||
+20
-15
@@ -44,24 +44,24 @@
|
||||
|
||||
:root {
|
||||
--background: 0 0% 95%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 0 0% 5%;
|
||||
--card: 0 0% 97%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 0 0% 5%;
|
||||
--popover: 0 0% 97%;
|
||||
--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%;
|
||||
--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%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 85%;
|
||||
--input: 214.3 31.8% 88%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 85%;
|
||||
--input: 0 0% 88%;
|
||||
--ring: 0 0% 5%;
|
||||
--sidebar: 0 0% 92%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
@@ -157,6 +157,11 @@
|
||||
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 {
|
||||
|
||||
@@ -18,7 +18,9 @@ import type {
|
||||
ModelDownloadRequest,
|
||||
ModelStatusListResponse,
|
||||
PresetVoice,
|
||||
PersonalityTextResponse,
|
||||
ProfileSampleResponse,
|
||||
RocmStatus,
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
@@ -29,11 +31,28 @@ 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 {
|
||||
@@ -115,6 +134,17 @@ 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,
|
||||
@@ -246,6 +276,20 @@ 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',
|
||||
@@ -381,6 +425,122 @@ 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');
|
||||
@@ -536,6 +696,23 @@ 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');
|
||||
@@ -614,6 +791,17 @@ 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,
|
||||
@@ -752,6 +940,21 @@ 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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+195
-2
@@ -12,6 +12,8 @@ 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 {
|
||||
@@ -26,12 +28,19 @@ 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;
|
||||
@@ -71,6 +80,8 @@ 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;
|
||||
@@ -127,6 +138,118 @@ 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;
|
||||
/** 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;
|
||||
@@ -146,7 +269,8 @@ export interface HealthResponse {
|
||||
gpu_type?: string;
|
||||
vram_used_mb?: number;
|
||||
backend_type?: string;
|
||||
backend_variant?: string; // "cpu" or "cuda"
|
||||
backend_variant?: string; // "cpu", "cuda", or "rocm"
|
||||
supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable
|
||||
}
|
||||
|
||||
export interface CudaDownloadProgress {
|
||||
@@ -163,11 +287,34 @@ export interface CudaDownloadProgress {
|
||||
export interface CudaStatus {
|
||||
available: boolean; // CUDA binary exists on disk
|
||||
active: boolean; // Currently running the CUDA binary
|
||||
binary_path?: string;
|
||||
binary_path: string | null;
|
||||
cuda_libs_version: string | null;
|
||||
download_supported: boolean; // Platform has a matching release asset
|
||||
unsupported_reason: string | null;
|
||||
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;
|
||||
@@ -268,11 +415,17 @@ 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;
|
||||
}
|
||||
@@ -367,3 +520,43 @@ 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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface UseAudioRecordingOptions {
|
||||
}
|
||||
|
||||
export function useAudioRecording({
|
||||
maxDurationSeconds = 29,
|
||||
maxDurationSeconds,
|
||||
onRecordingComplete,
|
||||
}: UseAudioRecordingOptions = {}) {
|
||||
const platform = usePlatform();
|
||||
@@ -124,8 +124,11 @@ export function useAudioRecording({
|
||||
console.error('MediaRecorder error:', event);
|
||||
};
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start(100); // Collect data every 100ms
|
||||
// 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();
|
||||
setIsRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
@@ -135,8 +138,11 @@ export function useAudioRecording({
|
||||
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
|
||||
// Auto-stop at max duration
|
||||
if (elapsed >= maxDurationSeconds) {
|
||||
// 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') {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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 {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
onCaptureCreated?: (capture: CaptureResponse) => 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.
|
||||
*/
|
||||
onFinalText?: (
|
||||
text: string,
|
||||
capture: CaptureResponse,
|
||||
allowAutoPaste: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface UseCaptureRecordingSessionResult {
|
||||
pillState: CapturePillState;
|
||||
pillElapsedMs: number;
|
||||
errorMessage: string | null;
|
||||
isRecording: boolean;
|
||||
isUploading: boolean;
|
||||
isRefining: boolean;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
toggleRecording: () => void;
|
||||
dismissError: () => void;
|
||||
uploadFile: (file: File, source: CaptureSource) => void;
|
||||
refine: (captureId: string) => 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;
|
||||
|
||||
// Snapshot of ``allow_auto_paste`` from the capture-create response —
|
||||
// held so the refine onSuccess (which only sees the plain CaptureResponse)
|
||||
// can still pass the original setting through to onFinalText.
|
||||
const allowAutoPasteRef = useRef<boolean>(true);
|
||||
|
||||
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 finalText = data.transcript_refined ?? data.transcript_raw;
|
||||
if (finalText) {
|
||||
onFinalTextRef.current?.(finalText, data, allowAutoPasteRef.current);
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showError(err.message || 'Refinement failed');
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async ({ file, source }: { file: File; source: CaptureSource }) =>
|
||||
apiClient.createCapture(file, { source }),
|
||||
onSuccess: (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 };
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
broadcastCreated(capture);
|
||||
onCaptureCreatedRef.current?.(capture);
|
||||
allowAutoPasteRef.current = capture.allow_auto_paste;
|
||||
if (capture.auto_refine) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
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,
|
||||
} = useAudioRecording({
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// 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' });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (recordError) {
|
||||
showError(recordError);
|
||||
}
|
||||
}, [recordError, showError]);
|
||||
|
||||
const startRecording = useCallback(() => {
|
||||
if (isRecording) return;
|
||||
clearRestTimer();
|
||||
setFrozenElapsedMs(0);
|
||||
setPillState('recording');
|
||||
beginAudioRecording();
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEffect } 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 pushKeys = settings?.chord_push_to_talk_keys;
|
||||
const toggleKeys = settings?.chord_toggle_to_talk_keys;
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
if (enabled === undefined || !pushKeys || !toggleKeys) return;
|
||||
const shouldArm = enabled && canRecord;
|
||||
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);
|
||||
});
|
||||
}, [
|
||||
platform.metadata.isTauri,
|
||||
enabled,
|
||||
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(','),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,6 +29,7 @@ const generationSchema = z.object({
|
||||
'kokoro',
|
||||
])
|
||||
.optional(),
|
||||
personality: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||
@@ -43,9 +44,10 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
const { toast } = useToast();
|
||||
const generation = useGeneration();
|
||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||
const { settings: genSettings } = useGenerationSettings();
|
||||
const maxChunkChars = genSettings?.max_chunk_chars ?? 800;
|
||||
const crossfadeMs = genSettings?.crossfade_ms ?? 50;
|
||||
const normalizeAudio = genSettings?.normalize_audio ?? true;
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
@@ -65,6 +67,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
modelSize: '1.7B',
|
||||
instruct: '',
|
||||
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
|
||||
personality: false,
|
||||
...options.defaultValues,
|
||||
},
|
||||
});
|
||||
@@ -149,6 +152,7 @@ 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,
|
||||
@@ -166,6 +170,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
modelSize: data.modelSize,
|
||||
instruct: '',
|
||||
engine: data.engine,
|
||||
personality: data.personality,
|
||||
});
|
||||
options.onSuccess?.(result.id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,17 +2,22 @@ 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
|
||||
@@ -26,7 +31,8 @@ export function useGenerationProgress() {
|
||||
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||
const { settings: genSettings } = useGenerationSettings();
|
||||
const autoplayOnGenerate = genSettings?.autoplay_on_generate ?? true;
|
||||
|
||||
// Keep refs to avoid stale closures in EventSource handlers
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
@@ -109,8 +115,11 @@ export function useGenerationProgress() {
|
||||
// });
|
||||
}
|
||||
|
||||
// Auto-play if enabled and nothing is currently playing
|
||||
if (autoplayRef.current && !isPlayingRef.current) {
|
||||
// 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) {
|
||||
const genAudioUrl = apiClient.getAudioUrl(id);
|
||||
setAudioWithAutoPlay(genAudioUrl, id, '', '');
|
||||
}
|
||||
|
||||
@@ -47,12 +47,14 @@ export function useExportGeneration() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
// Create safe filename from text
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
@@ -73,12 +75,14 @@ export function useExportGenerationAudio() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
// Create safe filename from text
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}.wav`;
|
||||
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryItemVolumeUpdate,
|
||||
} from '@/lib/api/types';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
@@ -154,6 +155,26 @@ 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();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
interface ActiveSource {
|
||||
source: AudioBufferSourceNode;
|
||||
clipGain: GainNode;
|
||||
itemId: string;
|
||||
generationId: string;
|
||||
startTimeMs: number;
|
||||
@@ -61,11 +62,29 @@ 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);
|
||||
}
|
||||
}, []);
|
||||
@@ -264,10 +283,17 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(masterGainRef.current || audioContext.destination);
|
||||
// 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);
|
||||
|
||||
const activeSource: ActiveSource = {
|
||||
source,
|
||||
clipGain,
|
||||
itemId: item.id,
|
||||
generationId: item.generation_id,
|
||||
startTimeMs: item.start_time_ms,
|
||||
|
||||
+28
-13
@@ -1,5 +1,5 @@
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { ja, zhCN, zhTW } from 'date-fns/locale';
|
||||
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
@@ -10,36 +10,51 @@ export function formatDuration(seconds: number): string {
|
||||
|
||||
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 {
|
||||
let dateObj: Date;
|
||||
if (typeof date === 'string') {
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
dateObj = new Date(`${dateStr}Z`);
|
||||
} else {
|
||||
dateObj = new Date(dateStr);
|
||||
}
|
||||
} else {
|
||||
dateObj = date;
|
||||
// Backend timestamps are naive UTC — append `Z` so JS doesn't parse a
|
||||
// timezone-less date-time string as local time.
|
||||
function parseServerDate(date: string | Date): Date {
|
||||
if (typeof date !== 'string') {
|
||||
return date;
|
||||
}
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
return new Date(`${dateStr}Z`);
|
||||
}
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
return formatDistance(dateObj, new Date(), {
|
||||
export function formatDate(date: string | Date): string {
|
||||
return formatDistance(parseServerDate(date), new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(),
|
||||
}).replace(/^about /i, '');
|
||||
}
|
||||
|
||||
export function formatAbsoluteDate(date: string | Date): string {
|
||||
const dateObj = parseServerDate(date);
|
||||
return dateObj.toLocaleString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
qwen: 'Qwen',
|
||||
luxtts: 'LuxTTS',
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -60,6 +60,7 @@ 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;
|
||||
|
||||
+22
-6
@@ -6,16 +6,18 @@ import {
|
||||
redirect,
|
||||
} from '@tanstack/react-router';
|
||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||
import { CapturesTab } from '@/components/CapturesTab/CapturesTab';
|
||||
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';
|
||||
@@ -111,11 +113,11 @@ const voicesRoute = createRoute({
|
||||
component: VoicesTab,
|
||||
});
|
||||
|
||||
// Audio route
|
||||
const audioRoute = createRoute({
|
||||
// Captures route (prototype — will replace AudioTab once the new flow is ready)
|
||||
const capturesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/audio',
|
||||
component: AudioTab,
|
||||
path: '/captures',
|
||||
component: CapturesTab,
|
||||
});
|
||||
|
||||
// Effects route
|
||||
@@ -152,6 +154,18 @@ 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',
|
||||
@@ -189,13 +203,15 @@ const serverRedirectRoute = createRoute({
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
storiesRoute,
|
||||
capturesRoute,
|
||||
voicesRoute,
|
||||
audioRoute,
|
||||
effectsRoute,
|
||||
modelsRoute,
|
||||
settingsRoute.addChildren([
|
||||
settingsGeneralRoute,
|
||||
settingsGenerationRoute,
|
||||
settingsCapturesRoute,
|
||||
settingsMCPRoute,
|
||||
settingsGpuRoute,
|
||||
settingsLogsRoute,
|
||||
settingsChangelogRoute,
|
||||
|
||||
@@ -15,18 +15,6 @@ 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;
|
||||
}
|
||||
@@ -39,10 +27,44 @@ 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: 'http://127.0.0.1:17493',
|
||||
serverUrl: getDefaultServerUrl(),
|
||||
setServerUrl: (url) => {
|
||||
const prev = get().serverUrl;
|
||||
set({ serverUrl: url });
|
||||
@@ -60,18 +82,6 @@ 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 }),
|
||||
}),
|
||||
|
||||
+54
-25
@@ -1,10 +1,25 @@
|
||||
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
|
||||
@@ -44,37 +59,51 @@ interface UIStore {
|
||||
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||
|
||||
// Theme
|
||||
theme: 'light' | 'dark';
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIStore>((set) => ({
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
export const useUIStore = create<UIStore>()(
|
||||
persist(
|
||||
(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: 'light',
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
},
|
||||
}));
|
||||
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);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.4.5"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
+175
-80
@@ -3,7 +3,10 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -35,9 +38,67 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# AMD GPU environment variables must be set before torch import
|
||||
# An empty HSA_OVERRIDE_GFX_VERSION poisons the ROCm HSA runtime. It is
|
||||
# treated as "force-empty" and no GPU is detected, even natively supported
|
||||
# ones (e.g. gfx1201 / RX 9070 on ROCm 7.2). docker-compose can't
|
||||
# conditionally omit an env var, so we clean it up here before torch loads.
|
||||
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
|
||||
os.environ.pop("HSA_OVERRIDE_GFX_VERSION", None)
|
||||
|
||||
# AMD GPU environment variables must be set before torch import
|
||||
# Only set HSA_OVERRIDE_GFX_VERSION for older GPUs that need it.
|
||||
# RDNA 3+ (gfx1100+) and RDNA 4 (gfx1200+) are natively supported by ROCm
|
||||
# and the override can cause suboptimal performance or errors.
|
||||
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["rocminfo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Collect all GPUs found in rocminfo output
|
||||
gfx_versions = []
|
||||
for line in result.stdout.splitlines():
|
||||
line_lower = line.lower()
|
||||
if "gfx" in line_lower:
|
||||
match = re.search(r"(gfx\d+)", line_lower)
|
||||
if match:
|
||||
gfx_versions.append(match.group(1))
|
||||
|
||||
if gfx_versions:
|
||||
# Check if any GPU needs the override (RDNA 2 and older)
|
||||
# Use the oldest GPU (lowest gfx number) for the decision
|
||||
try:
|
||||
gfx_nums = []
|
||||
for v in gfx_versions:
|
||||
m = re.search(r"\d+", v)
|
||||
if m:
|
||||
gfx_nums.append(int(m.group()))
|
||||
if gfx_nums:
|
||||
oldest_num = min(gfx_nums)
|
||||
oldest_gfx = gfx_versions[gfx_nums.index(oldest_num)]
|
||||
if oldest_num < 1100:
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
|
||||
logger.info(
|
||||
"AMD GPU detected (%s), setting HSA_OVERRIDE_GFX_VERSION=10.3.0 for compatibility. All GPUs: %s",
|
||||
oldest_gfx,
|
||||
", ".join(gfx_versions),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"AMD GPU detected (%s), native ROCm support available, skipping HSA_OVERRIDE_GFX_VERSION. All GPUs: %s",
|
||||
oldest_gfx,
|
||||
", ".join(gfx_versions),
|
||||
)
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.info("Could not parse GPU version from rocminfo output: %s", e)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||
logger.info(
|
||||
"Could not detect AMD GPU via rocminfo, skipping automatic HSA_OVERRIDE_GFX_VERSION configuration: %s",
|
||||
e,
|
||||
)
|
||||
if not os.environ.get("MIOPEN_LOG_LEVEL"):
|
||||
os.environ["MIOPEN_LOG_LEVEL"] = "4"
|
||||
|
||||
@@ -47,7 +108,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from urllib.parse import quote
|
||||
|
||||
from . import __version__, config, database
|
||||
from .services import tts, transcribe
|
||||
from .services import tts, transcribe, llm
|
||||
from .database import get_db
|
||||
from .utils.platform_detect import get_backend_type
|
||||
from .utils.progress import get_progress_manager
|
||||
@@ -68,15 +129,46 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str:
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
from .mcp_server.server import build_mcp_server, compose_lifespan
|
||||
from .mcp_server.context import ClientIdMiddleware
|
||||
|
||||
# Build the MCP app up-front so we can wire its lifespan into FastAPI's —
|
||||
# FastMCP's Streamable HTTP transport only works if its session manager
|
||||
# runs inside the parent ASGI lifespan.
|
||||
mcp = build_mcp_server()
|
||||
mcp_app = mcp.http_app(path="/", transport="http")
|
||||
|
||||
@asynccontextmanager
|
||||
async def voicebox_lifespan(app: FastAPI):
|
||||
await _run_startup(app)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Paired with _run_startup via try/finally: runs whether or
|
||||
# not the nested MCP lifespan entered cleanly, so a partial
|
||||
# startup still unloads whatever models were loaded.
|
||||
await _run_shutdown()
|
||||
|
||||
# compose_lifespan enters factories in order (voicebox startup →
|
||||
# MCP startup) and exits in LIFO (MCP teardown first → models
|
||||
# unload last). That ordering matters on shutdown: FastMCP's
|
||||
# __aexit__ cancels in-flight session tasks, and we want that to
|
||||
# happen *before* _run_shutdown yanks the TTS / Whisper / LLM
|
||||
# models out from under any MCP request that was still generating.
|
||||
lifespan = compose_lifespan(voicebox_lifespan, mcp_app.router.lifespan_context)
|
||||
|
||||
application = FastAPI(
|
||||
title="voicebox API",
|
||||
description="Production-quality Qwen3-TTS voice cloning API",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_configure_cors(application)
|
||||
application.add_middleware(ClientIdMiddleware)
|
||||
register_routers(application)
|
||||
_register_lifecycle(application)
|
||||
application.mount("/mcp", mcp_app)
|
||||
logger.info("MCP: mounted at /mcp")
|
||||
_mount_frontend(application)
|
||||
|
||||
return application
|
||||
@@ -179,103 +271,106 @@ def _get_gpu_status() -> str:
|
||||
return "None (CPU only)"
|
||||
|
||||
|
||||
def _register_lifecycle(application: FastAPI) -> None:
|
||||
"""Attach startup and shutdown event handlers."""
|
||||
async def _run_startup(application: FastAPI) -> None:
|
||||
"""Database init, warnings, model-cache prep. Runs on lifespan entry."""
|
||||
import platform
|
||||
import sys
|
||||
|
||||
@application.on_event("startup")
|
||||
async def startup_event():
|
||||
import platform
|
||||
import sys
|
||||
logger.info("Voicebox v%s starting up", __version__)
|
||||
logger.info(
|
||||
"Python %s on %s %s (%s)",
|
||||
sys.version.split()[0],
|
||||
platform.system(),
|
||||
platform.release(),
|
||||
platform.machine(),
|
||||
)
|
||||
|
||||
logger.info("Voicebox v%s starting up", __version__)
|
||||
logger.info(
|
||||
"Python %s on %s %s (%s)",
|
||||
sys.version.split()[0],
|
||||
platform.system(),
|
||||
platform.release(),
|
||||
platform.machine(),
|
||||
)
|
||||
database.init_db()
|
||||
|
||||
database.init_db()
|
||||
from .database.session import _db_path
|
||||
|
||||
from .database.session import _db_path
|
||||
logger.info("Database: %s", _db_path)
|
||||
logger.info("Data directory: %s", config.get_data_dir())
|
||||
|
||||
logger.info("Database: %s", _db_path)
|
||||
logger.info("Data directory: %s", config.get_data_dir())
|
||||
init_queue()
|
||||
|
||||
init_queue()
|
||||
# Mark stale "generating" records as failed -- leftovers from a killed process
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
# Mark stale "generating" records as failed -- leftovers from a killed process
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
result = db.execute(
|
||||
sa_text(
|
||||
"UPDATE generations SET status = 'failed', "
|
||||
"error = 'Server was shut down during generation' "
|
||||
"WHERE status IN ('generating', 'loading_model')"
|
||||
)
|
||||
db = next(get_db())
|
||||
try:
|
||||
result = db.execute(
|
||||
sa_text(
|
||||
"UPDATE generations SET status = 'failed', "
|
||||
"error = 'Server was shut down during generation' "
|
||||
"WHERE status IN ('generating', 'loading_model')"
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
|
||||
|
||||
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
|
||||
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
|
||||
|
||||
profile_count = db.query(DBVoiceProfile).count()
|
||||
generation_count = db.query(DBGeneration).count()
|
||||
logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
|
||||
profile_count = db.query(DBVoiceProfile).count()
|
||||
generation_count = db.query(DBGeneration).count()
|
||||
logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning("Could not clean up stale generations: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning("Could not clean up stale generations: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
backend_type = get_backend_type()
|
||||
logger.info("Backend: %s", backend_type.upper())
|
||||
logger.info("GPU: %s", _get_gpu_status())
|
||||
backend_type = get_backend_type()
|
||||
logger.info("Backend: %s", backend_type.upper())
|
||||
logger.info("GPU: %s", _get_gpu_status())
|
||||
|
||||
# Warn if GPU architecture is not supported by this PyTorch build
|
||||
from .backends.base import check_cuda_compatibility
|
||||
from .backends.base import check_cuda_compatibility
|
||||
|
||||
_compatible, _cuda_warning = check_cuda_compatibility()
|
||||
if not _compatible:
|
||||
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
|
||||
_compatible, _cuda_warning = check_cuda_compatibility()
|
||||
if not _compatible:
|
||||
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
|
||||
|
||||
from .services.cuda import check_and_update_cuda_binary
|
||||
from .services.cuda import check_and_update_cuda_binary
|
||||
from .services.rocm import check_and_update_rocm_binary
|
||||
|
||||
create_background_task(check_and_update_cuda_binary())
|
||||
create_background_task(check_and_update_cuda_binary())
|
||||
create_background_task(check_and_update_rocm_binary())
|
||||
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
progress_manager._set_main_loop(asyncio.get_running_loop())
|
||||
except Exception as e:
|
||||
logger.warning("Could not initialize progress manager event loop: %s", e)
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
progress_manager._set_main_loop(asyncio.get_running_loop())
|
||||
except Exception as e:
|
||||
logger.warning("Could not initialize progress manager event loop: %s", e)
|
||||
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
try:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Model cache: %s", cache_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Could not create HuggingFace cache directory: %s", e)
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Model cache: %s", cache_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Could not create HuggingFace cache directory: %s", e)
|
||||
|
||||
logger.info("Ready")
|
||||
logger.info("Ready")
|
||||
|
||||
@application.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Voicebox server shutting down...")
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload TTS model")
|
||||
try:
|
||||
transcribe.unload_whisper_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload Whisper model")
|
||||
|
||||
async def _run_shutdown() -> None:
|
||||
"""Unload models on lifespan exit."""
|
||||
logger.info("Voicebox server shutting down...")
|
||||
try:
|
||||
tts.unload_tts_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload TTS model")
|
||||
try:
|
||||
transcribe.unload_whisper_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload Whisper model")
|
||||
try:
|
||||
llm.unload_llm_model()
|
||||
except Exception:
|
||||
logger.exception("Failed to unload LLM model")
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -18,6 +18,9 @@ from typing import Protocol, Optional, Tuple, List
|
||||
from typing_extensions import runtime_checkable
|
||||
import numpy as np
|
||||
|
||||
DEFAULT_LLM_MAX_TOKENS = 512
|
||||
DEFAULT_LLM_TEMPERATURE = 0.7
|
||||
|
||||
from ..utils.platform_detect import get_backend_type
|
||||
|
||||
LANGUAGE_CODE_TO_NAME = {
|
||||
@@ -53,6 +56,7 @@ class ModelConfig:
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
retries_runaway: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
|
||||
@@ -160,11 +164,47 @@ class STTBackend(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMBackend(Protocol):
|
||||
"""Protocol for local LLM (chat/completion) backend implementations."""
|
||||
|
||||
async def load_model(self, model_size: str) -> None:
|
||||
"""Load LLM weights and tokenizer."""
|
||||
...
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
|
||||
temperature: float = DEFAULT_LLM_TEMPERATURE,
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
"""Run a single-turn chat completion and return the assistant reply.
|
||||
|
||||
``examples`` is an optional list of ``(user, assistant)`` pairs
|
||||
prepended to the conversation as proper chat turns — small models
|
||||
pattern-match on inline system-prompt examples (echoing them
|
||||
verbatim for unrelated inputs), but treat structured turns as
|
||||
data and generalize instead. Used by the refinement service.
|
||||
"""
|
||||
...
|
||||
|
||||
def unload_model(self) -> None:
|
||||
...
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
...
|
||||
|
||||
|
||||
# Global backend instances
|
||||
_tts_backend: Optional[TTSBackend] = None
|
||||
_tts_backends: dict[str, TTSBackend] = {}
|
||||
_tts_backends_lock = threading.Lock()
|
||||
_stt_backend: Optional[STTBackend] = None
|
||||
_llm_backends: dict[str, LLMBackend] = {}
|
||||
_llm_backends_lock = threading.Lock()
|
||||
|
||||
# Supported TTS engines — keyed by engine name, value is the backend class import path.
|
||||
# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
|
||||
@@ -178,6 +218,10 @@ TTS_ENGINES = {
|
||||
"kokoro": "Kokoro",
|
||||
}
|
||||
|
||||
LLM_ENGINES = {
|
||||
"qwen_llm": "Qwen3 LLM",
|
||||
}
|
||||
|
||||
|
||||
def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
"""Return Qwen model configs with backend-aware HF repo IDs."""
|
||||
@@ -189,6 +233,10 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
|
||||
# mlx-audio can continue after an EOS miss with silence followed by
|
||||
# codec noise. Retry only the affected text as smaller chunks.
|
||||
retries_runaway = backend_type == "mlx"
|
||||
|
||||
return [
|
||||
ModelConfig(
|
||||
model_name="qwen-tts-1.7B",
|
||||
@@ -197,6 +245,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_1_7b,
|
||||
model_size="1.7B",
|
||||
size_mb=3500,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False, # Base model drops instruct silently
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -207,6 +256,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_0_6b,
|
||||
model_size="0.6B",
|
||||
size_mb=1200,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False,
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -365,9 +415,66 @@ def _get_whisper_configs() -> list[ModelConfig]:
|
||||
]
|
||||
|
||||
|
||||
def _get_qwen_llm_configs() -> list[ModelConfig]:
|
||||
"""Return Qwen3 LLM configs with backend-aware HF repo IDs.
|
||||
|
||||
MLX path uses 4-bit community quantizations for Apple Silicon; PyTorch path
|
||||
uses the upstream instruct weights.
|
||||
"""
|
||||
backend_type = get_backend_type()
|
||||
if backend_type == "mlx":
|
||||
repo_0_6 = "mlx-community/Qwen3-0.6B-4bit"
|
||||
repo_1_7 = "mlx-community/Qwen3-1.7B-4bit"
|
||||
repo_4 = "mlx-community/Qwen3-4B-4bit"
|
||||
else:
|
||||
repo_0_6 = "Qwen/Qwen3-0.6B"
|
||||
repo_1_7 = "Qwen/Qwen3-1.7B"
|
||||
repo_4 = "Qwen/Qwen3-4B"
|
||||
|
||||
common_languages = [
|
||||
"en", "zh", "ja", "ko", "de", "fr", "ru", "pt", "es", "it",
|
||||
]
|
||||
|
||||
return [
|
||||
ModelConfig(
|
||||
model_name="qwen3-0.6b",
|
||||
display_name="Qwen3 0.6B",
|
||||
engine="qwen_llm",
|
||||
hf_repo_id=repo_0_6,
|
||||
model_size="0.6B",
|
||||
size_mb=400 if backend_type == "mlx" else 1400,
|
||||
languages=common_languages,
|
||||
),
|
||||
ModelConfig(
|
||||
model_name="qwen3-1.7b",
|
||||
display_name="Qwen3 1.7B",
|
||||
engine="qwen_llm",
|
||||
hf_repo_id=repo_1_7,
|
||||
model_size="1.7B",
|
||||
size_mb=1100 if backend_type == "mlx" else 3500,
|
||||
languages=common_languages,
|
||||
),
|
||||
ModelConfig(
|
||||
model_name="qwen3-4b",
|
||||
display_name="Qwen3 4B",
|
||||
engine="qwen_llm",
|
||||
hf_repo_id=repo_4,
|
||||
model_size="4B",
|
||||
size_mb=2500 if backend_type == "mlx" else 8000,
|
||||
languages=common_languages,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_all_model_configs() -> list[ModelConfig]:
|
||||
"""Return the full list of model configs (TTS + STT)."""
|
||||
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
|
||||
"""Return the full list of model configs (TTS + STT + LLM)."""
|
||||
return (
|
||||
_get_qwen_model_configs()
|
||||
+ _get_qwen_custom_voice_configs()
|
||||
+ _get_non_qwen_tts_configs()
|
||||
+ _get_whisper_configs()
|
||||
+ _get_qwen_llm_configs()
|
||||
)
|
||||
|
||||
|
||||
def get_tts_model_configs() -> list[ModelConfig]:
|
||||
@@ -375,6 +482,16 @@ def get_tts_model_configs() -> list[ModelConfig]:
|
||||
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs()
|
||||
|
||||
|
||||
def get_llm_model_configs() -> list[ModelConfig]:
|
||||
"""Return only LLM model configs."""
|
||||
return _get_qwen_llm_configs()
|
||||
|
||||
|
||||
def get_stt_model_configs() -> list[ModelConfig]:
|
||||
"""Return only STT (Whisper) model configs."""
|
||||
return _get_whisper_configs()
|
||||
|
||||
|
||||
# Lookup helpers — these replace the if/elif chains in main.py
|
||||
|
||||
|
||||
@@ -394,6 +511,14 @@ def engine_needs_trim(engine: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def engine_retries_runaway(engine: str) -> bool:
|
||||
"""Whether unstable output should be retried in smaller chunks."""
|
||||
for cfg in get_tts_model_configs():
|
||||
if cfg.engine == engine:
|
||||
return cfg.retries_runaway
|
||||
return False
|
||||
|
||||
|
||||
def engine_has_model_sizes(engine: str) -> bool:
|
||||
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
|
||||
configs = [c for c in get_tts_model_configs() if c.engine == engine]
|
||||
@@ -440,7 +565,7 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
|
||||
def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
|
||||
from . import get_tts_backend_for_engine
|
||||
from ..services import tts, transcribe
|
||||
from ..services import tts, transcribe, llm as llm_service
|
||||
|
||||
if config.engine == "whisper":
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
@@ -449,6 +574,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
return True
|
||||
return False
|
||||
|
||||
if config.engine == "qwen_llm":
|
||||
backend = llm_service.get_llm_model()
|
||||
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
|
||||
if backend.is_loaded() and loaded_size == config.model_size:
|
||||
backend.unload_model()
|
||||
return True
|
||||
return False
|
||||
|
||||
if config.engine == "qwen":
|
||||
tts_model = tts.get_tts_model()
|
||||
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
|
||||
@@ -476,13 +609,18 @@ def unload_model_by_config(config: ModelConfig) -> bool:
|
||||
def check_model_loaded(config: ModelConfig) -> bool:
|
||||
"""Check if a model is currently loaded."""
|
||||
from . import get_tts_backend_for_engine
|
||||
from ..services import tts, transcribe
|
||||
from ..services import tts, transcribe, llm as llm_service
|
||||
|
||||
try:
|
||||
if config.engine == "whisper":
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size
|
||||
|
||||
if config.engine == "qwen_llm":
|
||||
backend = llm_service.get_llm_model()
|
||||
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
|
||||
return backend.is_loaded() and loaded_size == config.model_size
|
||||
|
||||
if config.engine == "qwen":
|
||||
tts_model = tts.get_tts_model()
|
||||
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
|
||||
@@ -502,7 +640,7 @@ def check_model_loaded(config: ModelConfig) -> bool:
|
||||
def get_model_load_func(config: ModelConfig):
|
||||
"""Return a callable that loads/downloads the model."""
|
||||
from . import get_tts_backend_for_engine
|
||||
from ..services import tts, transcribe
|
||||
from ..services import tts, transcribe, llm as llm_service
|
||||
|
||||
if config.engine == "whisper":
|
||||
return lambda: transcribe.get_whisper_model().load_model(config.model_size)
|
||||
@@ -513,6 +651,9 @@ def get_model_load_func(config: ModelConfig):
|
||||
if config.engine == "qwen_custom_voice":
|
||||
return lambda: get_tts_backend_for_engine(config.engine).load_model(config.model_size)
|
||||
|
||||
if config.engine == "qwen_llm":
|
||||
return lambda: llm_service.get_llm_model().load_model(config.model_size)
|
||||
|
||||
return lambda: get_tts_backend_for_engine(config.engine).load_model()
|
||||
|
||||
|
||||
@@ -613,9 +754,43 @@ def get_stt_backend() -> STTBackend:
|
||||
return _stt_backend
|
||||
|
||||
|
||||
def get_llm_backend() -> LLMBackend:
|
||||
"""Get or create the default Qwen3 LLM backend based on platform."""
|
||||
return get_llm_backend_for_engine("qwen_llm")
|
||||
|
||||
|
||||
def get_llm_backend_for_engine(engine: str) -> LLMBackend:
|
||||
"""Get or create an LLM backend for the given engine."""
|
||||
global _llm_backends
|
||||
|
||||
if engine in _llm_backends:
|
||||
return _llm_backends[engine]
|
||||
|
||||
with _llm_backends_lock:
|
||||
if engine in _llm_backends:
|
||||
return _llm_backends[engine]
|
||||
|
||||
if engine == "qwen_llm":
|
||||
backend_type = get_backend_type()
|
||||
if backend_type == "mlx":
|
||||
from .qwen_llm_backend import MLXQwenLLMBackend
|
||||
|
||||
backend = MLXQwenLLMBackend()
|
||||
else:
|
||||
from .qwen_llm_backend import PyTorchQwenLLMBackend
|
||||
|
||||
backend = PyTorchQwenLLMBackend()
|
||||
else:
|
||||
raise ValueError(f"Unknown LLM engine: {engine}. Supported: {list(LLM_ENGINES.keys())}")
|
||||
|
||||
_llm_backends[engine] = backend
|
||||
return backend
|
||||
|
||||
|
||||
def reset_backends():
|
||||
"""Reset backend instances (useful for testing)."""
|
||||
global _tts_backend, _tts_backends, _stt_backend
|
||||
global _tts_backend, _tts_backends, _stt_backend, _llm_backends
|
||||
_tts_backend = None
|
||||
_tts_backends.clear()
|
||||
_stt_backend = None
|
||||
_llm_backends.clear()
|
||||
|
||||
@@ -138,6 +138,11 @@ def check_cuda_compatibility() -> tuple[bool, str | None]:
|
||||
if not torch.cuda.is_available():
|
||||
return True, None
|
||||
|
||||
# ROCm/HIP uses the cuda frontend but has different architecture names (gfx*).
|
||||
# Skip NVIDIA-specific compute capability checks on AMD hardware.
|
||||
if hasattr(torch.version, "hip") and torch.version.hip:
|
||||
return True, None
|
||||
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
capability = f"{major}.{minor}"
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
|
||||
@@ -146,7 +146,15 @@ class HumeTadaBackend:
|
||||
)
|
||||
|
||||
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
|
||||
if device == "cuda" and torch.cuda.is_bf16_supported():
|
||||
# On ROCm/AMD, torch.cuda.is_bf16_supported() works via the HIP abstraction,
|
||||
# but we wrap it defensively in case an older build lacks the symbol.
|
||||
_bf16_ok = False
|
||||
if device == "cuda":
|
||||
try:
|
||||
_bf16_ok = torch.cuda.is_bf16_supported()
|
||||
except Exception:
|
||||
_bf16_ok = False
|
||||
if _bf16_ok:
|
||||
model_dtype = torch.bfloat16
|
||||
elif device == "xpu":
|
||||
# Intel Arc (Alchemist+) supports bf16 natively
|
||||
@@ -240,9 +248,13 @@ class HumeTadaBackend:
|
||||
audio = audio.T # (samples, channels) -> (channels, samples)
|
||||
audio = audio.to(device)
|
||||
|
||||
# Encode with forced alignment
|
||||
# Encode with forced alignment.
|
||||
# Must run under inference_mode: encoder params still require
|
||||
# grad by default, and an autograd graph across the DAC/Snake
|
||||
# stack can balloon VRAM far past the model footprint (#890).
|
||||
text_arg = [reference_text] if reference_text else None
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
with torch.inference_mode():
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
|
||||
# Serialize EncoderOutput to a dict of CPU tensors for caching
|
||||
prompt_dict = {}
|
||||
|
||||
@@ -96,11 +96,16 @@ KOKORO_VOICES = [
|
||||
("pf_dora", "Dora", "female", "pt"),
|
||||
("pm_alex", "Alex", "male", "pt"),
|
||||
("pm_santa", "Santa", "male", "pt"),
|
||||
# Chinese
|
||||
# Chinese female
|
||||
("zf_xiaobei", "Xiaobei", "female", "zh"),
|
||||
("zf_xiaoni", "Xiaoni", "female", "zh"),
|
||||
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
|
||||
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
|
||||
# Chinese male
|
||||
("zm_yunjian", "Yunjian", "male", "zh"),
|
||||
("zm_yunxi", "Yunxi", "male", "zh"),
|
||||
("zm_yunxia", "Yunxia", "male", "zh"),
|
||||
("zm_yunyang", "Yunyang", "male", "zh"),
|
||||
]
|
||||
|
||||
# Map our ISO language codes to Kokoro lang_code characters
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Qwen3 LLM backend implementations.
|
||||
|
||||
Provides MLX (Apple Silicon, 4-bit community quants) and PyTorch
|
||||
(transformers AutoModelForCausalLM) paths that share the same
|
||||
`LLMBackend` protocol and model-load progress plumbing as the TTS
|
||||
and STT engines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from . import LLMBackend, DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PYTORCH_HF_REPOS = {
|
||||
"0.6B": "Qwen/Qwen3-0.6B",
|
||||
"1.7B": "Qwen/Qwen3-1.7B",
|
||||
"4B": "Qwen/Qwen3-4B",
|
||||
}
|
||||
|
||||
MLX_HF_REPOS = {
|
||||
"0.6B": "mlx-community/Qwen3-0.6B-4bit",
|
||||
"1.7B": "mlx-community/Qwen3-1.7B-4bit",
|
||||
"4B": "mlx-community/Qwen3-4B-4bit",
|
||||
}
|
||||
|
||||
|
||||
def _progress_name(model_size: str) -> str:
|
||||
return f"qwen3-{model_size.lower()}"
|
||||
|
||||
|
||||
def _build_messages(
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> list[dict]:
|
||||
messages: list[dict] = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
if examples:
|
||||
for user_text, assistant_text in examples:
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
messages.append({"role": "assistant", "content": assistant_text})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
return messages
|
||||
|
||||
|
||||
class PyTorchQwenLLMBackend:
|
||||
"""Qwen3 LLM backend using HuggingFace transformers."""
|
||||
|
||||
def __init__(self, model_size: str = "0.6B"):
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self.model_size = model_size
|
||||
self._current_model_size: Optional[str] = None
|
||||
self.device = self._get_device()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
return get_torch_device(allow_xpu=True, allow_directml=True, allow_mps=True)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
if model_size not in PYTORCH_HF_REPOS:
|
||||
raise ValueError(f"Unknown Qwen3 size: {model_size}")
|
||||
return PYTORCH_HF_REPOS[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
return is_model_cached(self._get_model_path(model_size))
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
|
||||
def _load_model_sync(self, model_size: str) -> None:
|
||||
import torch
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
|
||||
progress_model_name = _progress_name(model_size)
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
repo = self._get_model_path(model_size)
|
||||
|
||||
with model_load_progress(progress_model_name, is_cached):
|
||||
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
|
||||
# Loads run with the process's default HF_HUB_OFFLINE state.
|
||||
# Forcing offline for cached models flips process-global state
|
||||
# and silently switches every concurrent download/load on other
|
||||
# threads to offline mode (issue #841) — the same regression
|
||||
# removed app-wide in #524/#530.
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(repo)
|
||||
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
repo,
|
||||
dtype=dtype,
|
||||
)
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
logger.info("Qwen3 %s loaded successfully", model_size)
|
||||
|
||||
def unload_model(self) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
del self.model
|
||||
del self.tokenizer
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self._current_model_size = None
|
||||
empty_device_cache(self.device)
|
||||
logger.info("Qwen3 unloaded")
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
|
||||
temperature: float = DEFAULT_LLM_TEMPERATURE,
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
await self.load_model(model_size)
|
||||
return await asyncio.to_thread(
|
||||
self._generate_sync, prompt, system, max_tokens, temperature, examples
|
||||
)
|
||||
|
||||
def _generate_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
import torch
|
||||
|
||||
messages = _build_messages(prompt, system, examples)
|
||||
text = self.tokenizer.apply_chat_template(
|
||||
messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=False,
|
||||
)
|
||||
inputs = self.tokenizer(text, return_tensors="pt").to(self.device)
|
||||
|
||||
do_sample = temperature > 0
|
||||
generate_kwargs = {
|
||||
"max_new_tokens": max_tokens,
|
||||
"do_sample": do_sample,
|
||||
"pad_token_id": self.tokenizer.eos_token_id,
|
||||
}
|
||||
if do_sample:
|
||||
generate_kwargs["temperature"] = temperature
|
||||
generate_kwargs["top_p"] = 0.9
|
||||
|
||||
with torch.no_grad():
|
||||
output_ids = self.model.generate(**inputs, **generate_kwargs)
|
||||
|
||||
input_len = inputs["input_ids"].shape[1]
|
||||
new_tokens = output_ids[0, input_len:]
|
||||
return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
|
||||
|
||||
|
||||
class MLXQwenLLMBackend:
|
||||
"""Qwen3 LLM backend using mlx-lm (Apple Silicon)."""
|
||||
|
||||
def __init__(self, model_size: str = "0.6B"):
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self.model_size = model_size
|
||||
self._current_model_size: Optional[str] = None
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
if model_size not in MLX_HF_REPOS:
|
||||
raise ValueError(f"Unknown Qwen3 size: {model_size}")
|
||||
return MLX_HF_REPOS[model_size]
|
||||
|
||||
def _is_model_cached(self, model_size: str) -> bool:
|
||||
return is_model_cached(
|
||||
self._get_model_path(model_size),
|
||||
weight_extensions=(".safetensors", ".bin", ".npz"),
|
||||
)
|
||||
|
||||
async def load_model(self, model_size: Optional[str] = None) -> None:
|
||||
if model_size is None:
|
||||
model_size = self.model_size
|
||||
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
|
||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||
|
||||
def _load_model_sync(self, model_size: str) -> None:
|
||||
from mlx_lm import load as mlx_load
|
||||
|
||||
progress_model_name = _progress_name(model_size)
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
repo = self._get_model_path(model_size)
|
||||
|
||||
with model_load_progress(progress_model_name, is_cached):
|
||||
logger.info("Loading Qwen3 %s via MLX...", model_size)
|
||||
# See the PyTorch loader comment — no offline forcing (issue #841).
|
||||
loaded = mlx_load(repo)
|
||||
|
||||
# mlx_lm.load returns (model, tokenizer) by default and
|
||||
# (model, tokenizer, config) when return_config=True.
|
||||
self.model = loaded[0]
|
||||
self.tokenizer = loaded[1]
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
logger.info("Qwen3 %s (MLX) loaded successfully", model_size)
|
||||
|
||||
def unload_model(self) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
del self.model
|
||||
del self.tokenizer
|
||||
self.model = None
|
||||
self.tokenizer = None
|
||||
self._current_model_size = None
|
||||
logger.info("Qwen3 (MLX) unloaded")
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
|
||||
temperature: float = DEFAULT_LLM_TEMPERATURE,
|
||||
model_size: Optional[str] = None,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
await self.load_model(model_size)
|
||||
return await asyncio.to_thread(
|
||||
self._generate_sync, prompt, system, max_tokens, temperature, examples
|
||||
)
|
||||
|
||||
def _generate_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
examples: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
from mlx_lm import generate as mlx_generate
|
||||
from mlx_lm.sample_utils import make_sampler
|
||||
|
||||
messages = _build_messages(prompt, system, examples)
|
||||
chat_prompt = self.tokenizer.apply_chat_template(
|
||||
messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=False,
|
||||
)
|
||||
|
||||
sampler = make_sampler(temp=temperature, top_p=0.9) if temperature > 0 else None
|
||||
text = mlx_generate(
|
||||
self.model,
|
||||
self.tokenizer,
|
||||
prompt=chat_prompt,
|
||||
max_tokens=max_tokens,
|
||||
sampler=sampler,
|
||||
verbose=False,
|
||||
)
|
||||
return text.strip()
|
||||
+384
-55
@@ -22,24 +22,34 @@ def is_apple_silicon():
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
def build_server(cuda=False):
|
||||
def build_server(cuda=False, rocm=False):
|
||||
"""Build Python server as standalone binary.
|
||||
|
||||
Args:
|
||||
cuda: If True, build with CUDA support and name the binary
|
||||
voicebox-server-cuda instead of voicebox-server.
|
||||
rocm: If True, build with ROCm support and name the binary
|
||||
voicebox-server-rocm instead of voicebox-server.
|
||||
"""
|
||||
if cuda and rocm:
|
||||
raise ValueError("Cannot build with both CUDA and ROCm support")
|
||||
|
||||
backend_dir = Path(__file__).parent
|
||||
|
||||
binary_name = "voicebox-server-cuda" if cuda else "voicebox-server"
|
||||
if rocm:
|
||||
binary_name = "voicebox-server-rocm"
|
||||
elif cuda:
|
||||
binary_name = "voicebox-server-cuda"
|
||||
else:
|
||||
binary_name = "voicebox-server"
|
||||
|
||||
# PyInstaller arguments
|
||||
# CUDA builds use --onedir so we can split the output into two archives:
|
||||
# CUDA and ROCm builds use --onedir so we can split the output into two archives:
|
||||
# 1. Server core (~200-400MB) — versioned with the app
|
||||
# 2. CUDA libs (~2GB) — versioned independently (only redownloaded on
|
||||
# CUDA toolkit / torch major version changes)
|
||||
# 2. GPU libs (~2GB) — versioned independently (only redownloaded on
|
||||
# GPU toolkit / torch major version changes)
|
||||
# CPU builds remain --onefile for simplicity.
|
||||
pack_mode = "--onedir" if cuda else "--onefile"
|
||||
pack_mode = "--onedir" if (cuda or rocm) else "--onefile"
|
||||
args = [
|
||||
"server.py", # Use server.py as entry point instead of main.py
|
||||
pack_mode,
|
||||
@@ -295,25 +305,102 @@ def build_server(cuda=False):
|
||||
"unidic_lite",
|
||||
"--hidden-import",
|
||||
"loguru",
|
||||
# MCP server — Streamable-HTTP endpoint and the 4 voicebox.* tools.
|
||||
# FastMCP pulls in a chain of deps (mcp, cyclopts, openapi-pydantic,
|
||||
# etc.) that don't auto-discover cleanly under PyInstaller, so we
|
||||
# collect them whole. Small compared to torch.
|
||||
"--hidden-import",
|
||||
"backend.mcp_server",
|
||||
"--hidden-import",
|
||||
"backend.mcp_server.server",
|
||||
"--hidden-import",
|
||||
"backend.mcp_server.tools",
|
||||
"--hidden-import",
|
||||
"backend.mcp_server.context",
|
||||
"--hidden-import",
|
||||
"backend.mcp_server.resolve",
|
||||
"--hidden-import",
|
||||
"backend.mcp_server.events",
|
||||
"--collect-all",
|
||||
"fastmcp",
|
||||
"--collect-all",
|
||||
"mcp",
|
||||
"--hidden-import",
|
||||
"sse_starlette",
|
||||
]
|
||||
)
|
||||
|
||||
# Add CUDA-specific hidden imports
|
||||
if cuda:
|
||||
logger.info("Building with CUDA support")
|
||||
if sys.version_info >= (3, 13):
|
||||
args.extend(["--hidden-import", "audioop"])
|
||||
|
||||
# Add CUDA/ROCm-specific hidden imports
|
||||
if cuda or rocm:
|
||||
variant = "ROCm" if rocm else "CUDA"
|
||||
logger.info("Building with %s support", variant)
|
||||
gpu_hidden = [
|
||||
"--hidden-import",
|
||||
"torch.cuda",
|
||||
]
|
||||
# cudnn is NVIDIA-specific; ROCm uses MIOpen under the abstraction layer
|
||||
if cuda:
|
||||
gpu_hidden.extend(
|
||||
[
|
||||
"--hidden-import",
|
||||
"torch.backends.cudnn",
|
||||
]
|
||||
)
|
||||
args.extend(gpu_hidden)
|
||||
|
||||
if rocm:
|
||||
# rocm_sdk imports its backend packages dynamically via
|
||||
# importlib.import_module(py_package_name), which PyInstaller's
|
||||
# static analyzer cannot see. We must collect them explicitly —
|
||||
# otherwise only the pure-python rocm_sdk wrapper ships and
|
||||
# rocm_sdk.find_libraries crashes with UnboundLocalError at boot.
|
||||
#
|
||||
# The backend packages also contain the HIP/MIOpen/hipBLAS DLLs
|
||||
# under bin/ (plus ~750 MB of tensile kernel files under
|
||||
# bin/rocblas/library and bin/hipblaslt/library) — collect-all
|
||||
# walks the tree recursively so both DLLs and kernel data are
|
||||
# bundled. See rocm_sdk/_dist_info.py for the package mapping.
|
||||
args.extend(
|
||||
[
|
||||
"--collect-all",
|
||||
"rocm_sdk",
|
||||
"--collect-all",
|
||||
"_rocm_sdk_core",
|
||||
"--collect-all",
|
||||
"_rocm_sdk_libraries_custom",
|
||||
"--collect-all",
|
||||
"rocm_sdk_core",
|
||||
"--collect-all",
|
||||
"rocm_sdk_libraries_custom",
|
||||
"--hidden-import",
|
||||
"torch.cuda",
|
||||
"_rocm_sdk_core",
|
||||
"--hidden-import",
|
||||
"torch.backends.cudnn",
|
||||
"_rocm_sdk_libraries_custom",
|
||||
"--hidden-import",
|
||||
"rocm_sdk_core",
|
||||
"--hidden-import",
|
||||
"rocm_sdk_libraries_custom",
|
||||
"--copy-metadata",
|
||||
"rocm",
|
||||
"--copy-metadata",
|
||||
"rocm-sdk-core",
|
||||
"--copy-metadata",
|
||||
"rocm-sdk-libraries-custom",
|
||||
# Repair rocm_sdk.find_libraries (masks UnboundLocalError
|
||||
# with a readable ModuleNotFoundError on missing backends).
|
||||
"--runtime-hook",
|
||||
"pyi_rth_rocm_sdk.py",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
|
||||
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||
# modules and the binary DLLs.
|
||||
|
||||
# Exclude NVIDIA CUDA packages from non-CUDA builds to keep binary small.
|
||||
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||
# modules and the binary DLLs. This applies to CPU and ROCm builds.
|
||||
if not cuda:
|
||||
nvidia_packages = [
|
||||
"nvidia",
|
||||
"nvidia.cublas",
|
||||
@@ -332,8 +419,8 @@ def build_server(cuda=False):
|
||||
for pkg in nvidia_packages:
|
||||
args.extend(["--exclude-module", pkg])
|
||||
|
||||
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
|
||||
if is_apple_silicon() and not cuda:
|
||||
# Add MLX-specific imports if building on Apple Silicon (never for GPU builds)
|
||||
if is_apple_silicon() and not cuda and not rocm:
|
||||
logger.info("Building for Apple Silicon - including MLX dependencies")
|
||||
args.extend(
|
||||
[
|
||||
@@ -351,10 +438,16 @@ def build_server(cuda=False):
|
||||
"mlx_audio.tts",
|
||||
"--hidden-import",
|
||||
"mlx_audio.stt",
|
||||
"--hidden-import",
|
||||
"mlx_lm",
|
||||
"--hidden-import",
|
||||
"backend.backends.qwen_llm_backend",
|
||||
"--collect-submodules",
|
||||
"mlx",
|
||||
"--collect-submodules",
|
||||
"mlx_audio",
|
||||
"--collect-submodules",
|
||||
"mlx_lm",
|
||||
# Use --collect-all so PyInstaller bundles both data files AND
|
||||
# native shared libraries (.dylib, .metallib) for MLX.
|
||||
# Previously only --collect-data was used, which caused MLX to
|
||||
@@ -364,9 +457,14 @@ def build_server(cuda=False):
|
||||
"mlx",
|
||||
"--collect-all",
|
||||
"mlx_audio",
|
||||
# mlx_lm ships chat_templates/ JSON files and loads tool_parsers
|
||||
# submodules dynamically via importlib at tokenizer load time,
|
||||
# which --hidden-import alone can't resolve.
|
||||
"--collect-all",
|
||||
"mlx_lm",
|
||||
]
|
||||
)
|
||||
elif not cuda:
|
||||
elif not cuda and not rocm:
|
||||
logger.info("Building for non-Apple Silicon platform - PyTorch only")
|
||||
|
||||
dist_dir = str(backend_dir / "dist")
|
||||
@@ -387,43 +485,128 @@ def build_server(cuda=False):
|
||||
os.chdir(backend_dir)
|
||||
|
||||
# For CPU builds on Windows, ensure we're using CPU-only torch.
|
||||
# If CUDA torch is installed (local dev), swap to CPU torch before building,
|
||||
# then restore CUDA torch after. This prevents PyInstaller from bundling
|
||||
# ~3GB of CUDA DLLs into the CPU binary.
|
||||
restore_cuda = False
|
||||
if not cuda and platform.system() == "Windows":
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
|
||||
)
|
||||
has_cuda_torch = bool(result.stdout.strip())
|
||||
if has_cuda_torch:
|
||||
logger.info("CUDA torch detected — installing CPU torch for CPU build...")
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"torchaudio",
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cpu",
|
||||
"--force-reinstall",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
restore_cuda = True
|
||||
|
||||
# Run PyInstaller
|
||||
# If CUDA or ROCm torch is installed (local dev), swap to CPU torch before
|
||||
# building, then restore afterwards. This prevents PyInstaller from bundling
|
||||
# GPU libraries into the CPU binary.
|
||||
restore_torch = None
|
||||
try:
|
||||
if not cuda and not rocm and platform.system() == "Windows":
|
||||
import subprocess
|
||||
|
||||
cuda_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
|
||||
)
|
||||
rocm_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if cuda_result.stdout.strip():
|
||||
restore_torch = "cuda"
|
||||
logger.info("CUDA torch detected — installing CPU torch for CPU build...")
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"torchaudio",
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cpu",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif rocm_result.stdout.strip():
|
||||
restore_torch = "rocm"
|
||||
logger.info("ROCm torch detected — installing CPU torch for CPU build...")
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"torchaudio",
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cpu",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# For ROCm builds on Windows, ensure ROCm torch is installed.
|
||||
if rocm and platform.system() == "Windows":
|
||||
import subprocess
|
||||
|
||||
if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12):
|
||||
raise RuntimeError(
|
||||
"ROCm wheels are cp312-cp312-specific; "
|
||||
f"got {sys.implementation.name} {sys.version.split()[0]}. "
|
||||
"Use CPython 3.12 to build the ROCm binary."
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
|
||||
)
|
||||
has_rocm_torch = bool(result.stdout.strip())
|
||||
if not has_rocm_torch:
|
||||
logger.info("ROCm torch not detected — installing ROCm torch for ROCm build...")
|
||||
|
||||
# Determine what to restore BEFORE overwriting the environment
|
||||
cuda_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if cuda_result.stdout.strip():
|
||||
restore_torch = "cuda"
|
||||
else:
|
||||
restore_torch = "cpu"
|
||||
|
||||
# Now overwrite the environment safely
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_core-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_devel-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_libraries_custom-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm-7.2.1.tar.gz",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Run PyInstaller
|
||||
PyInstaller.__main__.run(args)
|
||||
finally:
|
||||
# Restore CUDA torch if we swapped it out (even on build failure)
|
||||
if restore_cuda:
|
||||
# Restore torch if we swapped it out (even on build failure)
|
||||
if restore_torch == "cuda":
|
||||
logger.info("Restoring CUDA torch...")
|
||||
import subprocess
|
||||
|
||||
@@ -439,20 +622,166 @@ def build_server(cuda=False):
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cu128",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif restore_torch == "rocm":
|
||||
logger.info("Restoring ROCm torch...")
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif restore_torch == "cpu":
|
||||
logger.info("Restoring CPU torch...")
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"torchaudio",
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cpu",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
|
||||
|
||||
|
||||
def build_shim():
|
||||
"""Build the voicebox-mcp stdio shim as a tiny standalone binary.
|
||||
|
||||
This is the bridge for MCP clients that only speak stdio — it proxies
|
||||
JSON-RPC to the main voicebox-server's /mcp endpoint. Keep it small: no
|
||||
torch, no ML deps, just httpx + asyncio.
|
||||
"""
|
||||
backend_dir = Path(__file__).parent
|
||||
|
||||
args = [
|
||||
"mcp_shim/__main__.py",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"voicebox-mcp",
|
||||
# Stdio-only — no console hiding needed on Windows since the parent
|
||||
# MCP client is spawning this as a child process and wants stdio.
|
||||
"--hidden-import",
|
||||
"backend.mcp_shim",
|
||||
"--hidden-import",
|
||||
"backend.mcp_shim.__main__",
|
||||
"--hidden-import",
|
||||
"httpx",
|
||||
"--hidden-import",
|
||||
"httpx._transports.default",
|
||||
"--hidden-import",
|
||||
"anyio",
|
||||
# Exclude everything heavy that httpx/asyncio don't actually need so
|
||||
# the binary stays tiny (~15 MB instead of ~400 MB).
|
||||
"--exclude-module",
|
||||
"torch",
|
||||
"--exclude-module",
|
||||
"transformers",
|
||||
"--exclude-module",
|
||||
"mlx",
|
||||
"--exclude-module",
|
||||
"mlx_audio",
|
||||
"--exclude-module",
|
||||
"mlx_lm",
|
||||
"--exclude-module",
|
||||
"qwen_tts",
|
||||
"--exclude-module",
|
||||
"chatterbox",
|
||||
"--exclude-module",
|
||||
"zipvoice",
|
||||
"--exclude-module",
|
||||
"tada",
|
||||
"--exclude-module",
|
||||
"kokoro",
|
||||
"--exclude-module",
|
||||
"misaki",
|
||||
"--exclude-module",
|
||||
"spacy",
|
||||
"--exclude-module",
|
||||
"librosa",
|
||||
"--exclude-module",
|
||||
"numba",
|
||||
"--exclude-module",
|
||||
"numpy",
|
||||
"--exclude-module",
|
||||
"pedalboard",
|
||||
"--exclude-module",
|
||||
"fastapi",
|
||||
"--exclude-module",
|
||||
"uvicorn",
|
||||
"--exclude-module",
|
||||
"sqlalchemy",
|
||||
"--exclude-module",
|
||||
"fastmcp",
|
||||
"--exclude-module",
|
||||
"mcp",
|
||||
]
|
||||
|
||||
dist_dir = str(backend_dir / "dist")
|
||||
build_dir = str(backend_dir / "build")
|
||||
args.extend(
|
||||
[
|
||||
"--distpath",
|
||||
dist_dir,
|
||||
"--workpath",
|
||||
build_dir,
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
]
|
||||
)
|
||||
|
||||
os.chdir(backend_dir)
|
||||
PyInstaller.__main__.run(args)
|
||||
logger.info("Shim built: %s", backend_dir / "dist" / "voicebox-mcp")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
||||
parser = argparse.ArgumentParser(description="Build voicebox binaries")
|
||||
parser.add_argument(
|
||||
"--cuda",
|
||||
action="store_true",
|
||||
help="Build CUDA-enabled binary (voicebox-server-cuda)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rocm",
|
||||
action="store_true",
|
||||
help="Build ROCm-enabled binary (voicebox-server-rocm) for AMD GPUs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shim",
|
||||
action="store_true",
|
||||
help="Build the voicebox-mcp stdio shim binary instead of the server",
|
||||
)
|
||||
cli_args = parser.parse_args()
|
||||
build_server(cuda=cli_args.cuda)
|
||||
if cli_args.shim:
|
||||
build_shim()
|
||||
else:
|
||||
build_server(cuda=cli_args.cuda, rocm=cli_args.rocm)
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
|
||||
return None
|
||||
|
||||
stored_path = Path(path)
|
||||
# Empty paths (e.g. failed generations) must not resolve to the data
|
||||
# dir itself, which exists and would defeat the callers' 404 guards.
|
||||
# Path("") is truthy, so check parts rather than the raw value.
|
||||
if not stored_path.parts:
|
||||
return None
|
||||
if stored_path.is_absolute():
|
||||
rebased_path = _path_relative_to_any_data_dir(stored_path)
|
||||
if rebased_path is not None:
|
||||
@@ -119,6 +124,13 @@ def get_generations_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def get_captures_dir() -> Path:
|
||||
"""Get captures directory path."""
|
||||
path = _data_dir / "captures"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Get cache directory path."""
|
||||
path = _data_dir / "cache"
|
||||
@@ -131,3 +143,17 @@ def get_models_dir() -> Path:
|
||||
path = _data_dir / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
|
||||
# pairing (voicebox.sh), the API owns sync + account endpoints
|
||||
# (api.voicebox.sh). Override both for local development, e.g.
|
||||
# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
|
||||
def get_cloud_web_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
|
||||
|
||||
|
||||
def get_cloud_api_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
|
||||
|
||||
@@ -8,10 +8,15 @@ without changing any importers.
|
||||
from .models import (
|
||||
Base,
|
||||
AudioChannel,
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
ChannelDeviceMapping,
|
||||
CloudSettings,
|
||||
EffectPreset,
|
||||
Generation,
|
||||
GenerationSettings,
|
||||
GenerationVersion,
|
||||
MCPClientBinding,
|
||||
ProfileChannelMapping,
|
||||
ProfileSample,
|
||||
Project,
|
||||
@@ -25,10 +30,15 @@ __all__ = [
|
||||
# Models
|
||||
"Base",
|
||||
"AudioChannel",
|
||||
"Capture",
|
||||
"CaptureSettings",
|
||||
"ChannelDeviceMapping",
|
||||
"CloudSettings",
|
||||
"EffectPreset",
|
||||
"Generation",
|
||||
"GenerationSettings",
|
||||
"GenerationVersion",
|
||||
"MCPClientBinding",
|
||||
"ProfileChannelMapping",
|
||||
"ProfileSample",
|
||||
"Project",
|
||||
|
||||
@@ -17,10 +17,17 @@ Adding a new migration:
|
||||
(idempotent) and print a short message when it does real work.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from ..utils.capture_chords import (
|
||||
default_push_to_talk_chord,
|
||||
default_toggle_to_talk_chord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +41,8 @@ def run_migrations(engine) -> None:
|
||||
_migrate_generations(engine, inspector, tables)
|
||||
_migrate_effect_presets(engine, inspector, tables)
|
||||
_migrate_generation_versions(engine, inspector, tables)
|
||||
_migrate_capture_settings(engine, inspector, tables)
|
||||
_migrate_mcp_bindings(engine, inspector, tables)
|
||||
_normalize_storage_paths(engine, tables)
|
||||
|
||||
|
||||
@@ -125,6 +134,8 @@ def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
|
||||
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
|
||||
if "version_id" not in columns:
|
||||
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
|
||||
if "volume" not in columns:
|
||||
_add_column(engine, "story_items", "volume FLOAT NOT NULL DEFAULT 1.0", "volume")
|
||||
|
||||
|
||||
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
||||
@@ -146,6 +157,8 @@ def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
||||
_add_column(engine, "profiles", "design_prompt TEXT", "design_prompt")
|
||||
if "default_engine" not in columns:
|
||||
_add_column(engine, "profiles", "default_engine VARCHAR", "default_engine")
|
||||
if "personality" not in columns:
|
||||
_add_column(engine, "profiles", "personality TEXT", "personality")
|
||||
|
||||
|
||||
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
|
||||
@@ -164,6 +177,13 @@ def _migrate_generations(engine, inspector, tables: set[str]) -> None:
|
||||
_add_column(engine, "generations", "model_size VARCHAR", "model_size")
|
||||
if "is_favorited" not in columns:
|
||||
_add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited")
|
||||
if "source" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"generations",
|
||||
"source VARCHAR NOT NULL DEFAULT 'manual'",
|
||||
"source",
|
||||
)
|
||||
|
||||
|
||||
def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
|
||||
@@ -182,6 +202,96 @@ def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
|
||||
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
|
||||
|
||||
|
||||
def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
|
||||
if "capture_settings" not in tables:
|
||||
return
|
||||
columns = _get_columns(inspector, "capture_settings")
|
||||
push_default = json.dumps(default_push_to_talk_chord())
|
||||
toggle_default = json.dumps(default_toggle_to_talk_chord())
|
||||
if "allow_auto_paste" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
"allow_auto_paste BOOLEAN NOT NULL DEFAULT 1",
|
||||
"allow_auto_paste",
|
||||
)
|
||||
if "default_playback_voice_id" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
"default_playback_voice_id VARCHAR",
|
||||
"default_playback_voice_id",
|
||||
)
|
||||
if "chord_push_to_talk_keys" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
f"chord_push_to_talk_keys TEXT NOT NULL DEFAULT '{push_default}'",
|
||||
"chord_push_to_talk_keys",
|
||||
)
|
||||
if "chord_toggle_to_talk_keys" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
f"chord_toggle_to_talk_keys TEXT NOT NULL DEFAULT '{toggle_default}'",
|
||||
"chord_toggle_to_talk_keys",
|
||||
)
|
||||
if "hotkey_enabled" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"capture_settings",
|
||||
"hotkey_enabled BOOLEAN NOT NULL DEFAULT 0",
|
||||
"hotkey_enabled",
|
||||
)
|
||||
|
||||
|
||||
def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
|
||||
"""Drop the legacy ``default_intent`` column and add ``default_personality``.
|
||||
|
||||
The intent tri-state (respond / rewrite / compose) has been collapsed
|
||||
to a boolean: when true, ``voicebox.speak`` rewrites input through the
|
||||
profile's personality LLM before TTS.
|
||||
"""
|
||||
if "mcp_client_bindings" not in tables:
|
||||
return
|
||||
columns = _get_columns(inspector, "mcp_client_bindings")
|
||||
if "default_personality" not in columns:
|
||||
_add_column(
|
||||
engine,
|
||||
"mcp_client_bindings",
|
||||
"default_personality BOOLEAN NOT NULL DEFAULT 0",
|
||||
"default_personality",
|
||||
)
|
||||
if "default_intent" in columns:
|
||||
if _supports_drop_column(engine):
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE mcp_client_bindings DROP COLUMN default_intent"))
|
||||
conn.commit()
|
||||
logger.info("Dropped legacy default_intent column from mcp_client_bindings")
|
||||
else:
|
||||
# ALTER TABLE … DROP COLUMN on SQLite requires 3.35+ (Mar
|
||||
# 2021). Production PyInstaller builds bundle Python 3.12
|
||||
# which links to SQLite 3.40+; this branch only fires for
|
||||
# dev environments running the backend directly against an
|
||||
# old system SQLite (Ubuntu 20.04 = 3.31, Debian 11 = 3.34).
|
||||
# Leaving the unused column in place is harmless — the ORM
|
||||
# only maps declared columns, so a stray one does no work
|
||||
# and gets no reads or writes.
|
||||
logger.warning(
|
||||
"SQLite %s too old to DROP COLUMN (need 3.35+); leaving unused default_intent column on mcp_client_bindings in place.",
|
||||
sqlite3.sqlite_version,
|
||||
)
|
||||
|
||||
|
||||
def _supports_drop_column(engine) -> bool:
|
||||
"""Whether ``ALTER TABLE … DROP COLUMN`` is supported by the dialect +
|
||||
runtime. Non-SQLite dialects (Postgres, MySQL) have supported it for
|
||||
decades; SQLite only gained the feature in 3.35."""
|
||||
if engine.dialect.name != "sqlite":
|
||||
return True
|
||||
return tuple(int(p) for p in sqlite3.sqlite_version.split(".")[:3]) >= (3, 35, 0)
|
||||
|
||||
|
||||
def _normalize_storage_paths(engine, tables: set[str]) -> None:
|
||||
"""Normalize stored file paths to be relative to the configured data dir."""
|
||||
from pathlib import Path
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user