diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 241a703c..6d70587e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.4.5 +current_version = 0.5.0 commit = True tag = True tag_name = v{new_version} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1559d692..500ad555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,69 @@ ## [Unreleased] +## [0.5.0] - 2026-04-22 + +**The Capture release.** Voicebox stops being a voice-cloning studio and becomes an AI voice studio. The loop closes in both directions: your voice goes into your computer through a global hotkey, and any agent's voice comes out of your computer through a voice you own. + +Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field in whatever app you were using. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Cline, Spacebot — speaks back through the same 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 actually says before it gets spoken. + +Everything still runs on your hardware. No cloud, no accounts, no audio leaving the machine. + +### 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 surfaces over your current app and walks through recording → transcribing → refining → done with a live elapsed timer during the clip. Your dictation lands as a clean transcript. +- **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 — the session rolls forward, you stop when you want. +- **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a platform-native paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Your clipboard is saved before and restored after, so nothing you had copied goes missing. +- **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, so you can pick the exact hardware signature you want to capture. +- **Defaults picked to stay out of your way.** macOS defaults deliberately avoid left-hand Cmd+Option chords so Cmd+Option+I (devtools), Cmd+Option+Esc (force quit), and Cmd+Option+Space (Spotlight) all remain yours. Windows defaults route around AltGr collisions on German/French/Spanish layouts where Ctrl+Alt synthesizes AltGr. +- **Accessibility permission is scoped.** The macOS permission prompt lives inline next to the auto-paste toggle in Settings → Captures, not as a global banner on every page. If permission isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. + +### 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, three new actions appear on the profile, each powered by a bundled Qwen3 LLM running entirely locally: + +- **Compose** — generate fresh utterances in the character's voice. Click again for variety. +- **Rewrite** — restate your text in the character's voice while preserving every idea. High-fidelity mode for turning dictation into in-character speech. +- **Respond** — treat your text as a prompt and produce the character's reply. + +Temperatures are tuned per mode (compose hot for variety, rewrite cold for fidelity, respond balanced) and the character framing enforces "speech only" output — no narration, no action tags, no meta-commentary. The same LLM doubles as the refinement model, so there's one local LLM in the app, not two. + +### Agents — any MCP-aware agent gets a voice + +> **Note:** the MCP server implementation is not yet in this tag. The pieces it depends on — the personality/compose/rewrite/respond runtime, voice-binding-per-profile, and the on-screen pill as a generic surface — are all in place. The MCP route lands in a follow-up commit before the final 0.5.0 tag. + +Once the MCP server ships, any MCP-aware agent — Claude Code, Cursor, Cline, Spacebot — can call `voicebox.speak({ profile, text, intent })` and Voicebox will produce in-character speech in the bound voice. The on-screen pill surfaces whenever an agent is talking so you always see what's coming out of your machine. Pin Claude Code to Morgan, Cursor to Scarlett, Spacebot to its own voice — you can tell which agent is speaking without looking. + +### Refinement hardening + +Refinement shipped earlier; 0.5.0 closes the stubborn edge cases: + +- **Deterministic 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. Legitimate repetition ("no, no, no, no, no") doesn't cross the threshold. +- **Refinement flags snapshot per capture.** `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. +- **Ten-transcript evaluation harness** (`backend/tests/test_refinement_samples.py`) scoring prompt leaks, answer leaks, loop echoes, filler removal, length-ratio outliers, substring preservation ("npm install", "handleSubmit"), and question-mark survival against every bundled refinement model size. +- **Refinement 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, copy-transcript-to-clipboard, auto-paste into focused field (with inline accessibility prompt). +- **Transcription**: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock, archive-audio toggle. +- **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. +- **Storage**: retention (forever / 90d / 30d / 7d), clear-all-captures. + +### Windows parity + +- **Synthetic paste** via `SendInput` with correct scan codes, plus a `SetForegroundWindow` + `AttachThreadInput` handshake to defeat foreground-lock when pasting into a window that wasn't frontmost at chord-start. +- **Right-hand default chord** (Ctrl+Shift) to avoid AltGr collisions on layouts where Ctrl+Alt is the compose key. +- **Focus capture via UIAutomation** for control-class identity and `GetForegroundWindow` for window identity, snapshotted at chord-start so paste lands in the original field even if focus drifts during transcribe/refine. +- **UAC/UIPI caveat**: synthetic paste into an elevated window from a non-elevated Voicebox is blocked by Windows itself. Run Voicebox elevated if you regularly dictate into elevated apps. + +### Landing page — /capture + +New `/capture` route tells the Capture story end-to-end. Hero line: *"Just talk to your computer."* Three pillars — multi-engine STT (Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR), refined transcripts, agents speaking in voices you own. Drop-in MCP config block for Claude Code / Cursor / Cline. Agent-integration section showing per-client voice binding. + ## [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 +720,8 @@ 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 +[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.5.0...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 diff --git a/README.md b/README.md index 8d220202..6ed6c05c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@

Voicebox

- The open-source voice synthesis studio.
- Clone voices. Generate speech. Apply effects. Build voice-powered apps.
- All running locally on your machine. + The open-source AI voice studio.
+ Clone any voice. Generate speech. Dictate into any app. Talk to agents in voices you own.
+ The full voice I/O stack, running locally on your machine.

@@ -63,17 +63,23 @@ ## 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 route captured speech through a local LLM into a cloned voice for end-to-end voice conversations with AI agents. -- **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 persona LLM, 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 hotkey dictation, in-app mic on every text field, 4 STT engines (Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR) +- **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 +- **Persona loop** — speak to a local LLM, hear the reply in any voice you've cloned, entirely offline +- **Pipeline routing** — configurable source → transform → sink chains, with a built-in MCP sink for Claude Code, Cursor, and Cline +- **API-first** — REST + WebSocket API 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 +191,81 @@ 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 — the transcript pastes into the focused text field. Or hit the mic on any Voicebox text input and dictate directly into the app. + +- **Global hotkey** — hold-to-speak or tap-to-toggle, configurable +- **Target-aware paste** — accessibility-verified injection into text fields, atomic clipboard save/restore so your clipboard isn't clobbered +- **In-app mic button** on every Voicebox text field — generation form, profile descriptions, story titles, anywhere you'd type +- **Streaming transcription** via `/transcribe/stream` WebSocket — partial transcripts land as you speak +- **LLM refinement** — optional cleanup of ums, stutters, and false starts before paste + +### Multi-Engine STT + +Four STT engines with different strengths, switchable per-capture: + +| Engine | Languages | Strengths | +| ------------------ | --------- | ------------------------------------------------------------------- | +| **Whisper** | 99 | The default. Broad language support, mature, battle-tested | +| **Whisper Turbo** | 99 | ~8x faster than Whisper large, minimal quality loss | +| **Parakeet v3** | 25 | Current quality leader for non-English local STT, very fast | +| **Qwen3-ASR 0.6B** | 50+ | Highest multilingual quality, int8 quantized for cross-platform use | + +### 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** with a different model, or edit the transcript inline +- **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 for voice cloning +- **Send to** — clipboard, file, webhook, MCP sink, or back into the generation pipeline +- **Configurable retention** — keep everything or auto-expire old captures + +### 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`, `rest`, and `speaking` are all states of the same OS-level overlay +- **Per-agent voice binding** — Claude Code in Morgan, Cursor in Scarlett, so you can tell which agent is talking without looking +- **Always visible** — no silent background TTS; every agent-initiated speech surfaces the pill +- **Global mute + per-source rate limits** — a panic button for runaway agents + +### Persona Loop + +One flow on top of `speak()`: STT → persona LLM → `speak(reply)`. Voice profiles gain optional personality metadata and default LLM behavior. End-to-end voice-to-voice with a cloned identity transforming the content, not just reading it. + +- **Local LLM** — Qwen 3.5 0.8B / 2B / 4B, same runtime as TTS (MLX on Apple Silicon, PyTorch elsewhere) +- **Voice profile personas** — optional personality description and default LLM behavior per profile +- **Pipeline-native** — STT → persona LLM → TTS is a preset, configurable like any other route +- **Voice-to-voice ready** — when end-to-end speech LLMs (Moshi, GLM-4-Voice, Qwen2.5 Omni) land, they slot in as a single transform and the pipeline shape stays the same + +Use cases: agent dev loops (talk to Claude Code, hear it back in a cloned voice), interactive characters for games and narrative tools, speech assistance for people who can't speak in their original voice. + +### Pipeline Routing + +Every voice event in Voicebox flows through the same shape: **Source → Transforms → Sinks**. Build presets, share them, invoke them from shell scripts and agent harnesses. + +| Sources | Transforms | Sinks | +| -------------------- | ------------------- | --------------------------- | +| Global hotkey | STT model | Paste into focused field | +| In-app mic | Refinement LLM | Clipboard | +| Long-form recorder | Persona LLM | File on disk | +| File drop | Translation (later) | HTTP webhook | +| API call (WS / HTTP) | | **MCP server** (agent sink) | +| | | TTS loopback (cloned voice) | + +Presets are addressable by ID via `POST /pipelines/{id}/run`. The MCP sink means Claude Code, Cursor, and Cline get voice I/O one checkbox away — no custom integration. ### Model Management @@ -214,7 +289,7 @@ 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 full REST + WebSocket API for integrating voice I/O into your own apps and agents. ```bash # Generate speech @@ -222,16 +297,51 @@ curl -X POST http://localhost:17493/generate \ -H "Content-Type: application/json" \ -d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}' +# Agent voice output — any app or script can speak in a cloned voice +curl -X POST http://localhost:17493/speak \ + -H "Content-Type: application/json" \ + -d '{"text": "Deploy complete.", "profile_id": "morgan"}' + +# Transcribe an audio file +curl -X POST http://localhost:17493/transcribe \ + -F "audio=@recording.wav" \ + -F "model=whisper-turbo" + +# Run a user-configured pipeline (STT → LLM → TTS, for example) +curl -X POST http://localhost:17493/pipelines/my-agent-reply/run \ + -F "audio=@input.wav" + # List voice profiles curl http://localhost:17493/profiles - -# Create a profile -curl -X POST http://localhost:17493/profiles \ - -H "Content-Type: application/json" \ - -d '{"name": "My Voice", "language": "en"}' ``` -**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation. +Streaming dictation runs over WebSocket at `ws://localhost:17493/transcribe/stream` — audio frames in, partial transcripts out. + +### MCP server + +Voicebox ships an MCP server so any MCP-aware agent (Claude Code, Cursor, Cline, etc.) can speak in any voice you've cloned with a single tool call. Add one entry to your MCP config: + +```json +{ + "mcpServers": { + "voicebox": { + "command": "voicebox", + "args": ["mcp"] + } + } +} +``` + +The `voicebox.speak` tool is then available in the agent: + +```ts +await voicebox.speak({ + text: "Tests passing. Ready to merge.", + profile: "Morgan", +}); +``` + +**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://localhost:17493/docs`. @@ -239,30 +349,33 @@ Full API documentation available at `http://localhost:17493/docs`. ## Tech Stack -| Layer | Technology | -| ------------- | ------------------------------------------------- | -| Desktop App | Tauri (Rust) | -| Frontend | React, TypeScript, Tailwind CSS | -| State | Zustand, React Query | -| Backend | FastAPI (Python) | +| Layer | Technology | +| ------------- | ------------------------------------------------------------------------------- | +| Desktop App | Tauri (Rust) | +| Frontend | React, TypeScript, Tailwind CSS | +| State | Zustand, React Query | +| Backend | FastAPI (Python) | | TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro | -| 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 Engines | Whisper, Whisper Turbo, Parakeet v3, Qwen3-ASR | +| LLM | Qwen 3.5 (0.8B / 2B / 4B), shared runtime with TTS/STT | +| Native Shim | Rust crate 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 | +| ---------------------------------- | --------------------------------------------------------------------- | +| **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. diff --git a/app/package.json b/app/package.json index a94b30b6..56bf162a 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@voicebox/app", - "version": "0.4.5", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/app/src/App.tsx b/app/src/App.tsx index d277c802..cb57a010 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,11 +1,13 @@ 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 { 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'; @@ -13,6 +15,11 @@ import { router } from '@/router'; import { useLogStore } from '@/stores/logStore'; import { 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. * Prevents misidentifying an unrelated service on the same port. @@ -64,6 +71,17 @@ const LOADING_MESSAGES = [ ]; function App() { + // 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 ; + } + return ; +} + +function MainApp() { const platform = usePlatform(); const [serverReady, setServerReady] = useState(false); const [startupError, setStartupError] = useState(null); @@ -73,6 +91,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) { diff --git a/app/src/components/AccessibilityGate/AccessibilityGate.tsx b/app/src/components/AccessibilityGate/AccessibilityGate.tsx new file mode 100644 index 00000000..50c7f2d5 --- /dev/null +++ b/app/src/components/AccessibilityGate/AccessibilityGate.tsx @@ -0,0 +1,127 @@ +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 { 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 => { + if (!platform.metadata.isTauri) return true; + setChecking(true); + try { + const trusted = await invoke('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 { 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 ( +

+
+ +
+

+ Grant Accessibility permission to enable auto-paste +

+

+ Voicebox needs System Settings → Privacy & Security → Accessibility + to paste transcriptions into other apps. Your dictation still lands + in the Captures tab without it. +

+
+ + +
+ {stillMissing && !checking && ( +

+ Still not detected. macOS usually requires quitting and reopening + Voicebox after toggling the permission. +

+ )} +
+
+
+ ); +} diff --git a/app/src/components/CapturePill/CapturePill.tsx b/app/src/components/CapturePill/CapturePill.tsx new file mode 100644 index 00000000..46f1e6d5 --- /dev/null +++ b/app/src/components/CapturePill/CapturePill.tsx @@ -0,0 +1,190 @@ +import { motion } from 'framer-motion'; +import { AlertCircle } from 'lucide-react'; +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' + | 'completed' + | 'rest' + | 'error'; + +const PILL_LABELS: Record, string> = { + recording: 'Recording', + transcribing: 'Transcribing', + refining: 'Refining', + completed: 'Done', +}; + +function barModeFor( + state: Exclude, +): 'generating' | 'playing' | 'idle' { + if (state === 'recording') return 'playing'; + if (state === 'completed' || state === 'rest') return 'idle'; + return 'generating'; +} + +export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) { + return ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ); +} + +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; +}) { + if (state === 'error') { + return ( + + ); + } + + const visible = state !== 'rest'; + const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state]; + const barMode = barModeFor(state); + + const dot = ( + + {state === 'recording' && ( + + )} + + + ); + + const stopButton = onStop && state === 'recording' ? ( + + ) : 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 ( +
+ {stopButton} + + {labelText} + + + + {formatElapsed(elapsedMs)} + +
+ ); +} + +function ErrorPill({ + message, + onDismiss, + className, +}: { + message: string; + onDismiss?: () => void; + className?: string; +}) { + 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 ( + + ); +} + diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx new file mode 100644 index 00000000..b7ec8692 --- /dev/null +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -0,0 +1,768 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { + Captions, + Check, + ChevronDown, + CircleDot, + Copy, + FileAudio, + Loader2, + Mic, + Play, + Send, + Settings2, + Sparkles, + Square, + Trash2, + Upload, + Volume2, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CapturePill } from '@/components/CapturePill/CapturePill'; +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 { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +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 { useCaptureSettings } from '@/lib/hooks/useSettings'; +import { cn } from '@/lib/utils/cn'; +import { usePlayerStore } from '@/stores/playerStore'; + +const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm'; + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + const diffMs = Date.now() - then; + const mins = Math.round(diffMs / 60_000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + const days = Math.round(hrs / 24); + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + return new Date(iso).toLocaleDateString(); +} + +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 formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function snippetOf(capture: CaptureResponse): string { + const source = capture.transcript_refined || capture.transcript_raw || ''; + return source.trim() || '(no transcript)'; +} + +function SourceBadge({ source }: { source: CaptureSource }) { + const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio; + const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File'; + return ( + + + {label} + + ); +} + +function FakeWaveform({ seed = 1, className }: { seed?: number; className?: string }) { + const bars = useMemo(() => { + return Array.from({ length: 72 }).map((_, i) => { + const h = + 28 + + Math.sin(i * 0.35 + seed) * 22 + + Math.cos(i * 0.81 + seed * 2) * 14 + + Math.sin(i * 1.7 + seed * 3) * 8; + return Math.max(6, Math.min(96, h)); + }); + }, [seed]); + + return ( +
+ {bars.map((h, i) => ( +
+ ))} +
+ ); +} + +type PlaybackState = 'idle' | 'generating' | 'playing'; + +function voiceGradient(profileId: string): string { + const gradients = [ + 'from-blue-400 to-indigo-500', + 'from-emerald-400 to-teal-500', + 'from-purple-500 to-fuchsia-500', + 'from-amber-400 to-rose-500', + 'from-rose-400 to-pink-500', + 'from-cyan-400 to-sky-500', + ]; + let hash = 0; + for (let i = 0; i < profileId.length; i++) hash = (hash * 31 + profileId.charCodeAt(i)) | 0; + return gradients[Math.abs(hash) % gradients.length]; +} + +export function CapturesTab() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + const fileInputRef = useRef(null); + const uploadInputRef = useRef(null); + + const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(''); + const [showRefined, setShowRefined] = useState(true); + const [playAsVoiceId, setPlayAsVoiceId] = useState(null); + const [playbackState, setPlaybackState] = useState('idle'); + + const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); + const audioUrl = usePlayerStore((s) => s.audioUrl); + const isPlayerVisible = !!audioUrl; + + const { settings: captureSettings } = useCaptureSettings(); + const sttModel = captureSettings?.stt_model ?? 'turbo'; + const llmModel = captureSettings?.llm_model ?? '0.6B'; + + 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]); + + // Default the Play-as voice to the first profile we see. + useEffect(() => { + if (!playAsVoiceId && profiles && profiles.length) { + setPlayAsVoiceId(profiles[0].id); + } + }, [profiles, playAsVoiceId]); + + // 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[] = []; + unlistens.push( + listen<{ capture: CaptureResponse }>('capture:created', (event) => { + const capture = event.payload?.capture; + if (capture) { + queryClient.setQueryData(['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; + const playAsVoice = profiles?.find((p) => p.id === playAsVoiceId) ?? null; + + const deleteMutation = useMutation({ + mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['captures'] }); + }, + onError: (err: Error) => { + toast({ title: 'Delete failed', 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('Capture has no transcript yet'); + 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; + const result = await apiClient.generateSpeech({ + profile_id: voice.id, + text, + language, + engine, + }); + return { capture, voice, result }; + }, + onSuccess: ({ capture, voice, result }) => { + if (result.audio_path && result.id) { + setAudioWithAutoPlay( + apiClient.getAudioUrl(result.id), + result.id, + voice.id, + `${voice.name} · ${capture.id.slice(0, 8)}`, + ); + setPlaybackState('playing'); + } + }, + onError: (err: Error) => { + setPlaybackState('idle'); + toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' }); + }, + }); + + // Pull playback state back to idle when the player closes out. + useEffect(() => { + if (!audioUrl) setPlaybackState('idle'); + }, [audioUrl]); + + const handleUploadClick = () => uploadInputRef.current?.click(); + + const handleUploadFile = (e: React.ChangeEvent, source: CaptureSource) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + session.uploadFile(file, source); + }; + + const handlePlayOriginal = () => { + if (!selected) return; + setAudioWithAutoPlay( + apiClient.getCaptureAudioUrl(selected.id), + `capture-${selected.id}`, + null, + `Capture · ${formatDate(selected.created_at)}`, + ); + }; + + 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: 'Transcript copied' }); + } catch { + toast({ title: 'Copy failed', variant: 'destructive' }); + } + }; + + const handlePlayAs = (voice?: VoiceProfileResponse) => { + if (!selected) return; + const target = voice ?? playAsVoice; + if (!target) { + toast({ + title: 'No voice profile', + description: 'Create a voice profile before using Play as.', + variant: 'destructive', + }); + return; + } + if (voice && voice.id !== playAsVoiceId) setPlayAsVoiceId(voice.id); + setPlaybackState('generating'); + playAsMutation.mutate({ capture: selected, voice: target }); + }; + + return ( +
+ handleUploadFile(e, 'file')} + className="hidden" + /> + handleUploadFile(e, 'file')} + className="hidden" + /> + + {/* Left: capture list */} +
+
+ +
+
+

Captures

+ + Beta + +
+
+ setSearch(e.target.value)} + className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+ +
+
+ {capturesLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {search ? ( +

No captures match "{search}"

+ ) : ( + <> +

No captures yet.

+ + + )} +
+ ) : ( + filtered.map((capture) => { + const isActive = selectedId === capture.id; + const refined = !!capture.transcript_refined; + return ( + + ); + }) + )} +
+
+
+ + {/* Right: capture detail */} +
+
+ + {/* Top action bar */} +
+
+
+ + + Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)} + · + Qwen3 · {llmModel} + +
+
+ {session.pillState !== 'hidden' && ( + + )} + {session.pillState === 'hidden' && ( + <> + + + + )} + +
+
+ + {selected ? ( +
+ {/* Meta row */} +
+ {formatDate(selected.created_at)} + {selected.language && ( + <> + · + {selected.language.toUpperCase()} + + )} + · + +
+ + {/* Audio player card */} +
+
+ + + + {formatDuration(selected.duration_ms)} + +
+
+ + {/* Transcript header */} +
+
+ + +
+
+ + {showRefined && selected.transcript_refined + ? `Refined with Qwen3 · ${selected.llm_model ?? llmModel}` + : selected.stt_model + ? `Transcribed with Whisper ${selected.stt_model}` + : null} + +
+ + {/* Transcript body */} +
+