personality: bool API, i18n across the app

- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
This commit is contained in:
Jamie Pine
2026-04-23 17:31:23 -07:00
parent 7c50e189cb
commit abf5dfda8c
41 changed files with 2146 additions and 1193 deletions
+15 -6
View File
@@ -7,6 +7,16 @@
## [Unreleased] ## [Unreleased]
### Personality — simpler API, better UX
Voice personalities have been collapsed from a three-mode (`compose` / `rewrite` / `respond`) API down to what they actually needed to be: a compose button and a boolean rewrite toggle.
- **Generate box UI.** When a profile has a personality set, two controls appear next to the generate button: a shuffle button that drops a fresh in-character line into the textarea, and a wand toggle that runs your input through the personality LLM before TTS. No more intent dropdown.
- **API surface.** `POST /generate` and `POST /speak` accept `personality: bool` in place of the removed `intent` field. `voicebox.speak` (MCP) takes `personality: bool`. The standalone `/profiles/{id}/rewrite`, `/profiles/{id}/respond`, and `/profiles/{id}/speak` endpoints are gone; `/profiles/{id}/compose` remains for the shuffle button.
- **MCP bindings.** `default_intent` has been replaced with `default_personality: bool`. Existing bindings with a non-null `default_intent` are migrated to `false`; re-enable the rewrite path from **Settings → MCP** if you were relying on it.
- **Respond mode is gone.** Letting the personality LLM *reply* to input text was only useful for agent-as-LLM flows nobody was actually building. Agents that want conversational replies can produce the reply themselves and pipe it into `voicebox.speak`.
## [0.5.0] - 2026-04-22 ## [0.5.0] - 2026-04-22
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full 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. **The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full 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.
@@ -24,19 +34,18 @@ When enabled, hold a key anywhere on your machine, speak, release — the transc
### Personality — voice profiles that speak for themselves ### 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: Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new actions appear next to the generate button, each powered by a bundled Qwen3 LLM running entirely locally:
- **Compose** — generate fresh utterances in the character's voice. Click again for variety. - **Compose** — drop a fresh in-character line into the textarea. Click again for variety, edit before speaking.
- **Rewrite** — restate your text in the character's voice while preserving every idea. High-fidelity mode for turning dictation into in-character speech. - **Speak in character** — a toggle that rewrites your input text in the character's voice before TTS, preserving every idea.
- **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. Temperatures are tuned per mode (compose hot for variety, rewrite cold for fidelity) 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 ### 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 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 `intent: compose | rewrite | respond` to run through the profile's personality LLM first - **`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 - **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path
- **`voicebox.list_captures`** — recent captures with their transcripts - **`voicebox.list_captures`** — recent captures with their transcripts
- **`voicebox.list_profiles`** — available voice profiles (cloned + preset) - **`voicebox.list_profiles`** — available voice profiles (cloned + preset)
+5 -6
View File
@@ -243,13 +243,12 @@ Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A,
### Voice Personalities ### Voice Personalities
Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Three new actions appear on the profile, each powered by a bundled Qwen3 LLM running entirely locally. 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** — generate a fresh utterance in the character's voice - **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take
- **Rewrite** — restate your text in their voice while preserving every idea - **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS
- **Respond** — treat your text as a prompt and produce the character's reply
Agents can invoke the same modes over MCP by passing `intent: "compose" | "rewrite" | "respond"` 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. 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). **Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
@@ -348,7 +347,7 @@ Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_capture
await voicebox.speak({ await voicebox.speak({
text: "Tests passing. Ready to merge.", text: "Tests passing. Ready to merge.",
profile: "Morgan", // optional — falls back to the per-client binding profile: "Morgan", // optional — falls back to the per-client binding
intent: "respond", // optional — runs text through the profile's personality LLM first personality: true, // optional — rewrites text through the profile's personality LLM first
}); });
``` ```
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react'; import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
@@ -81,6 +82,7 @@ export function useAccessibilityPermission() {
* already granted. * already granted.
*/ */
export function AccessibilityNotice() { export function AccessibilityNotice() {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission(); const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false); const [stillMissing, setStillMissing] = useState(false);
@@ -98,26 +100,23 @@ export function AccessibilityNotice() {
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" /> <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Grant Accessibility permission to enable auto-paste {t('captures.permissions.accessibility.title')}
</p> </p>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings → Privacy &amp; Security → Accessibility <Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
to paste transcriptions into other apps. Your dictation still lands
in the Captures tab without it.
</p> </p>
<div className="flex items-center gap-2 pt-1.5"> <div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5"> <Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
Open Settings {t('captures.permissions.accessibility.openSettings')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}> <Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking…' : "I've enabled it"} {checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
</Button> </Button>
</div> </div>
{stillMissing && !checking && ( {stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1"> <p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
Still not detected. macOS usually requires quitting and reopening {t('captures.permissions.accessibility.stillMissing')}
Voicebox after toggling the permission.
</p> </p>
)} )}
</div> </div>
+14 -10
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
/** /**
@@ -15,12 +16,12 @@ export type PillState =
| 'rest' | 'rest'
| 'error'; | 'error';
const PILL_LABELS: Record<Exclude<PillState, 'rest' | 'error'>, string> = { const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'Recording', recording: 'captures.pill.recording',
transcribing: 'Transcribing', transcribing: 'captures.pill.transcribing',
refining: 'Refining', refining: 'captures.pill.refining',
speaking: 'Speaking', speaking: 'captures.pill.speaking',
completed: 'Done', completed: 'captures.pill.completed',
}; };
function barModeFor( function barModeFor(
@@ -87,10 +88,12 @@ export function CapturePill({
onDismiss?: () => void; onDismiss?: () => void;
className?: string; className?: string;
}) { }) {
const { t } = useTranslation();
if (state === 'error') { if (state === 'error') {
return ( return (
<ErrorPill <ErrorPill
message={errorMessage ?? 'Something went wrong'} message={errorMessage ?? t('captures.pill.errorFallback')}
onDismiss={onDismiss} onDismiss={onDismiss}
className={className} className={className}
/> />
@@ -98,7 +101,7 @@ export function CapturePill({
} }
const visible = state !== 'rest'; const visible = state !== 'rest';
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state]; const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
const barMode = barModeFor(state); const barMode = barModeFor(state);
const dot = ( const dot = (
@@ -114,7 +117,7 @@ export function CapturePill({
<button <button
type="button" type="button"
onClick={onStop} onClick={onStop}
aria-label="Stop recording" 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" 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} {dot}
@@ -161,6 +164,7 @@ function ErrorPill({
onDismiss?: () => void; onDismiss?: () => void;
className?: string; className?: string;
}) { }) {
const { t } = useTranslation();
const handleClick = async () => { const handleClick = async () => {
try { try {
await navigator.clipboard.writeText(message); await navigator.clipboard.writeText(message);
@@ -175,7 +179,7 @@ function ErrorPill({
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
title="Click to copy error" title={t('captures.pill.errorCopyTooltip')}
className={cn( className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full', 'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-black/65 backdrop-blur-md text-red-300', 'bg-black/65 backdrop-blur-md text-red-300',
+67 -104
View File
@@ -20,6 +20,7 @@ import {
Volume2, Volume2,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CapturePill } from '@/components/CapturePill/CapturePill'; import { CapturePill } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -48,25 +49,12 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness'; import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm'; 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 { function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00'; if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000); const total = Math.round(ms / 1000);
@@ -75,20 +63,6 @@ function formatDuration(ms?: number | null): string {
return `${m}:${String(s).padStart(2, '0')}`; 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 ChordKeys({ keys }: { keys: string[] }) { function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null; if (keys.length === 0) return null;
return ( return (
@@ -114,8 +88,14 @@ function ChordKeys({ keys }: { keys: string[] }) {
} }
function SourceBadge({ source }: { source: CaptureSource }) { function SourceBadge({ source }: { source: CaptureSource }) {
const { t } = useTranslation();
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio; const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File'; const label =
source === 'dictation'
? t('captures.source.dictation')
: source === 'recording'
? t('captures.source.recording')
: t('captures.source.file');
return ( return (
<Badge <Badge
variant="secondary" variant="secondary"
@@ -154,26 +134,18 @@ function FakeWaveform({ seed = 1, className }: { seed?: number; className?: stri
type PlaybackState = 'idle' | 'generating' | 'playing'; 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() { export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = 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 [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true); const [showRefined, setShowRefined] = useState(true);
@@ -278,14 +250,14 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] }); queryClient.invalidateQueries({ queryKey: ['captures'] });
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' }); toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
}, },
}); });
const playAsMutation = useMutation({ const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => { mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw; const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error('Capture has no transcript yet'); if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode; const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the // Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an // profile's stored engine preference. Cloned profiles without an
@@ -308,14 +280,14 @@ export function CapturesTab() {
apiClient.getAudioUrl(result.id), apiClient.getAudioUrl(result.id),
result.id, result.id,
voice.id, voice.id,
`${voice.name} · ${capture.id.slice(0, 8)}`, t('captures.playerVoiceLabel', { voice: voice.name, captureId: capture.id.slice(0, 8) }),
); );
setPlaybackState('playing'); setPlaybackState('playing');
} }
}, },
onError: (err: Error) => { onError: (err: Error) => {
setPlaybackState('idle'); setPlaybackState('idle');
toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' }); toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
}, },
}); });
@@ -339,7 +311,7 @@ export function CapturesTab() {
apiClient.getCaptureAudioUrl(selected.id), apiClient.getCaptureAudioUrl(selected.id),
`capture-${selected.id}`, `capture-${selected.id}`,
null, null,
`Capture · ${formatDate(selected.created_at)}`, t('captures.captureCardLabel', { when: formatAbsoluteDate(selected.created_at) }),
); );
}; };
@@ -350,9 +322,9 @@ export function CapturesTab() {
: selected.transcript_raw; : selected.transcript_raw;
try { try {
await navigator.clipboard.writeText(text || ''); await navigator.clipboard.writeText(text || '');
toast({ title: 'Transcript copied' }); toast({ title: t('captures.toast.transcriptCopied') });
} catch { } catch {
toast({ title: 'Copy failed', variant: 'destructive' }); toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
} }
}; };
@@ -361,8 +333,8 @@ export function CapturesTab() {
const target = voice ?? playAsVoice; const target = voice ?? playAsVoice;
if (!target) { if (!target) {
toast({ toast({
title: 'No voice profile', title: t('captures.toast.noVoice'),
description: 'Create a voice profile before using Play as.', description: t('captures.toast.noVoiceDescription'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@@ -395,17 +367,17 @@ export function CapturesTab() {
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4"> <div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center mb-3"> <div className="flex items-center mb-3">
<h1 className="text-2xl px-4 font-bold">Captures</h1> <h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
<Badge <Badge
variant="secondary" variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20" className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
> >
Beta {t('captures.beta')}
</Badge> </Badge>
</div> </div>
<div className="relative"> <div className="relative">
<Input <Input
placeholder="Search transcripts..." placeholder={t('captures.searchPlaceholder')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -427,9 +399,9 @@ export function CapturesTab() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground"> <div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? ( {search ? (
<p>No captures match "{search}"</p> <p>{t('captures.empty.noMatches', { query: search })}</p>
) : ( ) : (
<p>No captures yet.</p> <p>{t('captures.empty.none')}</p>
)} )}
</div> </div>
) : ( ) : (
@@ -450,7 +422,7 @@ export function CapturesTab() {
> >
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium"> <span className="text-[11px] text-muted-foreground font-medium">
{formatRelative(capture.created_at)} {formatDate(capture.created_at)}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums"> <span className="text-[10px] text-muted-foreground/70 tabular-nums">
@@ -468,7 +440,7 @@ export function CapturesTab() {
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20" 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" /> <Sparkles className="h-2.5 w-2.5" />
Refined {t('captures.transcript.refined')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -490,9 +462,10 @@ export function CapturesTab() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <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 className="w-1.5 h-1.5 rounded-full bg-accent" />
<span> <span>
Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)} {t('captures.header.modelSummary', {
<span className="mx-1.5 text-muted-foreground/40">·</span> stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
Qwen3 · {llmModel} llm: llmModel,
})}
</span> </span>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
@@ -510,7 +483,7 @@ export function CapturesTab() {
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to="/settings/captures"> <Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" /> <Settings2 className="mr-2 h-4 w-4" />
Configure {t('captures.actions.configure')}
</Link> </Link>
</Button> </Button>
{readiness.allReady && ( {readiness.allReady && (
@@ -524,7 +497,7 @@ export function CapturesTab() {
) : ( ) : (
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
)} )}
{session.isUploading ? 'Uploading...' : 'Import'} {session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
</Button> </Button>
)} )}
</> </>
@@ -542,12 +515,12 @@ export function CapturesTab() {
{session.isRecording ? ( {session.isRecording ? (
<> <>
<Square className="h-4 w-4 mr-2 fill-current" /> <Square className="h-4 w-4 mr-2 fill-current" />
Stop {t('captures.actions.stop')}
</> </>
) : ( ) : (
<> <>
<Mic className="h-4 w-4 mr-2" /> <Mic className="h-4 w-4 mr-2" />
Dictate {t('captures.actions.dictate')}
</> </>
)} )}
</Button> </Button>
@@ -564,7 +537,7 @@ export function CapturesTab() {
> >
{/* Meta row */} {/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground"> <div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatDate(selected.created_at)}</span> <span>{formatAbsoluteDate(selected.created_at)}</span>
{selected.language && ( {selected.language && (
<> <>
<span className="text-muted-foreground/40">·</span> <span className="text-muted-foreground/40">·</span>
@@ -611,7 +584,7 @@ export function CapturesTab() {
)} )}
> >
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" /> <Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Refined {t('captures.transcript.refined')}
</button> </button>
<button <button
type="button" type="button"
@@ -624,15 +597,15 @@ export function CapturesTab() {
)} )}
> >
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" /> <Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Raw {t('captures.transcript.raw')}
</button> </button>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{showRefined && selected.transcript_refined {showRefined && selected.transcript_refined
? `Refined with Qwen3 · ${selected.llm_model ?? llmModel}` ? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
: selected.stt_model : selected.stt_model
? `Transcribed with Whisper ${selected.stt_model}` ? t('captures.transcript.rawHint', { model: selected.stt_model })
: null} : null}
</span> </span>
</div> </div>
@@ -665,29 +638,24 @@ export function CapturesTab() {
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15', 'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15',
)} )}
> >
{playAsVoice && (
<div
className={cn(
'h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(playAsVoice.id),
playbackState === 'playing' && 'animate-pulse',
)}
/>
)}
{playbackState === 'generating' ? ( {playbackState === 'generating' ? (
<> <>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating… {t('captures.actions.playAsGenerating')}
</> </>
) : playbackState === 'playing' ? ( ) : playbackState === 'playing' ? (
<> <>
<Square className="h-3 w-3 fill-current" /> <Square className="h-3 w-3 fill-current" />
Stop · {playAsVoice?.name ?? 'Voice'} {playAsVoice
? t('captures.actions.playAsStop', { name: playAsVoice.name })
: t('captures.actions.playAsStopFallback')}
</> </>
) : ( ) : (
<> <>
<Volume2 className="h-3.5 w-3.5" /> <Volume2 className="h-3.5 w-3.5" />
{playAsVoice ? `Play as ${playAsVoice.name}` : 'Play as…'} {playAsVoice
? t('captures.actions.playAs', { name: playAsVoice.name })
: t('captures.actions.playAsFallback')}
</> </>
)} )}
</Button> </Button>
@@ -708,21 +676,15 @@ export function CapturesTab() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64"> <DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide"> <DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Play transcript as {t('captures.actions.playAsDropdownLabel')}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{profiles?.map((v) => ( {profiles?.map((v) => (
<DropdownMenuItem <DropdownMenuItem
key={v.id} key={v.id}
onClick={() => handlePlayAs(v)} onClick={() => handlePlayAs(v)}
className="gap-2.5 py-2" className="py-2"
> >
<div
className={cn(
'h-7 w-7 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(v.id),
)}
/>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div> <div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate"> <div className="text-[11px] text-muted-foreground truncate">
@@ -739,7 +701,7 @@ export function CapturesTab() {
</div> </div>
<Button variant="outline" size="sm" onClick={handleCopy}> <Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" /> <Copy className="h-3.5 w-3.5 mr-1.5" />
Copy {t('captures.actions.copy')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -752,11 +714,13 @@ export function CapturesTab() {
) : ( ) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" /> <Sparkles className="h-3.5 w-3.5 mr-1.5" />
)} )}
{selected.transcript_refined ? 'Re-refine' : 'Refine'} {selected.transcript_refined
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button> </Button>
<Button variant="outline" size="sm" disabled> <Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" /> <Send className="h-3.5 w-3.5 mr-1.5" />
Send to {t('captures.actions.sendTo')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
@@ -771,7 +735,7 @@ export function CapturesTab() {
) : ( ) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" /> <Trash2 className="h-3.5 w-3.5 mr-1.5" />
)} )}
Delete {t('captures.actions.delete')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -780,12 +744,12 @@ export function CapturesTab() {
{capturesLoading ? ( {capturesLoading ? (
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" /> <Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Loading captures…</p> <p className="text-sm">{t('captures.empty.loading')}</p>
</div> </div>
) : captures.length ? ( ) : captures.length ? (
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" /> <Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Pick a capture to see the transcript.</p> <p className="text-sm">{t('captures.empty.pickOne')}</p>
</div> </div>
) : hotkeyEnabled && !readiness.allReady ? ( ) : hotkeyEnabled && !readiness.allReady ? (
<DictationReadinessChecklist readiness={readiness} /> <DictationReadinessChecklist readiness={readiness} />
@@ -796,7 +760,7 @@ export function CapturesTab() {
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<ChordKeys keys={pushToTalkKeys} /> <ChordKeys keys={pushToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Hold to record {t('captures.empty.holdToRecord')}
</span> </span>
</div> </div>
) : null} ) : null}
@@ -804,25 +768,24 @@ export function CapturesTab() {
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<ChordKeys keys={toggleToTalkKeys} /> <ChordKeys keys={toggleToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Toggle hands-free {t('captures.empty.toggleHandsFree')}
</span> </span>
</div> </div>
) : null} ) : null}
</div> </div>
<p className="text-sm"> <p className="text-sm">
Press the shortcut anywhere on your machine to start your first capture. {t('captures.empty.pressShortcut')}
</p> </p>
</div> </div>
) : ( ) : (
<div className="max-w-sm mx-auto text-center space-y-3"> <div className="max-w-sm mx-auto text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" /> <Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">No captures yet.</p> <p className="text-sm">{t('captures.empty.none')}</p>
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
Turn on the global shortcut to dictate from anywhere — or click {t('captures.empty.turnOnShortcut')}
Dictate above for an in-app capture.
</p> </p>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link to="/settings/captures">Open Captures settings</Link> <Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
</Button> </Button>
</div> </div>
)} )}
@@ -10,6 +10,7 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
@@ -74,6 +75,7 @@ function progressPercent(task: ActiveDownloadTask | undefined): number | null {
* "stuck pill" failure mode of pressing the chord with a missing model. * "stuck pill" failure mode of pressing the chord with a missing model.
*/ */
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) { export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
@@ -123,13 +125,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
const displayName = const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name; vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({ toast({
title: 'Download started', title: t('captures.readiness.downloadStarted'),
description: `${displayName} is downloading. The shortcut will arm itself when it finishes.`, description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
}); });
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
title: 'Download failed', title: t('captures.readiness.downloadFailed'),
description: err.message, description: err.message,
variant: 'destructive', variant: 'destructive',
}); });
@@ -159,12 +161,14 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{downloading ? ( {downloading ? (
<> <>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null ? `Downloading… ${pct}%` : 'Downloading…'} {pct != null
? t('captures.readiness.downloadingPercent', { pct })
: t('captures.readiness.downloading')}
</> </>
) : ( ) : (
<> <>
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
Download {t('captures.readiness.downloadButton')}
</> </>
)} )}
</Button> </Button>
@@ -175,21 +179,23 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
<div className="w-full max-w-md mx-auto space-y-2.5"> <div className="w-full max-w-md mx-auto space-y-2.5">
<div className="text-center mb-5 space-y-1"> <div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground"> <h2 className="text-base font-semibold text-foreground">
A few things before you can dictate {t('captures.readiness.title')}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The shortcut stays off until everything below is ready. {t('captures.readiness.subheading')}
</p> </p>
</div> </div>
{readiness.stt && ( {readiness.stt && (
<ChecklistRow <ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />} icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.stt.display_name} (speech-to-text)`} title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={ description={
readiness.stt.ready readiness.stt.ready
? 'Model downloaded.' ? t('captures.readiness.stt.ready')
: `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.` : sttSize
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
: t('captures.readiness.stt.missing')
} }
ready={readiness.stt.ready} ready={readiness.stt.ready}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)} action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
@@ -199,11 +205,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{readiness.llm && ( {readiness.llm && (
<ChecklistRow <ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />} icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.llm.display_name} (refinement)`} title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={ description={
readiness.llm.ready readiness.llm.ready
? 'Model downloaded.' ? t('captures.readiness.llm.ready')
: `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.` : llmSize
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
: t('captures.readiness.llm.missing')
} }
ready={readiness.llm.ready} ready={readiness.llm.ready}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)} action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
@@ -212,34 +220,34 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
<ChecklistRow <ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />} icon={<Keyboard className="h-3.5 w-3.5" />}
title="Input Monitoring permission" title={t('captures.readiness.inputMonitoring.label')}
description={ description={
readiness.inputMonitoring readiness.inputMonitoring
? 'macOS allows Voicebox to detect your global shortcut.' ? t('captures.readiness.inputMonitoring.ready')
: 'macOS needs to allow Voicebox to detect the global shortcut.' : t('captures.readiness.inputMonitoring.missing')
} }
ready={readiness.inputMonitoring} ready={readiness.inputMonitoring}
action={ action={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5"> <Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
Open Settings {t('captures.readiness.inputMonitoring.openSettings')}
</Button> </Button>
} }
/> />
<ChecklistRow <ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />} icon={<Accessibility className="h-3.5 w-3.5" />}
title="Accessibility permission" title={t('captures.readiness.accessibility.label')}
description={ description={
readiness.accessibility readiness.accessibility
? 'Voicebox can paste transcriptions into other apps.' ? t('captures.readiness.accessibility.ready')
: 'Required so transcriptions can paste into the focused app.' : t('captures.readiness.accessibility.missing')
} }
ready={readiness.accessibility} ready={readiness.accessibility}
action={ action={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5"> <Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
Open Settings {t('captures.readiness.accessibility.openSettings')}
</Button> </Button>
} }
/> />
@@ -1,5 +1,6 @@
import { Keyboard } from 'lucide-react'; import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -47,6 +48,7 @@ export function ChordPicker({
onSave, onSave,
onCancel, onCancel,
}: ChordPickerProps) { }: ChordPickerProps) {
const { t } = useTranslation();
// Currently held set, peak set captured this session, and "is the user // Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the // mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value. // Save button can read a stable value.
@@ -153,12 +155,12 @@ export function ChordPicker({
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" /> <Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? 'Capturing…' : 'Press your shortcut'} {pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]"> <div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
{displayKeys.length === 0 ? ( {displayKeys.length === 0 ? (
<span className="text-sm text-muted-foreground italic"> <span className="text-sm text-muted-foreground italic">
No keys yet {t('captures.chord.noKeys')}
</span> </span>
) : ( ) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />) displayKeys.map((k) => <ChordKey key={k} name={k} />)
@@ -166,8 +168,7 @@ export function ChordPicker({
</div> </div>
{unsupportedAttempt ? ( {unsupportedAttempt ? (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
"{unsupportedAttempt}" isn't supported in chords. Try a modifier {t('captures.chord.unsupported', { key: unsupportedAttempt })}
or letter key.
</p> </p>
) : null} ) : null}
</div> </div>
@@ -175,10 +176,10 @@ export function ChordPicker({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onCancel}> <Button variant="outline" onClick={onCancel}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}> <Button onClick={() => onSave(captured)} disabled={!canSave}>
Save {t('common.save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router'; import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion'; 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 { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
@@ -52,6 +53,21 @@ export function FloatingGenerateBox({
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId); const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd); 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 // Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({ const { data: effectPresets } = useQuery({
@@ -175,6 +191,10 @@ export function FloatingGenerateBox({
) { ) {
setSelectedPresetId(null); 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]); }, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded) // Auto-resize textarea based on content (only when expanded)
@@ -331,35 +351,90 @@ export function FloatingGenerateBox({
/> />
</motion.div> </motion.div>
<div className="relative shrink-0"> <div className="flex items-start gap-2 shrink-0">
<div className="group relative"> {/* Compose — fills the textarea with a fresh in-character line. */}
<Button <AnimatePresence>
type="submit" {selectedProfile?.personality?.trim() && (
disabled={isPending || !selectedProfileId} <motion.div
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" initial={{ opacity: 0, scale: 0.8 }}
size="icon" animate={{ opacity: 1, scale: 1 }}
aria-label={ exit={{ opacity: 0, scale: 0.8 }}
isPending transition={{ duration: 0.2 }}
? t('generation.button.generating') >
: !selectedProfileId <div className="group relative">
? t('generation.button.selectFirst') <Button
: t('generation.button.generate') type="button"
} variant="ghost"
> size="icon"
{isPending ? ( disabled={composeMutation.isPending || !selectedProfileId}
<Loader2 className="h-4 w-4 animate-spin" /> onClick={async () => {
) : ( const result = await composeMutation.mutateAsync();
<Sparkles className="h-4 w-4" /> form.setValue('text', result.text, { shouldDirty: true });
)} setIsExpanded(true);
</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]"> className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
{isPending aria-label={t('generation.compose.ariaLabel')}
? t('generation.button.generating') >
: !selectedProfileId {composeMutation.isPending ? (
? t('generation.button.selectFirst') <Loader2 className="h-4 w-4 animate-spin" />
: t('generation.button.generate')} ) : (
</span> <Dices className="h-4 w-4" />
</div> )}
</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 */} {/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence> <AnimatePresence>
@@ -369,7 +444,6 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }} exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
> >
<div className="group relative"> <div className="group relative">
<Button <Button
@@ -399,6 +473,35 @@ export function FloatingGenerateBox({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </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>
</div> </div>
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
</div> </div>
)} )}
<FormField <FormField
control={form.control} control={form.control}
name="language" name="language"
@@ -1,315 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { Loader2, Mic, RefreshCw, Sparkles } 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 { 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';
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 { toast } = useToast();
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);
}
// ── Personality-driven text generation ─────────────────────────────
// Compose fills the empty textarea with a fresh in-character line.
// Rewrite restates whatever's in the textarea in the profile's voice.
// Both buttons hide entirely when the selected profile has no
// personality set — nothing to drive the LLM with otherwise.
const personality = selectedProfile?.personality?.trim() || '';
const hasPersonality = personality.length > 0;
const currentText = form.watch('text');
const textHasContent = (currentText || '').trim().length > 0;
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Compose failed',
description: err.message || 'Could not generate text from this personality.',
variant: 'destructive',
});
},
});
const rewriteMutation = useMutation({
mutationFn: async (text: string) => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.rewriteWithPersonality(selectedProfileId, text);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Rewrite failed',
description: err.message || 'Could not rewrite the text in this voice.',
variant: 'destructive',
});
},
});
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>
)}
/>
{hasPersonality && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
textHasContent ||
composeMutation.isPending ||
rewriteMutation.isPending
}
onClick={() => composeMutation.mutate()}
>
{composeMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
Compose
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
!textHasContent ||
rewriteMutation.isPending ||
composeMutation.isPending
}
onClick={() => rewriteMutation.mutate(currentText || '')}
>
{rewriteMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Rewrite in voice
</Button>
<span className="text-xs text-muted-foreground">
Uses the profile's personality.
</span>
</div>
)}
{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>
);
}
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react'; import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
@@ -62,6 +63,7 @@ export function useInputMonitoringPermission() {
* be noise). * be noise).
*/ */
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) { export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission(); useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false); const [stillMissing, setStillMissing] = useState(false);
@@ -80,26 +82,23 @@ export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" /> <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Grant Input Monitoring to enable the global shortcut {t('captures.permissions.inputMonitoring.title')}
</p> </p>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings → Privacy &amp; Security → Input <Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
Monitoring to detect your dictation chord. The toggle is on, but
macOS is blocking key events until you allow it.
</p> </p>
<div className="flex items-center gap-2 pt-1.5"> <div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5"> <Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
Open Settings {t('captures.permissions.inputMonitoring.openSettings')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}> <Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking…' : "I've enabled it"} {checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
</Button> </Button>
</div> </div>
{stillMissing && !checking && ( {stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1"> <p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
Still not detected. macOS usually requires quitting and reopening {t('captures.permissions.inputMonitoring.stillMissing')}
Voicebox after toggling the permission.
</p> </p>
)} )}
</div> </div>
+103 -90
View File
@@ -1,5 +1,6 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react'; import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate'; import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate'; import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill'; import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
@@ -50,8 +51,9 @@ function voiceGradient(voiceId: string): string {
} }
function ChordPreview({ keys }: { keys: string[] }) { function ChordPreview({ keys }: { keys: string[] }) {
const { t } = useTranslation();
if (keys.length === 0) { if (keys.length === 0) {
return <span className="text-xs text-muted-foreground italic">Not set</span>; return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
} }
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -132,6 +134,7 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
} }
export function CapturesPage() { export function CapturesPage() {
const { t } = useTranslation();
const { settings, update } = useCaptureSettings(); const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles(); const { data: profiles } = useProfiles();
const { toast } = useToast(); const { toast } = useToast();
@@ -164,13 +167,13 @@ export function CapturesPage() {
<div className="flex gap-8 items-start max-w-5xl"> <div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10"> <div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection <SettingSection
title="Dictation" title={t('settings.captures.dictation.title')}
description="Capture from anywhere on your machine with a global shortcut." description={t('settings.captures.dictation.description')}
> >
<div> <div>
<SettingRow <SettingRow
title="Global shortcut" title={t('settings.captures.dictation.globalShortcut.title')}
description="Hold the shortcut to record from anywhere on your machine. Release to transcribe. macOS will ask for Input Monitoring permission the first time you turn this on." description={t('settings.captures.dictation.globalShortcut.description')}
htmlFor="hotkeyEnabled" htmlFor="hotkeyEnabled"
action={ action={
<Toggle <Toggle
@@ -195,10 +198,11 @@ export function CapturesPage() {
.filter(Boolean) .filter(Boolean)
.join(' and '); .join(' and ');
toast({ toast({
title: 'Shortcut on, but not yet armed', title: t('captures.toast.shortcutNotArmed'),
description: `${names} still need${ description: t('captures.toast.shortcutNotArmedDescription', {
missingModels.length === 1 ? 's' : '' names,
} to download. Open the Captures tab to start.`, count: missingModels.length,
}),
}); });
}} }}
/> />
@@ -208,8 +212,8 @@ export function CapturesPage() {
</div> </div>
<SettingRow <SettingRow
title="Push-to-talk shortcut" title={t('settings.captures.dictation.pushToTalk.title')}
description="Hold these keys anywhere on your system to record. Release to stop and transcribe." description={t('settings.captures.dictation.pushToTalk.description')}
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} /> <ChordPreview keys={pushToTalkKeys} />
@@ -220,15 +224,15 @@ export function CapturesPage() {
onClick={() => setChordEditor('push')} onClick={() => setChordEditor('push')}
> >
<Keyboard className="h-3.5 w-3.5 mr-1.5" /> <Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change {t('settings.captures.dictation.pushToTalk.change')}
</Button> </Button>
</div> </div>
} }
/> />
<SettingRow <SettingRow
title="Toggle shortcut" title={t('settings.captures.dictation.toggle.title')}
description="Press once to start a hands-free recording. Press again to stop. Usually push-to-talk plus Space." description={t('settings.captures.dictation.toggle.description')}
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} /> <ChordPreview keys={toggleToTalkKeys} />
@@ -239,7 +243,7 @@ export function CapturesPage() {
onClick={() => setChordEditor('toggle')} onClick={() => setChordEditor('toggle')}
> >
<Keyboard className="h-3.5 w-3.5 mr-1.5" /> <Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change {t('settings.captures.dictation.toggle.change')}
</Button> </Button>
</div> </div>
} }
@@ -247,8 +251,8 @@ export function CapturesPage() {
<ChordPicker <ChordPicker
open={chordEditor === 'push'} open={chordEditor === 'push'}
title="Set push-to-talk shortcut" title={t('settings.captures.dictation.chordPicker.pttTitle')}
description="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." description={t('settings.captures.dictation.chordPicker.pttDescription')}
initialKeys={pushToTalkKeys} initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)} onCancel={() => setChordEditor(null)}
onSave={(keys) => { onSave={(keys) => {
@@ -259,8 +263,8 @@ export function CapturesPage() {
<ChordPicker <ChordPicker
open={chordEditor === 'toggle'} open={chordEditor === 'toggle'}
title="Set toggle shortcut" title={t('settings.captures.dictation.chordPicker.toggleTitle')}
description="Hold the keys you want to use, then release and click Save. Pick something distinct from your push-to-talk chord." description={t('settings.captures.dictation.chordPicker.toggleDescription')}
initialKeys={toggleToTalkKeys} initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)} onCancel={() => setChordEditor(null)}
onSave={(keys) => { onSave={(keys) => {
@@ -270,15 +274,15 @@ export function CapturesPage() {
/> />
<SettingRow <SettingRow
title="Preview" title={t('settings.captures.dictation.preview.title')}
description="What appears on screen while you're holding the shortcut." description={t('settings.captures.dictation.preview.description')}
> >
<HotkeyPillPreview enabled={hotkeyEnabled} /> <HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow> </SettingRow>
<SettingRow <SettingRow
title="Copy transcript to clipboard" title={t('settings.captures.dictation.copyToClipboard.title')}
description="The cleaned transcript lands on your clipboard when the capture finishes." description={t('settings.captures.dictation.copyToClipboard.description')}
htmlFor="copyToClipboard" htmlFor="copyToClipboard"
action={ action={
<Toggle <Toggle
@@ -292,8 +296,8 @@ export function CapturesPage() {
<div> <div>
<SettingRow <SettingRow
title="Auto-paste into focused text field" title={t('settings.captures.dictation.autoPaste.title')}
description="If a text input is focused in another app, paste directly into it. Voicebox saves and restores whatever was on your clipboard." description={t('settings.captures.dictation.autoPaste.description')}
htmlFor="autoPaste" htmlFor="autoPaste"
action={ action={
<Toggle <Toggle
@@ -309,12 +313,12 @@ export function CapturesPage() {
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Transcription" title={t('settings.captures.transcription.title')}
description="Pick which speech-to-text model runs on your captures." description={t('settings.captures.transcription.description')}
> >
<SettingRow <SettingRow
title="Transcription model" title={t('settings.captures.transcription.model.title')}
description="Whisper ships with Voicebox and runs entirely on your machine." description={t('settings.captures.transcription.model.description')}
action={ action={
<Select <Select
value={sttModel} value={sttModel}
@@ -324,16 +328,20 @@ export function CapturesPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="base">Whisper Base · 74M · Fast</SelectItem> <SelectItem value="base">
<SelectItem value="small">Whisper Small · 244M · Balanced</SelectItem> {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"> <SelectItem value="medium">
Whisper Medium · 769M · Higher accuracy {t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
</SelectItem> </SelectItem>
<SelectItem value="large"> <SelectItem value="large">
Whisper Large · 1.5B · Best accuracy {t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
</SelectItem> </SelectItem>
<SelectItem value="turbo"> <SelectItem value="turbo">
Whisper Turbo · Pruned Large v3 · Near-best, fast {t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -341,42 +349,42 @@ export function CapturesPage() {
/> />
<SettingRow <SettingRow
title="Language" title={t('settings.captures.transcription.language.title')}
description="Auto-detect works for most captures. Lock it if you're always speaking the same language." description={t('settings.captures.transcription.language.description')}
action={ action={
<Select value={language} onValueChange={(v) => update({ language: v })}> <Select value={language} onValueChange={(v) => update({ language: v })}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem> <SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
<SelectItem value="en">English</SelectItem> <SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
<SelectItem value="es">Spanish</SelectItem> <SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
<SelectItem value="fr">French</SelectItem> <SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
<SelectItem value="de">German</SelectItem> <SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
<SelectItem value="ja">Japanese</SelectItem> <SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
<SelectItem value="zh">Chinese</SelectItem> <SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
<SelectItem value="hi">Hindi</SelectItem> <SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
/> />
<SettingRow <SettingRow
title="Archive audio" title={t('settings.captures.transcription.archive.title')}
description="Keep the original recording alongside every transcript." description={t('settings.captures.transcription.archive.description')}
htmlFor="archiveAudio" htmlFor="archiveAudio"
action={<Toggle id="archiveAudio" checked={archiveAudio} onCheckedChange={setArchiveAudio} />} action={<Toggle id="archiveAudio" checked={archiveAudio} onCheckedChange={setArchiveAudio} />}
/> />
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Refinement" title={t('settings.captures.refinement.title')}
description="Optionally run a local LLM over transcripts to clean filler words, punctuation, and self-corrections." description={t('settings.captures.refinement.description')}
> >
<SettingRow <SettingRow
title="Refine transcripts automatically" title={t('settings.captures.refinement.auto.title')}
description="Runs after every capture. You can still toggle between raw and refined in the Captures tab." description={t('settings.captures.refinement.auto.description')}
htmlFor="autoRefine" htmlFor="autoRefine"
action={ action={
<Toggle <Toggle
@@ -388,8 +396,8 @@ export function CapturesPage() {
/> />
<SettingRow <SettingRow
title="Refinement model" title={t('settings.captures.refinement.model.title')}
description="Larger models are slower but handle subtle self-corrections and technical vocabulary better." description={t('settings.captures.refinement.model.description')}
action={ action={
<Select <Select
value={llmModel} value={llmModel}
@@ -400,17 +408,23 @@ export function CapturesPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0.6B">Qwen3 · 0.6B · 400 MB · Very fast</SelectItem> <SelectItem value="0.6B">
<SelectItem value="1.7B">Qwen3 · 1.7B · 1.1 GB · Fast</SelectItem> {t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
<SelectItem value="4B">Qwen3 · 4B · 2.5 GB · Full quality</SelectItem> </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.size4', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
/> />
<SettingRow <SettingRow
title="Smart cleanup" title={t('settings.captures.refinement.smartCleanup.title')}
description="Remove filler words (um, uh, like), restore punctuation, and fix capitalization without rephrasing." description={t('settings.captures.refinement.smartCleanup.description')}
htmlFor="smartCleanup" htmlFor="smartCleanup"
action={ action={
<Toggle <Toggle
@@ -423,8 +437,8 @@ export function CapturesPage() {
/> />
<SettingRow <SettingRow
title="Remove self-corrections" title={t('settings.captures.refinement.selfCorrection.title')}
description={'When you change your mind mid-sentence ("actually, no...", "wait, I meant..."), drop the retracted part and keep the final intent.'} description={t('settings.captures.refinement.selfCorrection.description')}
htmlFor="selfCorrection" htmlFor="selfCorrection"
action={ action={
<Toggle <Toggle
@@ -437,8 +451,8 @@ export function CapturesPage() {
/> />
<SettingRow <SettingRow
title="Preserve technical terms" title={t('settings.captures.refinement.preserveTechnical.title')}
description="Keep code identifiers, command names, and acronyms exactly as spoken. Turn on when you dictate into a code prompt." description={t('settings.captures.refinement.preserveTechnical.description')}
htmlFor="preserveTechnical" htmlFor="preserveTechnical"
action={ action={
<Toggle <Toggle
@@ -452,12 +466,12 @@ export function CapturesPage() {
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Playback" title={t('settings.captures.playback.title')}
description='Default voice for the "Play as" action in the Captures tab.' description={t('settings.captures.playback.description')}
> >
<SettingRow <SettingRow
title="Default voice" title={t('settings.captures.playback.defaultVoice.title')}
description="Used when you click Play as without picking a voice first. You can change it per capture." description={t('settings.captures.playback.defaultVoice.description')}
action={ action={
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -480,7 +494,9 @@ export function CapturesPage() {
</> </>
) : ( ) : (
<span className="truncate text-muted-foreground"> <span className="truncate text-muted-foreground">
{voices.length === 0 ? 'No cloned voices yet' : 'None selected'} {voices.length === 0
? t('settings.captures.playback.defaultVoice.noClonedVoices')
: t('settings.captures.playback.defaultVoice.noneSelected')}
</span> </span>
)} )}
</div> </div>
@@ -489,7 +505,7 @@ export function CapturesPage() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64"> <DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide"> <DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Cloned voices {t('settings.captures.playback.defaultVoice.clonedVoices')}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{voices.map((v) => ( {voices.map((v) => (
@@ -522,30 +538,30 @@ export function CapturesPage() {
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Storage" title={t('settings.captures.storage.title')}
description="Captures are saved as paired audio and transcript files in your Voicebox data directory." description={t('settings.captures.storage.description')}
> >
<SettingRow <SettingRow
title="Retention" title={t('settings.captures.storage.retention.title')}
description="How long to keep captures. Applies to both audio and transcripts." description={t('settings.captures.storage.retention.description')}
action={ action={
<Select value={retention} onValueChange={setRetention}> <Select value={retention} onValueChange={setRetention}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="forever">Keep forever</SelectItem> <SelectItem value="forever">{t('settings.captures.storage.retention.forever')}</SelectItem>
<SelectItem value="90d">90 days</SelectItem> <SelectItem value="90d">{t('settings.captures.storage.retention.d90')}</SelectItem>
<SelectItem value="30d">30 days</SelectItem> <SelectItem value="30d">{t('settings.captures.storage.retention.d30')}</SelectItem>
<SelectItem value="7d">7 days</SelectItem> <SelectItem value="7d">{t('settings.captures.storage.retention.d7')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
/> />
<SettingRow <SettingRow
title="Clear all captures" title={t('settings.captures.storage.clearAll.title')}
description="Permanently delete every capture and its audio. This cannot be undone." description={t('settings.captures.storage.clearAll.description')}
action={ action={
<Button <Button
variant="outline" variant="outline"
@@ -553,7 +569,7 @@ export function CapturesPage() {
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30" className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
> >
<Trash2 className="h-3.5 w-3.5 mr-1.5" /> <Trash2 className="h-3.5 w-3.5 mr-1.5" />
Clear captures {t('settings.captures.storage.clearAll.action')}
</Button> </Button>
} }
/> />
@@ -562,41 +578,38 @@ export function CapturesPage() {
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0"> <aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">About Captures</h3> <h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Hold a shortcut anywhere on your machine, speak, and Voicebox turns {t('settings.captures.sidebar.aboutBody')}
your voice into text. Replay it in any cloned voice, paste it into
any app, or pipe it into your coding agent.
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold">What's different</h3> <h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5"> <li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" /> <Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed"> <span className="leading-relaxed">
<span className="text-foreground font-medium">Fully local.</span>{' '} <span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
Whisper and the refinement LLM run on your hardware. No cloud, {t('settings.captures.sidebar.local.body')}
no accounts, your voice never leaves the machine.
</span> </span>
</li> </li>
<li className="flex gap-2.5"> <li className="flex gap-2.5">
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" /> <Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed"> <span className="leading-relaxed">
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
Play as any voice. {t('settings.captures.sidebar.playAs.title')}
</span>{' '} </span>{' '}
Transcripts can be read back in any profile you've cloned. {t('settings.captures.sidebar.playAs.body')}
</span> </span>
</li> </li>
<li className="flex gap-2.5"> <li className="flex gap-2.5">
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" /> <Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed"> <span className="leading-relaxed">
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
Cross-platform. {t('settings.captures.sidebar.crossPlatform.title')}
</span>{' '} </span>{' '}
Same shortcut, same flow on macOS, Windows, and Linux. {t('settings.captures.sidebar.crossPlatform.body')}
</span> </span>
</li> </li>
</ul> </ul>
+40 -50
View File
@@ -1,10 +1,12 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react'; import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings'; import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles'; import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import { formatDate } from '@/lib/utils/format';
import { SettingRow, SettingSection } from './SettingRow'; import { SettingRow, SettingSection } from './SettingRow';
/** /**
@@ -13,6 +15,7 @@ import { SettingRow, SettingSection } from './SettingRow';
* existing Voicebox server; this page is the agent-onboarding surface. * existing Voicebox server; this page is the agent-onboarding surface.
*/ */
export function MCPPage() { export function MCPPage() {
const { t } = useTranslation();
const serverUrl = useServerStore((s) => s.serverUrl); const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings(); const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles(); const { data: profiles } = useProfiles();
@@ -47,12 +50,12 @@ export function MCPPage() {
<div className="flex gap-8 items-start max-w-5xl"> <div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8"> <div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection <SettingSection
title="Install into your agent" title={t('settings.mcp.install.title')}
description="Voicebox exposes a local MCP server whenever the app is open. Paste one of these snippets into your agent's MCP config." description={t('settings.mcp.install.description')}
> >
<SnippetRow <SnippetRow
title="HTTP (recommended)" title={t('settings.mcp.install.http.title')}
description="For clients that speak HTTP MCP — Claude Code, Cursor, Windsurf, VS Code." description={t('settings.mcp.install.http.description')}
snippet={JSON.stringify( snippet={JSON.stringify(
{ {
mcpServers: { mcpServers: {
@@ -67,13 +70,13 @@ export function MCPPage() {
)} )}
/> />
<SnippetRow <SnippetRow
title="Claude Code one-liner" title={t('settings.mcp.install.claudeCode.title')}
description="Registers via the Claude Code CLI." description={t('settings.mcp.install.claudeCode.description')}
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`} snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
/> />
<SnippetRow <SnippetRow
title="Stdio (fallback)" title={t('settings.mcp.install.stdio.title')}
description="For clients that only spawn stdio processes. The shim binary ships with the app." description={t('settings.mcp.install.stdio.description')}
snippet={JSON.stringify( snippet={JSON.stringify(
{ {
mcpServers: { mcpServers: {
@@ -91,12 +94,12 @@ export function MCPPage() {
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Default voice" title={t('settings.mcp.defaultVoice.title')}
description="Used when an agent calls voicebox.speak without a specific profile and has no per-client binding." description={t('settings.mcp.defaultVoice.description')}
> >
<SettingRow <SettingRow
title="Default playback voice" title={t('settings.mcp.defaultVoice.label')}
description="Shared with the Captures-tab 'Play as voice' dropdown — one default voice for passive playback." description={t('settings.mcp.defaultVoice.labelHint')}
action={ action={
<select <select
value={defaultProfileId} value={defaultProfileId}
@@ -107,7 +110,7 @@ export function MCPPage() {
} }
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]" className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
> >
<option value="">(none)</option> <option value="">{t('settings.mcp.defaultVoice.none')}</option>
{(profiles ?? []).map((p) => ( {(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
@@ -119,13 +122,12 @@ export function MCPPage() {
</SettingSection> </SettingSection>
<SettingSection <SettingSection
title="Per-agent voice" title={t('settings.mcp.bindings.title')}
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)." description={t('settings.mcp.bindings.description')}
> >
{bindings.length === 0 ? ( {bindings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 italic"> <p className="text-sm text-muted-foreground py-4 italic">
No bindings yet. Add one below, then configure your MCP client to <Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
send the matching <code>X-Voicebox-Client-Id</code>.
</p> </p>
) : ( ) : (
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
@@ -142,12 +144,12 @@ export function MCPPage() {
<code className="text-[11px]">{b.client_id}</code> <code className="text-[11px]">{b.client_id}</code>
{' · '} {' · '}
{b.last_seen_at ? ( {b.last_seen_at ? (
<span title={`Last seen ${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" />{' '} <Plug className="inline h-3 w-3 text-emerald-500" />{' '}
last seen {formatRelative(b.last_seen_at)} {t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
</span> </span>
) : ( ) : (
<span>never connected</span> <span>{t('settings.mcp.bindings.neverConnected')}</span>
)} )}
</div> </div>
</div> </div>
@@ -162,7 +164,7 @@ export function MCPPage() {
} }
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]" className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
> >
<option value="">(default)</option> <option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => ( {(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
@@ -173,7 +175,7 @@ export function MCPPage() {
size="icon" size="icon"
variant="ghost" variant="ghost"
onClick={() => remove(b.client_id)} onClick={() => remove(b.client_id)}
aria-label={`Remove binding for ${b.client_id}`} aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -183,18 +185,18 @@ export function MCPPage() {
)} )}
<div className="pt-4 space-y-2"> <div className="pt-4 space-y-2">
<div className="text-sm font-medium">Add a binding</div> <div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2"> <div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input <input
type="text" type="text"
placeholder="client id (e.g. claude-code)" placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
value={newClientId} value={newClientId}
onChange={(e) => setNewClientId(e.target.value)} onChange={(e) => setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm" className="h-9 px-3 rounded-md border bg-background text-sm"
/> />
<input <input
type="text" type="text"
placeholder="label (optional)" placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
value={newLabel} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)} onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm" className="h-9 px-3 rounded-md border bg-background text-sm"
@@ -204,7 +206,7 @@ export function MCPPage() {
onChange={(e) => setNewProfileId(e.target.value)} onChange={(e) => setNewProfileId(e.target.value)}
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]" className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
> >
<option value="">(default)</option> <option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => ( {(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
@@ -217,7 +219,7 @@ export function MCPPage() {
onClick={handleAdd} onClick={handleAdd}
disabled={!newClientId.trim() || adding} disabled={!newClientId.trim() || adding}
> >
Add binding {t('settings.mcp.bindings.add.action')}
</Button> </Button>
</div> </div>
</SettingSection> </SettingSection>
@@ -225,39 +227,36 @@ export function MCPPage() {
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0"> <aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">About MCP</h3> <h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Model Context Protocol lets your AI coding agent — Claude Code, {t('settings.mcp.sidebar.aboutBody')}
Cursor, Windsurf — call Voicebox tools. Speak in a cloned voice,
transcribe audio, browse captures.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Available tools</h3> <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"> <ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<li> <li>
<code className="text-accent">voicebox.speak</code> <code className="text-accent">voicebox.speak</code>
<div>Speak text in a voice profile.</div> <div>{t('settings.mcp.sidebar.tools.speak')}</div>
</li> </li>
<li> <li>
<code className="text-accent">voicebox.transcribe</code> <code className="text-accent">voicebox.transcribe</code>
<div>Whisper STT on a clip.</div> <div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
</li> </li>
<li> <li>
<code className="text-accent">voicebox.list_captures</code> <code className="text-accent">voicebox.list_captures</code>
<div>Recent dictations / recordings.</div> <div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
</li> </li>
<li> <li>
<code className="text-accent">voicebox.list_profiles</code> <code className="text-accent">voicebox.list_profiles</code>
<div>Available voice profiles.</div> <div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
</li> </li>
</ul> </ul>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waypoints className="h-3.5 w-3.5 text-accent" /> <Waypoints className="h-3.5 w-3.5 text-accent" />
<span> <span>
Also exposed as <code>POST /speak</code> for shell scripts, ACP, <Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
A2A.
</span> </span>
</div> </div>
</aside> </aside>
@@ -274,6 +273,7 @@ function SnippetRow({
description: string; description: string;
snippet: string; snippet: string;
}) { }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copy = async () => { const copy = async () => {
try { try {
@@ -296,12 +296,12 @@ function SnippetRow({
{copied ? ( {copied ? (
<> <>
<Check className="h-3.5 w-3.5 mr-1.5" /> <Check className="h-3.5 w-3.5 mr-1.5" />
Copied {t('settings.mcp.install.copied')}
</> </>
) : ( ) : (
<> <>
<Copy className="h-3.5 w-3.5 mr-1.5" /> <Copy className="h-3.5 w-3.5 mr-1.5" />
Copy {t('settings.mcp.install.copy')}
</> </>
)} )}
</Button> </Button>
@@ -312,13 +312,3 @@ function SnippetRow({
</div> </div>
); );
} }
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();
const diff = Math.max(0, now - then);
if (diff < 60_000) return 'just now';
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} h ago`;
return `${Math.floor(diff / 86400_000)} d ago`;
}
+2 -2
View File
@@ -23,8 +23,8 @@ interface SettingsTab {
const tabs: SettingsTab[] = [ const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' }, { labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' }, { labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ label: 'Captures', path: '/settings/captures' }, { labelKey: 'settings.tabs.captures', path: '/settings/captures' },
{ label: 'MCP', path: '/settings/mcp' }, { labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true }, { labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true }, { labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' }, { labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
+1 -1
View File
@@ -22,7 +22,7 @@ const tabs: Array<{
}> = [ }> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' }, { id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' }, { id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'captures', path: '/captures', icon: Captions, label: 'Captures' }, { id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' }, { id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' }, { id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' }, { id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
@@ -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 { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -126,6 +126,9 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
{profile.effects_chain && profile.effects_chain.length > 0 && ( {profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" /> <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>
<div className="flex gap-0.5 justify-end items-end mt-auto"> <div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton <CircleButton
@@ -1197,16 +1197,16 @@ export function ProfileForm() {
name="personality" name="personality"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Personality</FormLabel> <FormLabel>{t('profileForm.fields.personalityLabel')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Optional. Who this voice is and how they talk. E.g. &quot;a grumpy pirate who only speaks in nautical metaphors&quot;. Used by Compose, Rewrite, and the Speak API." placeholder={t('profileForm.fields.personalityPlaceholder')}
className="min-h-[96px]" className="min-h-[96px]"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Leave blank to hide the Compose and Rewrite buttons on the generate page. {t('profileForm.fields.personalityHint')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+357 -1
View File
@@ -14,6 +14,7 @@
"nav": { "nav": {
"generate": "Generate", "generate": "Generate",
"stories": "Stories", "stories": "Stories",
"captures": "Captures",
"voices": "Voices", "voices": "Voices",
"effects": "Effects", "effects": "Effects",
"audio": "Audio", "audio": "Audio",
@@ -21,6 +22,138 @@
"settings": "Settings", "settings": "Settings",
"updateBadge": "Update" "updateBadge": "Update"
}, },
"captures": {
"title": "Captures",
"beta": "Beta",
"searchPlaceholder": "Search transcripts…",
"snippetEmpty": "(no transcript)",
"noTranscriptError": "Capture has no transcript yet",
"captureCardLabel": "Capture · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"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",
"sendTo": "Send to",
"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"
},
"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",
"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": { "voicesTab": {
"title": "Voices", "title": "Voices",
"loading": "Loading voices…", "loading": "Loading voices…",
@@ -125,7 +258,10 @@
"noPreference": "No preference", "noPreference": "No preference",
"defaultEngineHint": "Auto-selects this engine when the profile is chosen.", "defaultEngineHint": "Auto-selects this engine when the profile is chosen.",
"defaultEffects": "Default Effects", "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": "Optional. Who this voice is and how they talk. E.g. \"a grumpy pirate who only speaks in nautical metaphors\". Drives the Compose button and the in-character rewrite toggle on the generate page.",
"personalityHint": "Leave blank to hide the Compose button and persona toggle on the generate page."
}, },
"avatar": { "avatar": {
"alt": "Avatar preview" "alt": "Avatar preview"
@@ -550,6 +686,18 @@
"effects": { "effects": {
"none": "No effects", "none": "No effects",
"profileDefault": "Profile default" "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": { "main": {
@@ -571,6 +719,8 @@
"tabs": { "tabs": {
"general": "General", "general": "General",
"generation": "Generation", "generation": "Generation",
"captures": "Captures",
"mcp": "MCP",
"gpu": "GPU", "gpu": "GPU",
"logs": "Logs", "logs": "Logs",
"changelog": "Changelog", "changelog": "Changelog",
@@ -678,6 +828,212 @@
"open": "Open" "open": "Open"
} }
}, },
"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. macOS will ask for Input Monitoring permission the first time you turn this on."
},
"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}}",
"size4": "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"
},
"clearAll": {
"title": "Clear all captures",
"description": "Permanently delete every capture and its audio. This cannot be undone.",
"action": "Clear captures"
}
},
"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."
}
}
},
"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": { "gpu": {
"cpuOnly": "CPU Only", "cpuOnly": "CPU Only",
"vramUsed": "{{mb}} MB VRAM", "vramUsed": "{{mb}} MB VRAM",
+357 -1
View File
@@ -14,6 +14,7 @@
"nav": { "nav": {
"generate": "生成", "generate": "生成",
"stories": "ストーリー", "stories": "ストーリー",
"captures": "キャプチャ",
"voices": "ボイス", "voices": "ボイス",
"effects": "エフェクト", "effects": "エフェクト",
"audio": "オーディオ", "audio": "オーディオ",
@@ -21,6 +22,138 @@
"settings": "設定", "settings": "設定",
"updateBadge": "更新" "updateBadge": "更新"
}, },
"captures": {
"title": "キャプチャ",
"beta": "ベータ",
"searchPlaceholder": "文字起こしを検索…",
"snippetEmpty": "(文字起こしなし)",
"noTranscriptError": "このキャプチャにはまだ文字起こしがありません",
"captureCardLabel": "キャプチャ · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"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": "再整形",
"sendTo": "送信先",
"delete": "削除",
"playAs": "{{name}} で再生",
"playAsFallback": "ボイスで再生…",
"playAsGenerating": "生成中…",
"playAsStop": "停止 · {{name}}",
"playAsStopFallback": "停止 · ボイス",
"playAsDropdownLabel": "文字起こしを次のボイスで再生"
},
"empty": {
"noMatches": "「{{query}}」に一致するキャプチャはありません",
"none": "キャプチャはまだありません。",
"loading": "キャプチャを読み込み中…",
"pickOne": "キャプチャを選択して文字起こしを表示します。",
"holdToRecord": "押し続けて録音",
"toggleHandsFree": "ハンズフリーを切り替え",
"pressShortcut": "マシン上のどこからでもショートカットを押すと、最初のキャプチャを開始できます。",
"turnOnShortcut": "グローバルショートカットを有効にしてどこからでもディクテーション — または上の「ディクテーション」をクリックしてアプリ内でキャプチャします。",
"openSettings": "キャプチャ設定を開く"
},
"toast": {
"deleteFailed": "削除に失敗しました",
"playAsFailed": "ボイスでの再生に失敗しました",
"noVoice": "ボイスプロファイルがありません",
"noVoiceDescription": "「ボイスで再生」を使う前にボイスプロファイルを作成してください。",
"transcriptCopied": "文字起こしをコピーしました",
"copyFailed": "コピーに失敗しました",
"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": { "voicesTab": {
"title": "ボイス", "title": "ボイス",
"loading": "ボイスを読み込み中…", "loading": "ボイスを読み込み中…",
@@ -125,7 +258,10 @@
"noPreference": "指定なし", "noPreference": "指定なし",
"defaultEngineHint": "このプロファイルが選ばれたとき、このエンジンを自動で選択します。", "defaultEngineHint": "このプロファイルが選ばれたとき、このエンジンを自動で選択します。",
"defaultEffects": "デフォルトエフェクト", "defaultEffects": "デフォルトエフェクト",
"defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。" "defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。",
"personalityLabel": "パーソナリティ",
"personalityPlaceholder": "任意。このボイスがどんな人物で、どのように話すかを記述します。例:「航海の比喩でしか話さない不機嫌な海賊」。生成ページの「Compose」ボタンと、キャラクターになりきって書き換えるトグルに反映されます。",
"personalityHint": "空欄にすると、生成ページの「Compose」ボタンとペルソナトグルは表示されません。"
}, },
"avatar": { "avatar": {
"alt": "アバタープレビュー" "alt": "アバタープレビュー"
@@ -550,6 +686,18 @@
"effects": { "effects": {
"none": "エフェクトなし", "none": "エフェクトなし",
"profileDefault": "プロファイルのデフォルト" "profileDefault": "プロファイルのデフォルト"
},
"compose": {
"tooltip": "Compose",
"ariaLabel": "キャラクターになりきって一文を生成",
"failedTitle": "Compose に失敗しました",
"failedDescription": "このパーソナリティからテキストを生成できませんでした。"
},
"persona": {
"tooltipActive": "キャラクターとして発話中",
"tooltipInactive": "キャラクターとして発話",
"ariaLabelActive": "キャラクターとして発話中",
"ariaLabelInactive": "キャラクターとして発話"
} }
}, },
"main": { "main": {
@@ -571,6 +719,8 @@
"tabs": { "tabs": {
"general": "一般", "general": "一般",
"generation": "生成", "generation": "生成",
"captures": "キャプチャ",
"mcp": "MCP",
"gpu": "GPU", "gpu": "GPU",
"logs": "ログ", "logs": "ログ",
"changelog": "変更履歴", "changelog": "変更履歴",
@@ -678,6 +828,212 @@
"open": "開く" "open": "開く"
} }
}, },
"captures": {
"dictation": {
"title": "ディクテーション",
"description": "グローバルショートカットでマシン上のどこからでもキャプチャできます。",
"globalShortcut": {
"title": "グローバルショートカット",
"description": "ショートカットを押し続けるとマシン上のどこからでも録音できます。離すと文字起こしが行われます。最初に有効化したとき、macOS から入力監視の許可を求められます。"
},
"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}}",
"size4": "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 日"
},
"clearAll": {
"title": "すべてのキャプチャをクリア",
"description": "すべてのキャプチャと音声を完全に削除します。元に戻せません。",
"action": "キャプチャをクリア"
}
},
"sidebar": {
"aboutTitle": "キャプチャについて",
"aboutBody": "マシン上のどこからでもショートカットを押し続けて話すと、Voicebox があなたの声をテキストに変換します。クローンしたどのボイスでも再生でき、任意のアプリに貼り付けたり、コーディングエージェントに渡したりできます。",
"differencesTitle": "ここが違います",
"local": {
"title": "完全にローカル。",
"body": "Whisper と整形用 LLM はあなたのハードウェア上で動作します。クラウドもアカウントも不要で、声がマシンの外に出ることはありません。"
},
"playAs": {
"title": "どのボイスでも再生。",
"body": "クローンしたどのプロファイルでも文字起こしを読み上げできます。"
},
"crossPlatform": {
"title": "クロスプラットフォーム。",
"body": "macOS、Windows、Linux で同じショートカットと同じフローを利用できます。"
}
}
},
"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": { "gpu": {
"cpuOnly": "CPU のみ", "cpuOnly": "CPU のみ",
"vramUsed": "VRAM 使用量 {{mb}} MB", "vramUsed": "VRAM 使用量 {{mb}} MB",
+357 -1
View File
@@ -14,6 +14,7 @@
"nav": { "nav": {
"generate": "生成", "generate": "生成",
"stories": "故事", "stories": "故事",
"captures": "捕获",
"voices": "声音", "voices": "声音",
"effects": "效果", "effects": "效果",
"audio": "音频", "audio": "音频",
@@ -21,6 +22,138 @@
"settings": "设置", "settings": "设置",
"updateBadge": "更新" "updateBadge": "更新"
}, },
"captures": {
"title": "捕获",
"beta": "Beta",
"searchPlaceholder": "搜索转录文本……",
"snippetEmpty": "(暂无转录)",
"noTranscriptError": "此次捕获尚无转录文本",
"captureCardLabel": "捕获 · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"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": "重新精修",
"sendTo": "发送到",
"delete": "删除",
"playAs": "以 {{name}} 播放",
"playAsFallback": "播放为……",
"playAsGenerating": "生成中…",
"playAsStop": "停止 · {{name}}",
"playAsStopFallback": "停止 · 声音",
"playAsDropdownLabel": "将转录播放为"
},
"empty": {
"noMatches": "没有捕获匹配 \"{{query}}\"",
"none": "暂无捕获。",
"loading": "加载捕获中…",
"pickOne": "选择一项捕获以查看转录。",
"holdToRecord": "按住以录制",
"toggleHandsFree": "切换免提模式",
"pressShortcut": "在系统的任何位置按下快捷键以开始第一次捕获。",
"turnOnShortcut": "开启全局快捷键以在任何位置进行听写——或点击上方的「听写」在应用内进行捕获。",
"openSettings": "打开「捕获」设置"
},
"toast": {
"deleteFailed": "删除失败",
"playAsFailed": "播放失败",
"noVoice": "暂无声音档案",
"noVoiceDescription": "使用「播放为」之前请先创建声音档案。",
"transcriptCopied": "转录已复制",
"copyFailed": "复制失败",
"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": { "voicesTab": {
"title": "声音", "title": "声音",
"loading": "加载声音中…", "loading": "加载声音中…",
@@ -125,7 +258,10 @@
"noPreference": "无偏好", "noPreference": "无偏好",
"defaultEngineHint": "选择该档案时自动使用此引擎。", "defaultEngineHint": "选择该档案时自动使用此引擎。",
"defaultEffects": "默认效果", "defaultEffects": "默认效果",
"defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。" "defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。",
"personalityLabel": "人物设定",
"personalityPlaceholder": "可选。这个声音是谁、说话方式如何。例如「一位脾气暴躁的海盗,只会用航海比喻说话」。会驱动生成页面上的「撰写」按钮和入戏改写开关。",
"personalityHint": "留空将隐藏生成页面上的「撰写」按钮和人物设定开关。"
}, },
"avatar": { "avatar": {
"alt": "头像预览" "alt": "头像预览"
@@ -550,6 +686,18 @@
"effects": { "effects": {
"none": "无效果", "none": "无效果",
"profileDefault": "档案默认" "profileDefault": "档案默认"
},
"compose": {
"tooltip": "撰写",
"ariaLabel": "以人物设定撰写一句台词",
"failedTitle": "撰写失败",
"failedDescription": "无法根据此人物设定生成文本。"
},
"persona": {
"tooltipActive": "以人物设定朗读",
"tooltipInactive": "以人物设定朗读",
"ariaLabelActive": "以人物设定朗读",
"ariaLabelInactive": "以人物设定朗读"
} }
}, },
"main": { "main": {
@@ -571,6 +719,8 @@
"tabs": { "tabs": {
"general": "常规", "general": "常规",
"generation": "生成", "generation": "生成",
"captures": "捕获",
"mcp": "MCP",
"gpu": "GPU", "gpu": "GPU",
"logs": "日志", "logs": "日志",
"changelog": "更新日志", "changelog": "更新日志",
@@ -678,6 +828,212 @@
"open": "打开" "open": "打开"
} }
}, },
"captures": {
"dictation": {
"title": "听写",
"description": "使用全局快捷键在系统的任何位置进行捕获。",
"globalShortcut": {
"title": "全局快捷键",
"description": "按住快捷键即可在系统的任何位置录制。松开后进行转录。首次开启时,macOS 会请求「输入监控」权限。"
},
"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}}",
"size4": "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 天"
},
"clearAll": {
"title": "清除所有捕获",
"description": "永久删除所有捕获及其音频。此操作不可撤销。",
"action": "清除捕获"
}
},
"sidebar": {
"aboutTitle": "关于「捕获」",
"aboutBody": "在系统的任何位置按住快捷键说话,Voicebox 就会把您的声音转换成文本。可用任何克隆的声音回放、粘贴到任何应用,或导入到您的编程代理中。",
"differencesTitle": "不同之处",
"local": {
"title": "完全本地。",
"body": "Whisper 和精修 LLM 都在您的硬件上运行。无云端、无账号,您的声音不会离开本机。"
},
"playAs": {
"title": "以任何声音播放。",
"body": "转录可以用您克隆的任何档案朗读出来。"
},
"crossPlatform": {
"title": "跨平台。",
"body": "在 macOS、Windows 和 Linux 上使用相同的快捷键和流程。"
}
}
},
"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": { "gpu": {
"cpuOnly": "仅 CPU", "cpuOnly": "仅 CPU",
"vramUsed": "{{mb}} MB 显存", "vramUsed": "{{mb}} MB 显存",
+357 -1
View File
@@ -14,6 +14,7 @@
"nav": { "nav": {
"generate": "生成", "generate": "生成",
"stories": "故事", "stories": "故事",
"captures": "擷取",
"voices": "聲音", "voices": "聲音",
"effects": "效果", "effects": "效果",
"audio": "音訊", "audio": "音訊",
@@ -21,6 +22,138 @@
"settings": "設定", "settings": "設定",
"updateBadge": "更新" "updateBadge": "更新"
}, },
"captures": {
"title": "擷取",
"beta": "Beta",
"searchPlaceholder": "搜尋轉錄文字……",
"snippetEmpty": "(無轉錄文字)",
"noTranscriptError": "此擷取尚無轉錄文字",
"captureCardLabel": "擷取 · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"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": "重新精修",
"sendTo": "傳送至",
"delete": "刪除",
"playAs": "以 {{name}} 播放",
"playAsFallback": "以聲音播放……",
"playAsGenerating": "生成中…",
"playAsStop": "停止 · {{name}}",
"playAsStopFallback": "停止 · 聲音",
"playAsDropdownLabel": "以聲音播放轉錄文字"
},
"empty": {
"noMatches": "找不到符合 \"{{query}}\" 的擷取",
"none": "尚無擷取。",
"loading": "載入擷取中…",
"pickOne": "選擇一個擷取以檢視其轉錄文字。",
"holdToRecord": "按住以錄音",
"toggleHandsFree": "切換免持模式",
"pressShortcut": "在您的電腦上任何位置按下快捷鍵以開始第一次擷取。",
"turnOnShortcut": "開啟全域快捷鍵以從任何地方口述——或點選上方的「口述」進行 App 內擷取。",
"openSettings": "開啟擷取設定"
},
"toast": {
"deleteFailed": "刪除失敗",
"playAsFailed": "以聲音播放失敗",
"noVoice": "無聲音檔案",
"noVoiceDescription": "使用「以聲音播放」前請先建立聲音檔案。",
"transcriptCopied": "已複製轉錄文字",
"copyFailed": "複製失敗",
"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": { "voicesTab": {
"title": "聲音", "title": "聲音",
"loading": "載入聲音中…", "loading": "載入聲音中…",
@@ -125,7 +258,10 @@
"noPreference": "無偏好", "noPreference": "無偏好",
"defaultEngineHint": "選擇此檔案時自動使用此引擎。", "defaultEngineHint": "選擇此檔案時自動使用此引擎。",
"defaultEffects": "預設效果", "defaultEffects": "預設效果",
"defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。" "defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。",
"personalityLabel": "個性",
"personalityPlaceholder": "選填。描述這個聲音是誰以及他們如何說話。例如:「一位脾氣暴躁的海盜,只會用航海比喻說話」。會驅動生成頁面上的「撰寫」按鈕和角色化重寫切換。",
"personalityHint": "留空則在生成頁面隱藏「撰寫」按鈕和角色切換。"
}, },
"avatar": { "avatar": {
"alt": "頭像預覽" "alt": "頭像預覽"
@@ -550,6 +686,18 @@
"effects": { "effects": {
"none": "無效果", "none": "無效果",
"profileDefault": "檔案預設" "profileDefault": "檔案預設"
},
"compose": {
"tooltip": "撰寫",
"ariaLabel": "以角色撰寫一句台詞",
"failedTitle": "撰寫失敗",
"failedDescription": "無法從此個性生成文字。"
},
"persona": {
"tooltipActive": "以角色發話中",
"tooltipInactive": "以角色發話",
"ariaLabelActive": "以角色發話中",
"ariaLabelInactive": "以角色發話"
} }
}, },
"main": { "main": {
@@ -571,6 +719,8 @@
"tabs": { "tabs": {
"general": "一般", "general": "一般",
"generation": "生成", "generation": "生成",
"captures": "擷取",
"mcp": "MCP",
"gpu": "GPU", "gpu": "GPU",
"logs": "日誌", "logs": "日誌",
"changelog": "更新日誌", "changelog": "更新日誌",
@@ -678,6 +828,212 @@
"open": "開啟" "open": "開啟"
} }
}, },
"captures": {
"dictation": {
"title": "口述",
"description": "使用全域快捷鍵從電腦上任何位置進行擷取。",
"globalShortcut": {
"title": "全域快捷鍵",
"description": "按住快捷鍵以從電腦上任何位置錄音。放開後進行轉錄。第一次開啟時 macOS 會要求輸入監控權限。"
},
"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}}",
"size4": "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 天"
},
"clearAll": {
"title": "清除所有擷取",
"description": "永久刪除每一筆擷取與其音訊。此操作無法復原。",
"action": "清除擷取"
}
},
"sidebar": {
"aboutTitle": "關於擷取",
"aboutBody": "在電腦上任何位置按住快捷鍵說話,Voicebox 會將您的聲音轉成文字。可以用任何複製的聲音重播、貼到任何 App,或送進您的程式碼代理。",
"differencesTitle": "有何不同",
"local": {
"title": "完全在本機。",
"body": "Whisper 與精修 LLM 都在您的硬體上執行。沒有雲端、沒有帳號,您的聲音永遠不會離開電腦。"
},
"playAs": {
"title": "以任何聲音播放。",
"body": "轉錄文字可以用您複製過的任何聲音檔案讀回。"
},
"crossPlatform": {
"title": "跨平台。",
"body": "在 macOS、Windows 與 Linux 上享有相同的快捷鍵與相同的流程。"
}
}
},
"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": { "gpu": {
"cpuOnly": "僅 CPU", "cpuOnly": "僅 CPU",
"vramUsed": "{{mb}} MB 顯示記憶體", "vramUsed": "{{mb}} MB 顯示記憶體",
+4 -13
View File
@@ -131,8 +131,9 @@ class ApiClient {
} }
// ── Personality-driven text generation ───────────────────────────── // ── Personality-driven text generation ─────────────────────────────
// compose + rewrite power the generate-box buttons. Respond and speak // Compose produces a fresh in-character utterance the UI drops into
// are API-only for now — if a UI use appears, add methods here. // 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> { async composeWithPersonality(profileId: string): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, { return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
@@ -140,16 +141,6 @@ class ApiClient {
}); });
} }
async rewriteWithPersonality(
profileId: string,
text: string,
): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/rewrite`, {
method: 'POST',
body: JSON.stringify({ text }),
});
}
async addProfileSample( async addProfileSample(
profileId: string, profileId: string,
file: File, file: File,
@@ -511,7 +502,7 @@ class ApiClient {
}); });
} }
// MCP bindings — per-MCP-client voice/engine/intent mapping. // MCP bindings — per-MCP-client voice/engine/personality mapping.
async listMCPBindings(): Promise<MCPClientBindingListResponse> { async listMCPBindings(): Promise<MCPClientBindingListResponse> {
return this.request<MCPClientBindingListResponse>('/mcp/bindings'); return this.request<MCPClientBindingListResponse>('/mcp/bindings');
} }
+6 -4
View File
@@ -12,7 +12,7 @@ export interface VoiceProfileCreate {
preset_voice_id?: string; preset_voice_id?: string;
design_prompt?: string; design_prompt?: string;
default_engine?: string; default_engine?: string;
/** Free-form character prompt used by compose / rewrite / respond / speak. */ /** Free-form character prompt used by compose and the `/generate` personality-rewrite path. */
personality?: string; personality?: string;
} }
@@ -35,7 +35,7 @@ export interface VoiceProfileResponse {
updated_at: string; updated_at: string;
} }
/** Response returned by /profiles/{id}/compose | /rewrite | /respond. */ /** Response returned by /profiles/{id}/compose. */
export interface PersonalityTextResponse { export interface PersonalityTextResponse {
text: string; text: string;
model_size: string; model_size: string;
@@ -80,6 +80,8 @@ export interface GenerationRequest {
| 'tada' | 'tada'
| 'kokoro'; | 'kokoro';
instruct?: string; 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; max_chunk_chars?: number;
crossfade_ms?: number; crossfade_ms?: number;
normalize?: boolean; normalize?: boolean;
@@ -496,7 +498,7 @@ export interface MCPClientBinding {
label: string | null; label: string | null;
profile_id: string | null; profile_id: string | null;
default_engine: string | null; default_engine: string | null;
default_intent: 'respond' | 'rewrite' | 'compose' | null; default_personality: boolean;
last_seen_at: string | null; last_seen_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -507,7 +509,7 @@ export interface MCPClientBindingUpsert {
label?: string | null; label?: string | null;
profile_id?: string | null; profile_id?: string | null;
default_engine?: string | null; default_engine?: string | null;
default_intent?: 'respond' | 'rewrite' | 'compose' | null; default_personality?: boolean;
} }
export interface MCPClientBindingListResponse { export interface MCPClientBindingListResponse {
+4
View File
@@ -29,6 +29,7 @@ const generationSchema = z.object({
'kokoro', 'kokoro',
]) ])
.optional(), .optional(),
personality: z.boolean().optional(),
}); });
export type GenerationFormValues = z.infer<typeof generationSchema>; export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -66,6 +67,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: '1.7B', modelSize: '1.7B',
instruct: '', instruct: '',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen', engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
personality: false,
...options.defaultValues, ...options.defaultValues,
}, },
}); });
@@ -150,6 +152,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
model_size: hasModelSizes ? data.modelSize : undefined, model_size: hasModelSizes ? data.modelSize : undefined,
engine, engine,
instruct: supportsInstruct ? data.instruct || undefined : undefined, instruct: supportsInstruct ? data.instruct || undefined : undefined,
personality: data.personality || undefined,
max_chunk_chars: maxChunkChars, max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs, crossfade_ms: crossfadeMs,
normalize: normalizeAudio, normalize: normalizeAudio,
@@ -167,6 +170,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: data.modelSize, modelSize: data.modelSize,
instruct: '', instruct: '',
engine: data.engine, engine: data.engine,
personality: data.personality,
}); });
options.onSuccess?.(result.id); options.onSuccess?.(result.id);
} catch (error) { } catch (error) {
+10
View File
@@ -40,6 +40,16 @@ export function formatDate(date: string | Date): string {
}).replace(/^about /i, ''); }).replace(/^about /i, '');
} }
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
const ENGINE_DISPLAY_NAMES: Record<string, string> = { const ENGINE_DISPLAY_NAMES: Record<string, string> = {
qwen: 'Qwen', qwen: 'Qwen',
luxtts: 'LuxTTS', luxtts: 'LuxTTS',
+25
View File
@@ -35,6 +35,7 @@ def run_migrations(engine) -> None:
_migrate_effect_presets(engine, inspector, tables) _migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables) _migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables) _migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_normalize_storage_paths(engine, tables) _normalize_storage_paths(engine, tables)
@@ -233,6 +234,30 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
) )
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:
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")
def _normalize_storage_paths(engine, tables: set[str]) -> None: def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir.""" """Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path from pathlib import Path
+12 -9
View File
@@ -33,9 +33,10 @@ class VoiceProfile(Base):
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
design_prompt = Column(Text, nullable=True) # text description — only for designed design_prompt = Column(Text, nullable=True) # text description — only for designed
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
# Free-form character prompt used by the compose / rewrite / respond / speak # Free-form character prompt used by the compose button and the
# endpoints. Describes *what* this voice says and how, orthogonal to how # personality-rewrite path on /generate. Describes *what* this voice
# it sounds (which is handled by the preset / cloning metadata above). # says and how, orthogonal to how it sounds (handled by the preset /
# cloning metadata above).
personality = Column(Text, nullable=True) personality = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -71,9 +72,10 @@ class Generation(Base):
status = Column(String, default="completed") status = Column(String, default="completed")
error = Column(Text, nullable=True) error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False) is_favorited = Column(Boolean, default=False)
# Origin of this generation — "manual" for regular /generate calls, # Origin of this generation — "manual" for plain /generate calls,
# "personality_speak" for rows created by POST /profiles/{id}/speak. # "personality_speak" for rows whose text was rewritten through the
# Future sources (bulk import, agent replies, etc.) can extend this. # profile's personality LLM before TTS. Future sources (bulk import,
# agent replies, etc.) can extend this.
source = Column(String, nullable=False, default="manual") source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -228,7 +230,7 @@ class GenerationSettings(Base):
class MCPClientBinding(Base): class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, intent). """Per-MCP-client settings (voice profile, engine, personality default).
Lets users bind distinct voices to distinct agents — e.g. Claude Code Lets users bind distinct voices to distinct agents — e.g. Claude Code
speaks in "Morgan," Cursor in "Scarlett." The MCP client identifies speaks in "Morgan," Cursor in "Scarlett." The MCP client identifies
@@ -243,8 +245,9 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True) # display name label = Column(String, nullable=True) # display name
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True) profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True) default_engine = Column(String, nullable=True)
# "respond" | "rewrite" | "compose" — null means plain TTS (no LLM transform). # When true, voicebox.speak routes through the profile's personality LLM
default_intent = Column(String, nullable=True) # (rewrite) before TTS by default. Callers can still override per call.
default_personality = Column(Boolean, nullable=False, default=False)
last_seen_at = Column(DateTime, nullable=True) last_seen_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+28 -45
View File
@@ -12,7 +12,7 @@ import base64 as b64
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any
from fastmcp import FastMCP from fastmcp import FastMCP
@@ -47,7 +47,7 @@ def register_tools(mcp: FastMCP) -> None:
text: str, text: str,
profile: str | None = None, profile: str | None = None,
engine: str | None = None, engine: str | None = None,
intent: Literal["respond", "rewrite", "compose"] | None = None, personality: bool | None = None,
language: str | None = None, language: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Speak ``text`` in a voice profile. """Speak ``text`` in a voice profile.
@@ -56,14 +56,18 @@ def register_tools(mcp: FastMCP) -> None:
omitted, the server looks up the per-client binding for the calling omitted, the server looks up the per-client binding for the calling
MCP client, then falls back to the global default voice. MCP client, then falls back to the global default voice.
``intent`` only matters for profiles that have a personality prompt — ``personality`` only matters for profiles that have a personality
when set, the text is first transformed by the LLM (respond to it, prompt — when true, the text is first rewritten in character by the
rewrite it in character, or compose a fresh utterance). Leave unset LLM before TTS. When omitted, the per-client binding's
for plain TTS. ``default_personality`` flag decides; when that is unset, the
default is plain TTS.
""" """
from ..database.models import MCPClientBinding
db = next(get_db()) db = next(get_db())
try: try:
vp = resolve_profile(profile, current_client_id.get(), db) client_id = current_client_id.get()
vp = resolve_profile(profile, client_id, db)
if vp is None: if vp is None:
raise ValueError( raise ValueError(
"No voice profile resolved. Pass `profile=` with a " "No voice profile resolved. Pass `profile=` with a "
@@ -71,24 +75,24 @@ def register_tools(mcp: FastMCP) -> None:
"Voicebox → Settings → MCP." "Voicebox → Settings → MCP."
) )
# Persona path if intent requested and personality present. resolved_personality = personality
if intent is not None and vp.personality: if resolved_personality is None and client_id:
return await _speak_with_persona( binding = (
profile_id=vp.id, db.query(MCPClientBinding)
profile_name=vp.name, .filter(MCPClientBinding.client_id == client_id)
text=text, .first()
engine=engine,
intent=intent,
language=language,
db=db,
) )
if binding is not None:
resolved_personality = bool(binding.default_personality)
return await _speak_plain( use_persona = bool(resolved_personality) and bool(vp.personality)
return await _speak(
profile_id=vp.id, profile_id=vp.id,
profile_name=vp.name, profile_name=vp.name,
text=text, text=text,
engine=engine, engine=engine,
language=language, language=language,
personality=use_persona,
db=db, db=db,
) )
finally: finally:
@@ -200,19 +204,21 @@ def register_tools(mcp: FastMCP) -> None:
db.close() db.close()
# ─── Speak helpers ───────────────────────────────────────────────────────── # ─── Speak helper ──────────────────────────────────────────────────────────
async def _speak_plain( async def _speak(
*, *,
profile_id: str, profile_id: str,
profile_name: str, profile_name: str,
text: str, text: str,
engine: str | None, engine: str | None,
language: str | None, language: str | None,
personality: bool,
db, db,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Plain TTS path — mirrors POST /generate. No LLM transform.""" """Delegate to POST /generate — the route handles personality-rewrite
internally when ``personality=true`` and the profile has a prompt."""
from ..routes.generations import generate_speech from ..routes.generations import generate_speech
req = models.GenerationRequest( req = models.GenerationRequest(
@@ -220,35 +226,12 @@ async def _speak_plain(
text=text, text=text,
language=language or "en", language=language or "en",
engine=engine or "qwen", engine=engine or "qwen",
personality=personality,
) )
generation = await generate_speech(req, db) generation = await generate_speech(req, db)
return _speak_response(generation, profile_name, source="mcp") return _speak_response(generation, profile_name, source="mcp")
async def _speak_with_persona(
*,
profile_id: str,
profile_name: str,
text: str,
engine: str | None,
intent: str,
language: str | None,
db,
) -> dict[str, Any]:
"""LLM-transformed path — reuses POST /profiles/{id}/speak."""
from ..routes.profiles import speak_in_character
req = models.PersonalitySpeakRequest(
text=text,
persist=True,
language=language,
engine=engine,
intent=intent,
)
generation = await speak_in_character(profile_id, req, db)
return _speak_response(generation, profile_name, source="mcp")
def _speak_response( def _speak_response(
generation, profile_name: str, *, source: str generation, profile_name: str, *, source: str
) -> dict[str, Any]: ) -> dict[str, Any]:
+18 -49
View File
@@ -81,6 +81,10 @@ class GenerationRequest(BaseModel):
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$") model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500) instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$") engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
personality: bool = Field(
default=False,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS.",
)
max_chunk_chars: int = Field( max_chunk_chars: int = Field(
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting" default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
) )
@@ -297,8 +301,9 @@ class GenerationSettingsUpdate(BaseModel):
class MCPClientBindingResponse(BaseModel): class MCPClientBindingResponse(BaseModel):
"""Per-MCP-client voice binding — what voice / engine / intent the server """Per-MCP-client voice binding — what voice / engine the server should
should use when a given client_id calls voicebox.speak without args.""" use when a given client_id calls voicebox.speak without args, plus an
opt-in personality-rewrite default."""
client_id: str client_id: str
label: Optional[str] = None label: Optional[str] = None
@@ -307,9 +312,7 @@ class MCPClientBindingResponse(BaseModel):
None, None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
) )
default_intent: Optional[str] = Field( default_personality: bool = False
None, pattern="^(respond|rewrite|compose)$"
)
last_seen_at: Optional[datetime] = None last_seen_at: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -328,9 +331,7 @@ class MCPClientBindingUpsert(BaseModel):
None, None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
) )
default_intent: Optional[str] = Field( default_personality: bool = False
None, pattern="^(respond|rewrite|compose)$"
)
class MCPClientBindingListResponse(BaseModel): class MCPClientBindingListResponse(BaseModel):
@@ -349,8 +350,9 @@ class SpeakRequest(BaseModel):
None, None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
) )
intent: Optional[str] = Field( personality: Optional[bool] = Field(
None, pattern="^(respond|rewrite|compose)$" None,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS. When null, the per-client binding's default_personality flag decides.",
) )
language: Optional[str] = Field( language: Optional[str] = Field(
None, None,
@@ -380,53 +382,20 @@ class LLMGenerateResponse(BaseModel):
model_size: str model_size: str
# ── Profile personality endpoints ───────────────────────────────────── # ── Profile personality endpoint ──────────────────────────────────────
# compose / rewrite / respond return raw text; /speak chains LLM → TTS # The sole standalone personality endpoint is ``/profiles/{id}/compose``,
# and either persists as a generation (persist=true) or streams audio # which produces a fresh in-character utterance the UI drops into the
# back transiently. # generate textarea. Rewrite is now reached via ``/generate`` with
# ``personality=true``.
class PersonalityTextRequest(BaseModel):
"""Body for ``/profiles/{id}/rewrite`` and ``/profiles/{id}/respond``."""
text: str = Field(..., min_length=1, max_length=10000)
class PersonalityTextResponse(BaseModel): class PersonalityTextResponse(BaseModel):
"""Response returned by compose / rewrite / respond endpoints.""" """Response returned by the ``/profiles/{id}/compose`` endpoint."""
text: str text: str
model_size: str model_size: str
class PersonalitySpeakRequest(BaseModel):
"""Body for ``/profiles/{id}/speak`` — LLM transform then TTS."""
text: str = Field(..., min_length=1, max_length=10000)
# When true, the generated audio is persisted as a regular row in the
# generations table (tagged with ``source="personality_speak"``) and
# the response returns a GenerationResponse the client polls like any
# other generation. When false, the LLM output is fed to a synchronous
# TTS call and the wav bytes stream back directly.
persist: bool = True
language: Optional[str] = Field(
None,
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
)
engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
# ``respond`` is the default because this endpoint is designed for
# conversational / agent-style callers. Override to ``rewrite`` to
# speak the user's text in character verbatim, or ``compose`` to
# speak an utterance the character would come up with on its own
# (in which case ``text`` is treated as a topical hint, not content).
intent: str = Field(
default="respond", pattern="^(respond|rewrite|compose)$"
)
class ModelReadiness(BaseModel): class ModelReadiness(BaseModel):
"""Per-model entry in the dictation readiness checklist. """Per-model entry in the dictation readiness checklist.
+17 -4
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from .. import models from .. import models
from ..services import history, profiles, tts from ..services import history, personality, profiles, tts
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
from ..services.generation import run_generation from ..services.generation import run_generation
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
@@ -47,9 +47,21 @@ async def generate_speech(
model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
text = data.text
source = "manual"
if data.personality and getattr(profile, "personality", None):
try:
llm_result = await personality.rewrite_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
text = llm_result.text.strip()
if not text:
raise HTTPException(status_code=500, detail="LLM produced empty output; nothing to speak.")
source = "personality_speak"
generation = await history.create_generation( generation = await history.create_generation(
profile_id=data.profile_id, profile_id=data.profile_id,
text=data.text, text=text,
language=data.language, language=data.language,
audio_path="", audio_path="",
duration=0, duration=0,
@@ -60,12 +72,13 @@ async def generate_speech(
status="generating", status="generating",
engine=engine, engine=engine,
model_size=model_size if engine_has_model_sizes(engine) else None, model_size=model_size if engine_has_model_sizes(engine) else None,
source=source,
) )
task_manager.start_generation( task_manager.start_generation(
task_id=generation_id, task_id=generation_id,
profile_id=data.profile_id, profile_id=data.profile_id,
text=data.text, text=text,
) )
effects_chain_config = None effects_chain_config = None
@@ -86,7 +99,7 @@ async def generate_speech(
run_generation( run_generation(
generation_id=generation_id, generation_id=generation_id,
profile_id=data.profile_id, profile_id=data.profile_id,
text=data.text, text=text,
language=data.language, language=data.language,
engine=engine, engine=engine,
model_size=model_size, model_size=model_size,
+1 -1
View File
@@ -55,7 +55,7 @@ async def upsert_mcp_binding(
row.label = data.label row.label = data.label
row.profile_id = data.profile_id row.profile_id = data.profile_id
row.default_engine = data.default_engine row.default_engine = data.default_engine
row.default_intent = data.default_intent row.default_personality = data.default_personality
row.updated_at = datetime.utcnow() row.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(row) db.refresh(row)
+10 -186
View File
@@ -4,7 +4,6 @@ import io
import json as _json import json as _json
import logging import logging
import tempfile import tempfile
import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -15,7 +14,7 @@ from sqlalchemy.orm import Session
from .. import config, models from .. import config, models
from ..app import safe_content_disposition from ..app import safe_content_disposition
from ..database import VoiceProfile as DBVoiceProfile, get_db from ..database import VoiceProfile as DBVoiceProfile, get_db
from ..services import channels, export_import, history, personality, profiles from ..services import channels, export_import, personality, profiles
from ..services.profiles import _profile_to_response from ..services.profiles import _profile_to_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -364,31 +363,12 @@ async def update_profile_effects(
return _profile_to_response(profile) return _profile_to_response(profile)
# ── Personality endpoints ───────────────────────────────────────────── # ── Personality endpoint ──────────────────────────────────────────────
# compose / rewrite / respond / speak. All four require a non-empty # Only ``/profiles/{id}/compose`` remains — the UI's compose button
# personality on the profile; the service layer raises ValueError which # produces a fresh in-character utterance the user can edit before
# we translate to HTTP 400. compose and rewrite power the generate-box # speaking. Rewrite now happens inside ``/generate`` (and ``/speak``)
# UI; respond is API-only for conversational / agent-style callers; # when ``personality=true``; there is no standalone rewrite/respond/speak
# speak chains LLM → TTS in one call. # endpoint.
def _load_profile_for_personality(profile_id: str, db: Session) -> DBVoiceProfile:
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return profile
def _resolve_speak_engine(
data: models.PersonalitySpeakRequest,
profile: DBVoiceProfile,
) -> str:
return (
data.engine
or getattr(profile, "default_engine", None)
or getattr(profile, "preset_engine", None)
or "qwen"
)
@router.post( @router.post(
@@ -400,7 +380,9 @@ async def compose_in_character(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Produce a fresh utterance in the profile's character voice.""" """Produce a fresh utterance in the profile's character voice."""
profile = _load_profile_for_personality(profile_id, db) profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
try: try:
result = await personality.compose_as_profile(profile.personality) result = await personality.compose_as_profile(profile.personality)
except ValueError as e: except ValueError as e:
@@ -408,161 +390,3 @@ async def compose_in_character(
return models.PersonalityTextResponse( return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size text=result.text, model_size=result.model_size
) )
@router.post(
"/profiles/{profile_id}/rewrite",
response_model=models.PersonalityTextResponse,
)
async def rewrite_in_character(
profile_id: str,
data: models.PersonalityTextRequest,
db: Session = Depends(get_db),
):
"""Restate the user's text in the profile's character voice."""
profile = _load_profile_for_personality(profile_id, db)
try:
result = await personality.rewrite_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size
)
@router.post(
"/profiles/{profile_id}/respond",
response_model=models.PersonalityTextResponse,
)
async def respond_in_character(
profile_id: str,
data: models.PersonalityTextRequest,
db: Session = Depends(get_db),
):
"""Produce an in-character reply to the user's text. API-only surface."""
profile = _load_profile_for_personality(profile_id, db)
try:
result = await personality.respond_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size
)
@router.post("/profiles/{profile_id}/speak")
async def speak_in_character(
profile_id: str,
data: models.PersonalitySpeakRequest,
db: Session = Depends(get_db),
):
"""LLM (by intent) → TTS, returned either as a generation row the client
polls (``persist=true``) or a direct wav stream (``persist=false``).
Response shape depends on ``persist``:
- ``true``: 200 JSON ``GenerationResponse`` with ``status="generating"``.
Row is tagged ``source="personality_speak"``.
- ``false``: 200 ``audio/wav`` streaming response, nothing persisted.
"""
from ..backends import engine_has_model_sizes, load_engine_model
from ..services.generation import generate_audio_sync, run_generation
from ..services.task_queue import enqueue_generation
from ..utils.tasks import get_task_manager
profile = _load_profile_for_personality(profile_id, db)
engine = _resolve_speak_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Run the LLM transform per requested intent. personality.* enforce
# the empty-personality guard — catch and translate here.
try:
if data.intent == "compose":
llm_result = await personality.compose_as_profile(profile.personality)
elif data.intent == "rewrite":
llm_result = await personality.rewrite_as_profile(
profile.personality, data.text
)
else: # "respond"
llm_result = await personality.respond_as_profile(
profile.personality, data.text
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
spoken_text = llm_result.text.strip()
if not spoken_text:
raise HTTPException(
status_code=500,
detail="LLM produced empty output; nothing to speak.",
)
resolved_language = data.language or getattr(profile, "language", None) or "en"
model_size = "1.7B" if engine_has_model_sizes(engine) else None
if not data.persist:
# Transient path — generate synchronously, stream wav back.
# ``load_engine_model`` is defensive against engines that don't
# take a size (kokoro, etc.); pass "default" to match the
# in-tree signature default.
await load_engine_model(engine, model_size or "default")
wav_bytes = await generate_audio_sync(
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
engine=engine,
model_size=model_size or "default",
)
return StreamingResponse(
iter([wav_bytes]),
media_type="audio/wav",
headers={"Content-Disposition": 'inline; filename="speech.wav"'},
)
# Persistent path — mirrors /generate exactly, plus source marker.
generation_id = str(uuid.uuid4())
task_manager = get_task_manager()
generation = await history.create_generation(
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
audio_path="",
duration=0,
seed=None,
db=db,
instruct=None,
generation_id=generation_id,
status="generating",
engine=engine,
model_size=model_size if engine_has_model_sizes(engine) else None,
source="personality_speak",
)
task_manager.start_generation(
task_id=generation_id,
profile_id=profile_id,
text=spoken_text,
)
enqueue_generation(
generation_id,
run_generation(
generation_id=generation_id,
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
engine=engine,
model_size=model_size,
seed=None,
normalize=True,
effects_chain=None,
instruct=None,
mode="generate",
),
)
return generation
+22 -27
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models from .. import models
from ..database import get_db from ..database import MCPClientBinding, get_db
from ..mcp_server import events as mcp_events from ..mcp_server import events as mcp_events
from ..mcp_server.resolve import resolve_profile from ..mcp_server.resolve import resolve_profile
@@ -52,34 +52,29 @@ async def speak(
), ),
) )
# Persona path if intent requested AND profile has a personality prompt. # Resolve per-client personality default when the caller didn't pin it.
if data.intent is not None and profile.personality: personality_flag = data.personality
from .profiles import speak_in_character if personality_flag is None and client_id:
binding = (
generation = await speak_in_character( db.query(MCPClientBinding)
profile.id, .filter(MCPClientBinding.client_id == client_id)
models.PersonalitySpeakRequest( .first()
text=data.text,
persist=True,
language=data.language,
engine=data.engine,
intent=data.intent,
),
db,
) )
else: if binding is not None:
# Plain TTS path — matches POST /generate. personality_flag = bool(binding.default_personality)
from .generations import generate_speech
generation = await generate_speech( from .generations import generate_speech
models.GenerationRequest(
profile_id=profile.id, generation = await generate_speech(
text=data.text, models.GenerationRequest(
language=data.language or "en", profile_id=profile.id,
engine=data.engine or "qwen", text=data.text,
), language=data.language or "en",
db, engine=data.engine or "qwen",
) personality=bool(personality_flag),
),
db,
)
mcp_events.publish( mcp_events.publish(
"speak-start", "speak-start",
+14 -46
View File
@@ -1,26 +1,22 @@
""" """
Personality-driven text generation — lets a voice profile "speak" or "reply" Personality-driven text generation — lets a voice profile "speak" or
using an LLM that takes on the character described by the profile's restate text using an LLM that takes on the character described by the
``personality`` prompt. profile's ``personality`` prompt.
Three entry points: Two entry points:
- :func:`compose_as_profile` — zero-input, the character produces a fresh - :func:`compose_as_profile` — zero-input, the character produces a fresh
utterance. Wired to the "Compose" UI button (fill an empty generate box) utterance. Wired to the Compose button in the generate box and to the
and to the ``/profiles/{id}/compose`` endpoint. ``/profiles/{id}/compose`` endpoint.
- :func:`rewrite_as_profile` — takes user text, restates it in the - :func:`rewrite_as_profile` — takes user text, restates it in the
character's voice while keeping every idea. Wired to the "Rewrite" character's voice while keeping every idea. Invoked by ``POST /generate``
button and the ``/profiles/{id}/rewrite`` endpoint. (and ``POST /speak``) when ``personality=true`` and the profile has a
- :func:`respond_as_profile` — takes user text and produces the personality prompt set.
character's reply to it (new content, not a rewrite). API-only via
``/profiles/{id}/respond`` and the ``/profiles/{id}/speak`` endpoint
when ``intent="respond"``.
All three reuse the same local Qwen3 instance that refinement uses — no Both reuse the same local Qwen3 instance that refinement uses — no extra
extra model downloads, no extra warm-up. Temperature is tuned per mode: model downloads, no extra warm-up. Temperature is tuned per mode: compose
compose runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to the user's
the user's ideas, respond mid-range (0.7) so the character feels alive ideas.
without drifting.
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -47,9 +43,6 @@ _COMPOSE_TASK = """Task: Produce one short utterance — one or two sentences at
_REWRITE_TASK = """Task: The user's next message is a piece of text. Restate every idea in it using your character's voice — keep the meaning, change the wording. Do not add new ideas, do not drop any, do not reply to the text. Output only the restated version.""" _REWRITE_TASK = """Task: The user's next message is a piece of text. Restate every idea in it using your character's voice — keep the meaning, change the wording. Do not add new ideas, do not drop any, do not reply to the text. Output only the restated version."""
_RESPOND_TASK = """Task: The user's next message is spoken to your character. Reply in character. Produce new content — do not echo or paraphrase the user's words, do not narrate back what they said. One to three sentences of natural speech the character would say in reply."""
@dataclass @dataclass
class PersonalityResult: class PersonalityResult:
"""What the three service functions return.""" """What the three service functions return."""
@@ -71,7 +64,7 @@ def _build_system_prompt(personality: str, task: str) -> str:
def _require_personality(personality: str | None) -> str: def _require_personality(personality: str | None) -> str:
if not personality or not personality.strip(): if not personality or not personality.strip():
raise ValueError( raise ValueError(
"This profile has no personality set. Add one on the profile to use compose, rewrite, respond, or speak." "This profile has no personality set. Add one on the profile to use compose or personality-rewrite."
) )
return personality return personality
@@ -125,28 +118,3 @@ async def rewrite_as_profile(
model_size=resolved_size, model_size=resolved_size,
) )
return PersonalityResult(text=output.strip(), model_size=resolved_size) return PersonalityResult(text=output.strip(), model_size=resolved_size)
async def respond_as_profile(
personality: str | None,
user_text: str,
model_size: str | None = None,
) -> PersonalityResult:
"""Produce the character's in-character reply to the user's text."""
character = _require_personality(personality)
cleaned = collapse_repetitive_artifacts(user_text)
if not cleaned.strip():
raise ValueError("Respond needs non-empty text to reply to.")
backend = llm_service.get_llm_model()
resolved_size = model_size or backend.model_size
system_prompt = _build_system_prompt(character, _RESPOND_TASK)
output = await backend.generate(
prompt=cleaned,
system=system_prompt,
max_tokens=512,
temperature=0.7,
model_size=resolved_size,
)
return PersonalityResult(text=output.strip(), model_size=resolved_size)
+33 -63
View File
@@ -1,14 +1,15 @@
""" """
Personality-service sanity sweep — spins up a throwaway profile with a Personality-service sanity sweep — spins up a throwaway profile with a
fake personality, hits ``/profiles/{id}/compose``, ``/rewrite``, and fake personality, exercises ``/profiles/{id}/compose`` and the rewrite
``/respond``, and scores each output against a handful of deterministic path on ``/generate`` (``personality=true``), and scores each output
heuristics so a person can eyeball quality. against a handful of deterministic heuristics so a person can eyeball
quality.
Same philosophy as ``test_refinement_samples.py``: LLM output is Same philosophy as ``test_refinement_samples.py``: LLM output is
non-deterministic, "correctness" is subjective, so this is interactive non-deterministic, "correctness" is subjective, so this is interactive
evaluation — not a CI pass/fail. Gross failures (prompt-echo, refusal, evaluation — not a CI pass/fail. Gross failures (prompt-echo, refusal,
empty output, user-text echoing for respond) trip heuristic flags. A empty output) trip heuristic flags. A human still reads the final
human still reads the final column. column.
Usage: Usage:
# Backend server must be running. # Backend server must be running.
@@ -49,9 +50,9 @@ class Personality:
description: str description: str
"""Free-form character prompt saved to the profile.""" """Free-form character prompt saved to the profile."""
sample_text: str sample_text: str
"""Input used for rewrite / respond. Picked so each personality has """Input used for rewrite. Picked so each personality has something
something distinctive to say about it — an ill fit between text and distinctive to say about it — an ill fit between text and personality
personality makes the transformation more obvious.""" makes the transformation more obvious."""
PERSONALITIES: tuple[Personality, ...] = ( PERSONALITIES: tuple[Personality, ...] = (
@@ -121,14 +122,13 @@ class Scorecard:
endpoint: str endpoint: str
model: str model: str
input_text: str input_text: str
"""Empty for compose, the sample_text for rewrite/respond.""" """Empty for compose."""
refined: str refined: str
latency_ms: int latency_ms: int
length_chars: int = 0 length_chars: int = 0
prompt_leak: Optional[str] = None prompt_leak: Optional[str] = None
refusal: Optional[str] = None refusal: Optional[str] = None
stage_directions: list[str] = field(default_factory=list) stage_directions: list[str] = field(default_factory=list)
echoed_input: bool = False
flags: list[str] = field(default_factory=list) flags: list[str] = field(default_factory=list)
@@ -141,20 +141,6 @@ def first_match(patterns, text: str) -> Optional[str]:
return None return None
def check_echo(input_text: str, output_text: str) -> bool:
"""Rough check — does the output start with (≥ 15 chars of) the input?
Respond is the target: the character should produce new content, not
regurgitate the user's words. Rewrite is SUPPOSED to preserve the
ideas, so this check is only meaningful for respond-mode output.
"""
if not input_text or not output_text:
return False
norm_in = re.sub(r"\s+", " ", input_text.strip().lower())[:40]
norm_out = re.sub(r"\s+", " ", output_text.strip().lower())[: len(norm_in)]
return norm_in == norm_out and len(norm_in) >= 15
def score( def score(
personality: Personality, personality: Personality,
endpoint: str, endpoint: str,
@@ -175,8 +161,6 @@ def score(
refusal=first_match(REFUSAL_PHRASES, refined), refusal=first_match(REFUSAL_PHRASES, refined),
stage_directions=STAGE_DIRECTION_RE.findall(refined)[:3], stage_directions=STAGE_DIRECTION_RE.findall(refined)[:3],
) )
if endpoint == "respond":
card.echoed_input = check_echo(input_text, refined)
if not refined.strip(): if not refined.strip():
card.flags.append("empty-output") card.flags.append("empty-output")
@@ -186,8 +170,6 @@ def score(
card.flags.append(f"refusal({card.refusal!r})") card.flags.append(f"refusal({card.refusal!r})")
if card.stage_directions: if card.stage_directions:
card.flags.append(f"stage-directions={card.stage_directions}") card.flags.append(f"stage-directions={card.stage_directions}")
if card.echoed_input:
card.flags.append("echoed-input")
return card return card
@@ -198,10 +180,10 @@ def score(
DEFAULT_PORTS = (8000, 8765, 8899, 17493) DEFAULT_PORTS = (8000, 8765, 8899, 17493)
THROWAWAY_PROFILE_PREFIX = "personality-harness-" THROWAWAY_PROFILE_PREFIX = "personality-harness-"
KOKORO_PROBE_VOICE = "af_heart" KOKORO_PROBE_VOICE = "af_heart"
"""Any valid kokoro voice id works — compose/rewrite/respond never """Any valid kokoro voice id works — compose never calls into TTS, it
actually call into TTS, they just need a profile row with a personality just needs a profile row with a personality attached. We pick a
attached. We pick a known-shipping Kokoro voice so the throwaway known-shipping Kokoro voice so the throwaway profile satisfies the
profile satisfies the preset-engine validator on creation.""" preset-engine validator on creation."""
def detect_backend_port(hint: Optional[int]) -> int: def detect_backend_port(hint: Optional[int]) -> int:
@@ -258,19 +240,14 @@ def delete_profile(client: httpx.Client, port: int, profile_id: str) -> None:
print(f" (warning: failed to delete throwaway profile {profile_id}: {e})") print(f" (warning: failed to delete throwaway profile {profile_id}: {e})")
def hit_endpoint( def hit_compose(
client: httpx.Client, client: httpx.Client,
port: int, port: int,
profile_id: str, profile_id: str,
endpoint: str,
text: Optional[str],
) -> tuple[str, int]: ) -> tuple[str, int]:
start = time.monotonic() start = time.monotonic()
url = f"http://127.0.0.1:{port}/profiles/{profile_id}/{endpoint}" url = f"http://127.0.0.1:{port}/profiles/{profile_id}/compose"
if endpoint == "compose": resp = client.post(url, timeout=180.0)
resp = client.post(url, timeout=180.0)
else:
resp = client.post(url, json={"text": text}, timeout=180.0)
latency_ms = int((time.monotonic() - start) * 1000) latency_ms = int((time.monotonic() - start) * 1000)
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("text", "").strip(), latency_ms return resp.json().get("text", "").strip(), latency_ms
@@ -334,29 +311,22 @@ def main() -> int:
print(f" [{personality.name}] ", end="", flush=True) print(f" [{personality.name}] ", end="", flush=True)
profile_id = create_throwaway_profile(client, port, personality, model) profile_id = create_throwaway_profile(client, port, personality, model)
try: try:
for endpoint, input_text in ( try:
("compose", None), text, latency = hit_compose(client, port, profile_id)
("rewrite", personality.sample_text), except Exception as e:
("respond", personality.sample_text), print(f" compose:ERR ({e})", end="")
): continue
try: card = score(
text, latency = hit_endpoint( personality=personality,
client, port, profile_id, endpoint, input_text endpoint="compose",
) model=model,
except Exception as e: input_text="",
print(f" {endpoint}:ERR ({e})", end="") refined=text,
continue latency_ms=latency,
card = score( )
personality=personality, cards.append(card)
endpoint=endpoint, status = "ok" if not card.flags else "⚠"
model=model, print(f" compose:{status} ({latency}ms)", end="")
input_text=input_text or "",
refined=text,
latency_ms=latency,
)
cards.append(card)
status = "ok" if not card.flags else "⚠"
print(f" {endpoint}:{status} ({latency}ms)", end="")
print() print()
finally: finally:
delete_profile(client, port, profile_id) delete_profile(client, port, profile_id)
+2 -2
View File
@@ -13,7 +13,7 @@ you own. Everything runs on your hardware.
- **Dictation** — hold a chord anywhere on your machine, speak, release; the transcript pastes into the focused field - **Dictation** — hold a chord anywhere on your machine, speak, release; the transcript pastes into the focused field
- **Captures tab** — paired audio + transcript archive, retranscribe / refine / play-as-voice - **Captures tab** — paired audio + transcript archive, retranscribe / refine / play-as-voice
- **Voice personalities** — per-profile compose / rewrite / respond, powered by a local LLM - **Voice personalities** — per-profile compose button + persona-rewrite toggle, powered by a local LLM
- **Agents speak back** — any MCP-aware agent can call Voicebox to speak in one of your cloned voices - **Agents speak back** — any MCP-aware agent can call Voicebox to speak in one of your cloned voices
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, Kokoro - **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, Kokoro
- **Cloning and preset voices** — zero-shot cloning or 50+ curated preset voices - **Cloning and preset voices** — zero-shot cloning or 50+ curated preset voices
@@ -42,5 +42,5 @@ you own. Everything runs on your hardware.
- [Installation](/overview/installation) — download and install Voicebox - [Installation](/overview/installation) — download and install Voicebox
- [Quick Start](/overview/quick-start) — get up and running in 5 minutes - [Quick Start](/overview/quick-start) — get up and running in 5 minutes
- [Dictation](/overview/dictation) — start talking to your computer - [Dictation](/overview/dictation) — start talking to your computer
- [Voice Personalities](/overview/voice-personalities) — compose, rewrite, respond in any profile - [Voice Personalities](/overview/voice-personalities) — compose and rewrite in any profile
- [API Reference](/api-reference) — integrate voice synthesis into your apps - [API Reference](/api-reference) — integrate voice synthesis into your apps
+2 -2
View File
@@ -185,8 +185,8 @@ of your machine can talk to.
The global hotkey flow that feeds most captures. The global hotkey flow that feeds most captures.
</Card> </Card>
<Card title="Voice Personalities" href="/overview/voice-personalities"> <Card title="Voice Personalities" href="/overview/voice-personalities">
Per-profile compose / rewrite / respond modes for captures you want to Per-profile compose button and persona rewrite toggle for captures you
transform, not just transcribe. want to transform, not just transcribe.
</Card> </Card>
<Card title="Creating Voice Profiles" href="/overview/creating-voice-profiles"> <Card title="Creating Voice Profiles" href="/overview/creating-voice-profiles">
Promote a capture into a voice sample on a profile. Promote a capture into a voice sample on a profile.
+1 -1
View File
@@ -200,7 +200,7 @@ A few cases where Voicebox deliberately does *not* synthesize a paste:
The paired audio + transcript archive every dictation lands in. The paired audio + transcript archive every dictation lands in.
</Card> </Card>
<Card title="Voice Personalities" href="/overview/voice-personalities"> <Card title="Voice Personalities" href="/overview/voice-personalities">
The same local LLM doubles as per-profile compose / rewrite / respond. The same local LLM powers per-profile compose and persona rewrite.
</Card> </Card>
<Card title="Transcription" href="/developer/transcription"> <Card title="Transcription" href="/developer/transcription">
Developer-level details on Whisper, Whisper Turbo, and the STT backend. Developer-level details on Whisper, Whisper Turbo, and the STT backend.
+3 -3
View File
@@ -14,7 +14,7 @@ accounts:
- **Agents talk back** — any MCP-aware agent can call Voicebox to speak in - **Agents talk back** — any MCP-aware agent can call Voicebox to speak in
one of your cloned voices one of your cloned voices
- **Voices speak for themselves** — voice profiles can carry a personality - **Voices speak for themselves** — voice profiles can carry a personality
that compose, rewrite, or respond to text before it's spoken that composes fresh lines or rewrites text before it's spoken
It's the free, local alternative to both ElevenLabs (voice cloning and TTS) It's the free, local alternative to both ElevenLabs (voice cloning and TTS)
and WisprFlow (voice dictation for agents and power users) — covering both and WisprFlow (voice dictation for agents and power users) — covering both
@@ -32,7 +32,7 @@ shared between input and output.
- **Preset voices** — 50+ curated voices via Kokoro and Qwen CustomVoice - **Preset voices** — 50+ curated voices via Kokoro and Qwen CustomVoice
for when you don't want to clone (see [Preset Voices](/overview/preset-voices)) for when you don't want to clone (see [Preset Voices](/overview/preset-voices))
- **Voice personalities** — optional free-form personality on any profile - **Voice personalities** — optional free-form personality on any profile
plus compose / rewrite / respond modes powered by a local LLM (see plus a compose button and persona-rewrite toggle powered by a local LLM (see
[Voice Personalities](/overview/voice-personalities)) [Voice Personalities](/overview/voice-personalities))
- **Post-processing effects** — pitch shift, reverb, delay, chorus, - **Post-processing effects** — pitch shift, reverb, delay, chorus,
compression, filters (Spotify's Pedalboard) compression, filters (Spotify's Pedalboard)
@@ -66,7 +66,7 @@ between dictation, the Captures tab, and per-profile personality modes:
| Layer | Models | | Layer | Models |
|---|---| |---|---|
| **STT** | Whisper Base / Small / Medium / Large / Turbo (PyTorch or MLX) | | **STT** | Whisper Base / Small / Medium / Large / Turbo (PyTorch or MLX) |
| **LLM** | Qwen3 0.6B / 1.7B / 4B (refinement + per-profile compose / rewrite / respond) | | **LLM** | Qwen3 0.6B / 1.7B / 4B (refinement + per-profile compose / persona-rewrite) |
No cloud fallback, no bring-your-own-API-key. Local is the product. No cloud fallback, no bring-your-own-API-key. Local is the product.
+7 -8
View File
@@ -117,7 +117,7 @@ voicebox.speak({
text: "Deploy complete.", text: "Deploy complete.",
profile?: "Morgan", // name or id; falls back to per-client binding, then default profile?: "Morgan", // name or id; falls back to per-client binding, then default
engine?: "qwen", // qwen | qwen_custom_voice | luxtts | chatterbox | chatterbox_turbo | tada | kokoro engine?: "qwen", // qwen | qwen_custom_voice | luxtts | chatterbox | chatterbox_turbo | tada | kokoro
intent?: "respond", // respond | rewrite | compose — only if the profile has a personality personality?: true, // rewrite via the profile's personality LLM before TTS; default comes from the per-client binding
language?: "en", language?: "en",
}) })
``` ```
@@ -134,10 +134,9 @@ Returns:
} }
``` ```
- **Plain TTS** — omit `intent`. Text is spoken as-is. - **Plain TTS** — `personality: false` (or omitted + binding default is false). Text is spoken as-is.
- **Persona mode** — pass `intent` and the profile must have a personality set. - **Persona mode** — `personality: true` and the profile must have a personality prompt set.
The LLM transforms the text (respond to it, rewrite it in character, or The LLM rewrites the text in character before TTS. See [Voice Personalities](/overview/voice-personalities).
compose a fresh utterance) before TTS. See [Voice Personalities](/overview/voice-personalities).
### `voicebox.transcribe` ### `voicebox.transcribe`
@@ -196,7 +195,7 @@ carries:
| `label` | Display name in the Settings UI (e.g. "Claude Code"). | | `label` | Display name in the Settings UI (e.g. "Claude Code"). |
| `profile_id` | The voice this client uses when `profile` isn't passed. | | `profile_id` | The voice this client uses when `profile` isn't passed. |
| `default_engine` | Override the TTS engine for this client. | | `default_engine` | Override the TTS engine for this client. |
| `default_intent` | Default persona mode (`respond` / `rewrite` / `compose`). | | `default_personality` | When true, `voicebox.speak` routes through the profile's personality LLM (rewrite) by default. |
| `last_seen_at` | Last time the server saw a request from this client. | | `last_seen_at` | Last time the server saw a request from this client. |
`last_seen_at` is stamped automatically by middleware on every `/mcp/*` `last_seen_at` is stamped automatically by middleware on every `/mcp/*`
@@ -228,7 +227,7 @@ curl -X POST http://127.0.0.1:17493/speak \
``` ```
Body fields match the MCP tool: `text`, optional `profile`, `engine`, Body fields match the MCP tool: `text`, optional `profile`, `engine`,
`intent`, `language`. Returns a `GenerationResponse` — the same shape as `personality`, `language`. Returns a `GenerationResponse` — the same shape as
`POST /generate`. `POST /generate`.
## Debugging ## Debugging
@@ -286,7 +285,7 @@ For the full developer-facing tour of the code layout, see
<Cards> <Cards>
<Card title="Voice Personalities" href="/overview/voice-personalities"> <Card title="Voice Personalities" href="/overview/voice-personalities">
Persona mode (`intent=respond/rewrite/compose`) for agents that should Persona mode (`personality: true`) for agents that should
transform text in-character before speaking. transform text in-character before speaking.
</Card> </Card>
<Card title="Dictation" href="/overview/dictation"> <Card title="Dictation" href="/overview/dictation">
@@ -1,18 +1,19 @@
--- ---
title: "Voice Personalities" title: "Voice Personalities"
description: "Attach a personality to a voice profile and use Compose, Rewrite, or Respond to generate in-character speech — all powered by a local LLM." description: "Attach a personality to a voice profile, compose fresh in-character lines, and rewrite input text in their voice — all powered by a local LLM."
--- ---
## Overview ## Overview
A **personality** is an optional free-form description attached to a voice A **personality** is an optional free-form description attached to a voice
profile — who this voice is, how they speak, what they care about. Set one profile — who this voice is, how they speak, what they care about. Set one
and three new actions appear on the profile, each powered by a bundled and two new controls appear next to the generate button, both powered by a
Qwen3 LLM running entirely locally: bundled Qwen3 LLM running entirely locally:
- **Compose** — generate a fresh utterance in this character's voice - **Compose** — drop a fresh in-character line into the textarea. Click
- **Rewrite** — restate your text in their voice while preserving every idea again for a different take.
- **Respond** — treat your text as a prompt and produce the character's reply - **Speak in character** — a toggle that rewrites your input text in the
character's voice before TTS, preserving every idea.
The LLM produces the text. The voice profile speaks it. No cloud round-trip, The LLM produces the text. The voice profile speaks it. No cloud round-trip,
no external API — the whole loop runs on your hardware. no external API — the whole loop runs on your hardware.
@@ -41,15 +42,16 @@ Good descriptions tend to include:
You can set a personality on any voice profile type — cloned or preset. The You can set a personality on any voice profile type — cloned or preset. The
three modes work identically regardless of engine. three modes work identically regardless of engine.
## The three modes ## The two actions
Each mode is tuned for a specific job and the LLM temperature is adjusted Each action is tuned for a specific job and the LLM temperature is adjusted
to match. to match.
### Compose ### Compose
Generate a fresh utterance in the character's voice, with no seed text. Generate a fresh utterance in the character's voice, with no seed text.
Click again to get a different take. Click the shuffle button to drop a line straight into the generate
textarea; click again for a different take.
- **When to use:** prototyping, sampling a character's voice, brainstorming - **When to use:** prototyping, sampling a character's voice, brainstorming
a line without typing one first a line without typing one first
@@ -57,30 +59,20 @@ Click again to get a different take.
- **Typical output:** a short, punchy line that fits the character's - **Typical output:** a short, punchy line that fits the character's
register register
### Rewrite ### Speak in character (rewrite)
Take your input text and restate it in the character's voice while Flip the persona toggle and whatever you type (or dictate) gets rewritten in
preserving every idea. High-fidelity mode — the content doesn't change, only the character's voice before TTS — every idea preserved, only the phrasing
the voice does. changes. High-fidelity mode: the content doesn't change, only the voice does.
- **When to use:** turning a dictated memo into in-character speech; lifting - **When to use:** turning a dictated memo into in-character speech; lifting
a plain-English script into a specific voice without editing by hand a plain-English script into a specific voice without editing by hand
- **Temperature:** cold — faithfulness wins - **Temperature:** cold — faithfulness wins
- **Typical output:** same ideas, same order, different phrasing and cadence - **Typical output:** same ideas, same order, different phrasing and cadence
### Respond
Treat your input as a prompt and produce the character's reply — as if
you'd said it *to* them.
- **When to use:** spoken-input agents; Q&A with a specific voice;
interactive character experiences
- **Temperature:** balanced — creative but grounded
- **Typical output:** a reply to your prompt, written in-character
## Speech-only framing ## Speech-only framing
All three modes enforce **speech-only** output. The LLM is prompted to Both modes enforce **speech-only** output. The LLM is prompted to
produce things a person would actually say out loud — no narration, no produce things a person would actually say out loud — no narration, no
action tags (`*sighs*`, `[laughs]`), no meta-commentary, no markdown action tags (`*sighs*`, `[laughs]`), no meta-commentary, no markdown
formatting, no stage directions. formatting, no stage directions.
@@ -109,57 +101,58 @@ Pick a size in **Settings → Captures → Refinement → Refinement model** —
personality modes reuse it. If you switch models, both refinement and personality modes reuse it. If you switch models, both refinement and
personality output pick up the change on the next call. personality output pick up the change on the next call.
## Using the modes ## Using the controls
The three actions appear as buttons on the profile when a personality is Both controls appear on the floating generate box when the selected profile
set. For each: has a personality set.
<Steps> <Steps>
<Step title="Type or dictate your input"> <Step title="Compose — shuffle a line">
Rewrite and Respond need input text. Compose doesn't. Click the shuffle button. The LLM runs and the result fills the generate
textarea. Edit if you want, then hit generate.
</Step> </Step>
<Step title="Click the mode"> <Step title="Speak in character — toggle persona rewrite">
The LLM runs, then the result fills the generate box. Type (or dictate) what you want said. Flip the wand toggle on. Hit
</Step> generate — Voicebox runs the text through the personality LLM first,
<Step title="Hit generate"> then TTS speaks the rewritten version. Leave the toggle off for plain
The TTS engine speaks the LLM output in the profile's voice. The result TTS.
lands in generation history as a normal generation.
</Step> </Step>
</Steps> </Steps>
Each button also has an inline regenerate affordance — click again to Compose always gives you something different on re-click. The persona
resample. Compose will give you something totally different; Rewrite and toggle, on the other hand, is a mode — it applies to every generate call
Respond will give you a variation on the same content. until you flip it back off.
## Use cases ## Use cases
- **Agents that speak in a voice you own.** Combine Respond with the - **Agents that speak in a voice you own.** Combine the persona toggle with
built-in [MCP Server](/overview/mcp-server) so Claude Code, Cursor, the built-in [MCP Server](/overview/mcp-server) so Claude Code, Cursor,
Cline, or any MCP-aware agent can talk back through a profile with a Cline, or any MCP-aware agent can talk back through a profile with a
personality. The agent calls `voicebox.speak({ text, profile, intent: personality. The agent calls `voicebox.speak({ text, profile, personality:
"respond" })` and Voicebox produces in-character speech in your cloned true })` and Voicebox rewrites the text in character before speaking.
voice.
- **Interactive characters.** Games, narrative tools, accessibility - **Interactive characters.** Games, narrative tools, accessibility
experiences. A character with a personality description plus a cloned experiences. A character with a personality description plus a cloned
voice becomes a reusable prop. voice becomes a reusable prop.
- **Accessibility.** People who can't speak in their original voice can - **Accessibility.** People who can't speak in their original voice can
keep a personality description of how they used to sound and use Rewrite keep a personality description of how they used to sound and use the
to turn typed input into in-character speech. rewrite toggle to turn typed input into in-character speech.
- **Creative drafting.** Write a plain outline, Rewrite line-by-line into - **Creative drafting.** Write a plain outline, flip the persona toggle,
the character's voice, drop the audio into a Story. generate line-by-line into the character's voice, drop the audio into a
Story.
## API surface ## API surface
Personalities and the three modes are accessible via REST: Personalities are accessible via REST:
| Method | Endpoint | Body | | Method | Endpoint | Body |
|---|---|---| |---|---|---|
| `PUT` | `/profiles/{id}` | Include a `personality` field up to 2,000 chars to set it. | | `PUT` | `/profiles/{id}` | Include a `personality` field up to 2,000 chars to set it. |
| `POST` | `/profiles/{id}/speak` | Runs the LLM + TTS in one shot. Body includes `text`, `intent` (`compose`, `rewrite`, or `respond`), optional `engine`, `language`. | | `POST` | `/profiles/{id}/compose` | No body. Returns a fresh in-character utterance as text. |
| `POST` | `/generate` | Include `personality: true` to run input text through the personality LLM before TTS. Same for `POST /speak`. |
The `/profiles/{id}/speak` endpoint is the same primitive MCP's `POST /generate` with `personality: true` is the same primitive MCP's
`voicebox.speak` tool calls when that ships. Scripts and agents can use it `voicebox.speak` tool uses when you pass `personality: true`. Scripts and
directly today. agents can use it directly.
## Limits and gotchas ## Limits and gotchas
+14 -11
View File
@@ -10,7 +10,7 @@
- **`backend/mcp_server/`** package with `server.py`, `tools.py`, `context.py`, `resolve.py`, `events.py`, `README.md`. Named `mcp_server` (not `mcp`) to sidestep a shadowing conflict with the installed `mcp` PyPI package that FastMCP imports internally. - **`backend/mcp_server/`** package with `server.py`, `tools.py`, `context.py`, `resolve.py`, `events.py`, `README.md`. Named `mcp_server` (not `mcp`) to sidestep a shadowing conflict with the installed `mcp` PyPI package that FastMCP imports internally.
- **Streamable HTTP mount at `/mcp`** via FastMCP's `http_app(transport='http')`. Sub-app lifespan composed with Voicebox's own startup/shutdown through an `@asynccontextmanager lifespan=` in `backend/app.py` (migrated away from the deprecated `@app.on_event` handlers). - **Streamable HTTP mount at `/mcp`** via FastMCP's `http_app(transport='http')`. Sub-app lifespan composed with Voicebox's own startup/shutdown through an `@asynccontextmanager lifespan=` in `backend/app.py` (migrated away from the deprecated `@app.on_event` handlers).
- **Four MCP tools**, dot-named to match the landing and ecosystem convention: - **Four MCP tools**, dot-named to match the landing and ecosystem convention:
- `voicebox.speak(text, profile?, engine?, intent?, language?)` - `voicebox.speak(text, profile?, engine?, personality?, language?)`
- `voicebox.transcribe(audio_base64?, audio_path?, language?, model?)` - `voicebox.transcribe(audio_base64?, audio_path?, language?, model?)`
- `voicebox.list_captures(limit, offset)` - `voicebox.list_captures(limit, offset)`
- `voicebox.list_profiles()` - `voicebox.list_profiles()`
@@ -111,7 +111,7 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True) label = Column(String, nullable=True)
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True) profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True) default_engine = Column(String, nullable=True)
default_intent = Column(String, nullable=True) # "respond" | "rewrite" | "compose" default_personality = Column(Boolean, nullable=False, default=False) # rewrite-before-speak default
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
``` ```
@@ -133,7 +133,7 @@ Global default stays in `capture_settings.default_playback_voice_id` — no dupl
| `backend/mcp/README.md` | MCP Inspector quickstart + `.mcp.json` snippets | | `backend/mcp/README.md` | MCP Inspector quickstart + `.mcp.json` snippets |
| `backend/mcp_shim/__init__.py`, `__main__.py` | Stdio ↔ Streamable HTTP proxy (~150 lines) | | `backend/mcp_shim/__init__.py`, `__main__.py` | Stdio ↔ Streamable HTTP proxy (~150 lines) |
| `backend/voicebox-mcp.spec` | PyInstaller spec for the shim (strips torch/transformers from `hiddenimports`) | | `backend/voicebox-mcp.spec` | PyInstaller spec for the shim (strips torch/transformers from `hiddenimports`) |
| `backend/routes/speak.py` | `POST /speak {text, profile?, engine?, intent?, language?}` — REST wrapper around `resolve_profile()` + `speak_in_character()` for non-MCP agents | | `backend/routes/speak.py` | `POST /speak {text, profile?, engine?, personality?, language?}` — REST wrapper around `resolve_profile()` + `generate_speech()` for non-MCP agents |
### Backend — modified ### Backend — modified
@@ -191,11 +191,12 @@ Tools are registered with **dotted names** (`voicebox.speak`, etc.) to match the
async def speak(text: str, async def speak(text: str,
profile: str | None = None, # name OR id profile: str | None = None, # name OR id
engine: str | None = None, engine: str | None = None,
intent: str = "respond", # "respond" | "rewrite" | "compose" personality: bool | None = None, # true → rewrite via profile's personality LLM before TTS
language: str | None = None) -> dict: language: str | None = None) -> dict:
"""Speak text in a voice profile. Returns {generation_id, status, profile, poll}.""" """Speak text in a voice profile. Returns {generation_id, status, profile, poll}."""
# resolve profile via precedence, call speak_in_character (profiles.py:453) # resolve profile via precedence, delegate to generate_speech — the
# with persist=True so it lands in history. # route honors `personality=True` by running rewrite_as_profile on
# the input before running the normal TTS pipeline.
@mcp.tool(name="voicebox.transcribe") @mcp.tool(name="voicebox.transcribe")
async def transcribe(audio_base64: str | None = None, async def transcribe(audio_base64: str | None = None,
@@ -224,12 +225,14 @@ async def speak(data: SpeakRequest, request: Request, db: Session = Depends(get_
client_id = request.headers.get("X-Voicebox-Client-Id") client_id = request.headers.get("X-Voicebox-Client-Id")
profile = resolve_profile(data.profile, client_id, db) profile = resolve_profile(data.profile, client_id, db)
if profile is None: raise HTTPException(400, "No voice profile resolved.") if profile is None: raise HTTPException(400, "No voice profile resolved.")
persist_req = PersonalitySpeakRequest(text=data.text, persist=True, language=data.language, req = GenerationRequest(profile_id=profile.id, text=data.text,
engine=data.engine, intent=data.intent or "respond") language=data.language or "en",
return await speak_in_character(profile.id, persist_req, db) engine=data.engine or "qwen",
personality=bool(data.personality))
return await generate_speech(req, db)
``` ```
`SpeakRequest`: `{ text: str, profile: str | None, engine: str | None, intent: str | None, language: str | None }`. Accepts name OR id for `profile` (via `resolve_profile`), and resolves via the same precedence as the MCP tool so the two surfaces behave identically. `SpeakRequest`: `{ text: str, profile: str | None, engine: str | None, personality: bool | None, language: str | None }`. Accepts name OR id for `profile` (via `resolve_profile`). `personality=None` means "use the per-client binding's `default_personality`"; explicit `true`/`false` always wins. Same precedence as the MCP tool so the two surfaces behave identically.
## Mount point (`backend/app.py`) ## Mount point (`backend/app.py`)
@@ -265,7 +268,7 @@ PyInstaller spec keeps only `mcp`, `httpx`, `anyio`, `click` — target binary <
## Settings UI (`MCPBindings.tsx`) ## Settings UI (`MCPBindings.tsx`)
- **Global default voice** picker bound to `capture_settings.default_playback_voice_id` (reuses `useCaptureSettings`). - **Global default voice** picker bound to `capture_settings.default_playback_voice_id` (reuses `useCaptureSettings`).
- **Per-client table** — add/edit/remove rows of `{client_id, label, profile_id, default_engine, default_intent}`. Uses `useMCPBindings`. - **Per-client table** — add/edit/remove rows of `{client_id, label, profile_id, default_engine, default_personality}`. Uses `useMCPBindings`.
- **Connection cheatsheet** — two tabs, HTTP (default) and Stdio (fallback), with copy-to-clipboard snippets per known client: - **Connection cheatsheet** — two tabs, HTTP (default) and Stdio (fallback), with copy-to-clipboard snippets per known client:
HTTP form (primary): HTTP form (primary):