- A few things before you can dictate
+ {t('captures.readiness.title')}
- The shortcut stays off until everything below is ready.
+ {t('captures.readiness.subheading')}
{readiness.stt && (
}
- title={`${readiness.stt.display_name} (speech-to-text)`}
+ title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={
readiness.stt.ready
- ? 'Model downloaded.'
- : `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.`
+ ? t('captures.readiness.stt.ready')
+ : sttSize
+ ? t('captures.readiness.stt.missingWithSize', { size: sttSize })
+ : t('captures.readiness.stt.missing')
}
ready={readiness.stt.ready}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
@@ -199,11 +205,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{readiness.llm && (
}
- title={`${readiness.llm.display_name} (refinement)`}
+ title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={
readiness.llm.ready
- ? 'Model downloaded.'
- : `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.`
+ ? t('captures.readiness.llm.ready')
+ : llmSize
+ ? t('captures.readiness.llm.missingWithSize', { size: llmSize })
+ : t('captures.readiness.llm.missing')
}
ready={readiness.llm.ready}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
@@ -212,34 +220,34 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
}
- title="Input Monitoring permission"
+ title={t('captures.readiness.inputMonitoring.label')}
description={
readiness.inputMonitoring
- ? 'macOS allows Voicebox to detect your global shortcut.'
- : 'macOS needs to allow Voicebox to detect the global shortcut.'
+ ? t('captures.readiness.inputMonitoring.ready')
+ : t('captures.readiness.inputMonitoring.missing')
}
ready={readiness.inputMonitoring}
action={
- Open Settings
+ {t('captures.readiness.inputMonitoring.openSettings')}
}
/>
}
- title="Accessibility permission"
+ title={t('captures.readiness.accessibility.label')}
description={
readiness.accessibility
- ? 'Voicebox can paste transcriptions into other apps.'
- : 'Required so transcriptions can paste into the focused app.'
+ ? t('captures.readiness.accessibility.ready')
+ : t('captures.readiness.accessibility.missing')
}
ready={readiness.accessibility}
action={
- Open Settings
+ {t('captures.readiness.accessibility.openSettings')}
}
/>
diff --git a/app/src/components/ChordPicker/ChordPicker.tsx b/app/src/components/ChordPicker/ChordPicker.tsx
index 7b7bc387..933bac2e 100644
--- a/app/src/components/ChordPicker/ChordPicker.tsx
+++ b/app/src/components/ChordPicker/ChordPicker.tsx
@@ -1,5 +1,6 @@
import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -47,6 +48,7 @@ export function ChordPicker({
onSave,
onCancel,
}: ChordPickerProps) {
+ const { t } = useTranslation();
// Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value.
@@ -153,12 +155,12 @@ export function ChordPicker({
- {pressed.size > 0 ? 'Capturing…' : 'Press your shortcut'}
+ {pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
{displayKeys.length === 0 ? (
- No keys yet
+ {t('captures.chord.noKeys')}
) : (
displayKeys.map((k) => )
@@ -166,8 +168,7 @@ export function ChordPicker({
{unsupportedAttempt ? (
- "{unsupportedAttempt}" isn't supported in chords. Try a modifier
- or letter key.
+ {t('captures.chord.unsupported', { key: unsupportedAttempt })}
) : null}
@@ -175,10 +176,10 @@ export function ChordPicker({
- Cancel
+ {t('common.cancel')}
onSave(captured)} disabled={!canSave}>
- Save
+ {t('common.save')}
diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx
index ac64406f..89fdbc7c 100644
--- a/app/src/components/Generation/FloatingGenerateBox.tsx
+++ b/app/src/components/Generation/FloatingGenerateBox.tsx
@@ -1,7 +1,7 @@
-import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
-import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
+import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
+import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
@@ -52,6 +53,21 @@ export function FloatingGenerateBox({
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
+ const { toast } = useToast();
+
+ const composeMutation = useMutation({
+ mutationFn: async () => {
+ if (!selectedProfileId) throw new Error('No profile selected');
+ return apiClient.composeWithPersonality(selectedProfileId);
+ },
+ onError: (err: Error) => {
+ toast({
+ title: t('generation.compose.failedTitle'),
+ description: err.message || t('generation.compose.failedDescription'),
+ variant: 'destructive',
+ });
+ },
+ });
// Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({
@@ -175,6 +191,10 @@ export function FloatingGenerateBox({
) {
setSelectedPresetId(null);
}
+ // Persona toggle only applies when the profile has a personality prompt.
+ if (selectedProfile && !selectedProfile.personality?.trim()) {
+ form.setValue('personality', false);
+ }
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
@@ -331,35 +351,90 @@ export function FloatingGenerateBox({
/>
-
-
-
- {isPending ? (
-
- ) : (
-
- )}
-
-
- {isPending
- ? t('generation.button.generating')
- : !selectedProfileId
- ? t('generation.button.selectFirst')
- : t('generation.button.generate')}
-
-
+
+ {/* Compose — fills the textarea with a fresh in-character line. */}
+
+ {selectedProfile?.personality?.trim() && (
+
+
+ {
+ const result = await composeMutation.mutateAsync();
+ form.setValue('text', result.text, { shouldDirty: true });
+ setIsExpanded(true);
+ }}
+ className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
+ aria-label={t('generation.compose.ariaLabel')}
+ >
+ {composeMutation.isPending ? (
+
+ ) : (
+
+ )}
+
+
+ {t('generation.compose.tooltip')}
+
+
+
+ )}
+
+
+ {/* Persona — rewrite input through the profile's personality LLM before TTS. */}
+
+ {selectedProfile?.personality?.trim() && (
+
+ {
+ const active = !!field.value;
+ return (
+
+
+
+ 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}
+ >
+
+
+
+ {active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
+
+
+
+
+ );
+ }}
+ />
+
+ )}
+
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
@@ -369,7 +444,6 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
- className="absolute top-0 right-[calc(100%+0.5rem)]"
>
)}
+
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+
+
+ {isPending
+ ? t('generation.button.generating')
+ : !selectedProfileId
+ ? t('generation.button.selectFirst')
+ : t('generation.button.generate')}
+
+
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
)}
+
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[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 (
-
-
- Generate Speech
-
-
-
-
-
-
- );
-}
diff --git a/app/src/components/InputMonitoringGate/InputMonitoringGate.tsx b/app/src/components/InputMonitoringGate/InputMonitoringGate.tsx
index bc09ded1..5d1de58e 100644
--- a/app/src/components/InputMonitoringGate/InputMonitoringGate.tsx
+++ b/app/src/components/InputMonitoringGate/InputMonitoringGate.tsx
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
@@ -62,6 +63,7 @@ export function useInputMonitoringPermission() {
* be noise).
*/
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
+ const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false);
@@ -80,26 +82,23 @@ export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
- Grant Input Monitoring to enable the global shortcut
+ {t('captures.permissions.inputMonitoring.title')}
- Voicebox needs System Settings → Privacy & Security → Input
- Monitoring to detect your dictation chord. The toggle is on, but
- macOS is blocking key events until you allow it.
+ }} />
- Open Settings
+ {t('captures.permissions.inputMonitoring.openSettings')}
- {checking ? 'Checking…' : "I've enabled it"}
+ {checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
{stillMissing && !checking && (
- Still not detected. macOS usually requires quitting and reopening
- Voicebox after toggling the permission.
+ {t('captures.permissions.inputMonitoring.stillMissing')}
)}
diff --git a/app/src/components/ServerTab/CapturesPage.tsx b/app/src/components/ServerTab/CapturesPage.tsx
index 94bb78a5..6d874721 100644
--- a/app/src/components/ServerTab/CapturesPage.tsx
+++ b/app/src/components/ServerTab/CapturesPage.tsx
@@ -1,5 +1,6 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
@@ -50,8 +51,9 @@ function voiceGradient(voiceId: string): string {
}
function ChordPreview({ keys }: { keys: string[] }) {
+ const { t } = useTranslation();
if (keys.length === 0) {
- return Not set ;
+ return {t('captures.chord.notSet')} ;
}
return (
@@ -132,6 +134,7 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
}
export function CapturesPage() {
+ const { t } = useTranslation();
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
@@ -164,13 +167,13 @@ export function CapturesPage() {
@@ -208,8 +212,8 @@ export function CapturesPage() {
@@ -220,15 +224,15 @@ export function CapturesPage() {
onClick={() => setChordEditor('push')}
>
- Change
+ {t('settings.captures.dictation.pushToTalk.change')}
}
/>
@@ -239,7 +243,7 @@ export function CapturesPage() {
onClick={() => setChordEditor('toggle')}
>
- Change
+ {t('settings.captures.dictation.toggle.change')}
}
@@ -247,8 +251,8 @@ export function CapturesPage() {
setChordEditor(null)}
onSave={(keys) => {
@@ -259,8 +263,8 @@ export function CapturesPage() {
setChordEditor(null)}
onSave={(keys) => {
@@ -270,15 +274,15 @@ export function CapturesPage() {
/>
- Whisper Base · 74M · Fast
- Whisper Small · 244M · Balanced
+
+ {t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
+
+
+ {t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
+
- Whisper Medium · 769M · Higher accuracy
+ {t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
- Whisper Large · 1.5B · Best accuracy
+ {t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
- Whisper Turbo · Pruned Large v3 · Near-best, fast
+ {t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
@@ -341,42 +349,42 @@ export function CapturesPage() {
/>
update({ language: v })}>
- Auto-detect
- English
- Spanish
- French
- German
- Japanese
- Chinese
- Hindi
+ {t('settings.captures.transcription.language.auto')}
+ {t('settings.captures.transcription.language.en')}
+ {t('settings.captures.transcription.language.es')}
+ {t('settings.captures.transcription.language.fr')}
+ {t('settings.captures.transcription.language.de')}
+ {t('settings.captures.transcription.language.ja')}
+ {t('settings.captures.transcription.language.zh')}
+ {t('settings.captures.transcription.language.hi')}
}
/>
}
/>
- Qwen3 · 0.6B · 400 MB · Very fast
- Qwen3 · 1.7B · 1.1 GB · Fast
- Qwen3 · 4B · 2.5 GB · Full quality
+
+ {t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
+
+
+ {t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
+
+
+ {t('settings.captures.refinement.model.size4', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
+
}
/>
@@ -480,7 +494,9 @@ export function CapturesPage() {
>
) : (
- {voices.length === 0 ? 'No cloned voices yet' : 'None selected'}
+ {voices.length === 0
+ ? t('settings.captures.playback.defaultVoice.noClonedVoices')
+ : t('settings.captures.playback.defaultVoice.noneSelected')}
)}
@@ -489,7 +505,7 @@ export function CapturesPage() {
- Cloned voices
+ {t('settings.captures.playback.defaultVoice.clonedVoices')}
{voices.map((v) => (
@@ -522,30 +538,30 @@ export function CapturesPage() {
- Keep forever
- 90 days
- 30 days
- 7 days
+ {t('settings.captures.storage.retention.forever')}
+ {t('settings.captures.storage.retention.d90')}
+ {t('settings.captures.storage.retention.d30')}
+ {t('settings.captures.storage.retention.d7')}
}
/>
- Clear captures
+ {t('settings.captures.storage.clearAll.action')}
}
/>
@@ -562,41 +578,38 @@ export function CapturesPage() {
-
About Captures
+
{t('settings.captures.sidebar.aboutTitle')}
- 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.
+ {t('settings.captures.sidebar.aboutBody')}
-
What's different
+
{t('settings.captures.sidebar.differencesTitle')}
- Fully local. {' '}
- Whisper and the refinement LLM run on your hardware. No cloud,
- no accounts, your voice never leaves the machine.
+ {t('settings.captures.sidebar.local.title')} {' '}
+ {t('settings.captures.sidebar.local.body')}
- Play as any voice.
+ {t('settings.captures.sidebar.playAs.title')}
{' '}
- Transcripts can be read back in any profile you've cloned.
+ {t('settings.captures.sidebar.playAs.body')}
- Cross-platform.
+ {t('settings.captures.sidebar.crossPlatform.title')}
{' '}
- Same shortcut, same flow on macOS, Windows, and Linux.
+ {t('settings.captures.sidebar.crossPlatform.body')}
diff --git a/app/src/components/ServerTab/MCPPage.tsx b/app/src/components/ServerTab/MCPPage.tsx
index e27185a8..d2f98da7 100644
--- a/app/src/components/ServerTab/MCPPage.tsx
+++ b/app/src/components/ServerTab/MCPPage.tsx
@@ -1,10 +1,12 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useServerStore } from '@/stores/serverStore';
+import { formatDate } from '@/lib/utils/format';
import { SettingRow, SettingSection } from './SettingRow';
/**
@@ -13,6 +15,7 @@ import { SettingRow, SettingSection } from './SettingRow';
* existing Voicebox server; this page is the agent-onboarding surface.
*/
export function MCPPage() {
+ const { t } = useTranslation();
const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles();
@@ -47,12 +50,12 @@ export function MCPPage() {
- (none)
+ {t('settings.mcp.defaultVoice.none')}
{(profiles ?? []).map((p) => (
{p.name}
@@ -119,13 +122,12 @@ export function MCPPage() {
{bindings.length === 0 ? (
- No bindings yet. Add one below, then configure your MCP client to
- send the matching X-Voicebox-Client-Id.
+ }} />
) : (
@@ -142,12 +144,12 @@ export function MCPPage() {
{b.client_id}
{' · '}
{b.last_seen_at ? (
-
+
{' '}
- last seen {formatRelative(b.last_seen_at)}
+ {t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
) : (
- never connected
+ {t('settings.mcp.bindings.neverConnected')}
)}
@@ -162,7 +164,7 @@ export function MCPPage() {
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
>
-
(default)
+
{t('settings.mcp.bindings.defaultOption')}
{(profiles ?? []).map((p) => (
{p.name}
@@ -173,7 +175,7 @@ export function MCPPage() {
size="icon"
variant="ghost"
onClick={() => remove(b.client_id)}
- aria-label={`Remove binding for ${b.client_id}`}
+ aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
>
@@ -183,18 +185,18 @@ export function MCPPage() {
)}
-
Add a binding
+
{t('settings.mcp.bindings.add.title')}
setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
setNewLabel(e.target.value)}
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)}
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
>
- (default)
+ {t('settings.mcp.bindings.defaultOption')}
{(profiles ?? []).map((p) => (
{p.name}
@@ -217,7 +219,7 @@ export function MCPPage() {
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
- Add binding
+ {t('settings.mcp.bindings.add.action')}
@@ -225,39 +227,36 @@ export function MCPPage() {
-
About MCP
+
{t('settings.mcp.sidebar.aboutTitle')}
- Model Context Protocol lets your AI coding agent — Claude Code,
- Cursor, Windsurf — call Voicebox tools. Speak in a cloned voice,
- transcribe audio, browse captures.
+ {t('settings.mcp.sidebar.aboutBody')}
-
Available tools
+
{t('settings.mcp.sidebar.toolsTitle')}
voicebox.speak
- Speak text in a voice profile.
+ {t('settings.mcp.sidebar.tools.speak')}
voicebox.transcribe
- Whisper STT on a clip.
+ {t('settings.mcp.sidebar.tools.transcribe')}
voicebox.list_captures
- Recent dictations / recordings.
+ {t('settings.mcp.sidebar.tools.listCaptures')}
voicebox.list_profiles
- Available voice profiles.
+ {t('settings.mcp.sidebar.tools.listProfiles')}
- Also exposed as POST /speak for shell scripts, ACP,
- A2A.
+ }} />
@@ -274,6 +273,7 @@ function SnippetRow({
description: string;
snippet: string;
}) {
+ const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
@@ -296,12 +296,12 @@ function SnippetRow({
{copied ? (
<>
- Copied
+ {t('settings.mcp.install.copied')}
>
) : (
<>
- Copy
+ {t('settings.mcp.install.copy')}
>
)}
@@ -312,13 +312,3 @@ function SnippetRow({
);
}
-
-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`;
-}
diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx
index f124b1c2..ec8502d0 100644
--- a/app/src/components/ServerTab/ServerTab.tsx
+++ b/app/src/components/ServerTab/ServerTab.tsx
@@ -23,8 +23,8 @@ interface SettingsTab {
const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
- { label: 'Captures', path: '/settings/captures' },
- { label: 'MCP', path: '/settings/mcp' },
+ { labelKey: 'settings.tabs.captures', path: '/settings/captures' },
+ { labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx
index c8f85e2d..c3f93683 100644
--- a/app/src/components/Sidebar.tsx
+++ b/app/src/components/Sidebar.tsx
@@ -22,7 +22,7 @@ const tabs: Array<{
}> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ 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: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx
index 5ed74a9d..a7b7f8ef 100644
--- a/app/src/components/VoiceProfiles/ProfileCard.tsx
+++ b/app/src/components/VoiceProfiles/ProfileCard.tsx
@@ -1,4 +1,4 @@
-import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
+import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
@@ -126,6 +126,9 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
{profile.effects_chain && profile.effects_chain.length > 0 && (
)}
+ {profile.personality?.trim() && (
+
+ )}
(
- Personality
+ {t('profileForm.fields.personalityLabel')}
- Leave blank to hide the Compose and Rewrite buttons on the generate page.
+ {t('profileForm.fields.personalityHint')}
diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json
index b90a6de9..00d5493f 100644
--- a/app/src/i18n/locales/en/translation.json
+++ b/app/src/i18n/locales/en/translation.json
@@ -14,6 +14,7 @@
"nav": {
"generate": "Generate",
"stories": "Stories",
+ "captures": "Captures",
"voices": "Voices",
"effects": "Effects",
"audio": "Audio",
@@ -21,6 +22,138 @@
"settings": "Settings",
"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 System Settings → Privacy & Security → Accessibility 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 System Settings → Privacy & Security → Input Monitoring to detect your dictation chord. The toggle is on, but macOS is blocking key events until you allow it.",
+ "openSettings": "Open Settings",
+ "recheck": "I've enabled it",
+ "rechecking": "Checking…",
+ "stillMissing": "Still not detected. macOS usually requires quitting and reopening Voicebox after toggling the permission."
+ }
+ }
+ },
"voicesTab": {
"title": "Voices",
"loading": "Loading voices…",
@@ -125,7 +258,10 @@
"noPreference": "No preference",
"defaultEngineHint": "Auto-selects this engine when the profile is chosen.",
"defaultEffects": "Default Effects",
- "defaultEffectsHint": "Effects applied automatically to all new generations with this voice."
+ "defaultEffectsHint": "Effects applied automatically to all new generations with this voice.",
+ "personalityLabel": "Personality",
+ "personalityPlaceholder": "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": {
"alt": "Avatar preview"
@@ -550,6 +686,18 @@
"effects": {
"none": "No effects",
"profileDefault": "Profile default"
+ },
+ "compose": {
+ "tooltip": "Compose",
+ "ariaLabel": "Compose a line in character",
+ "failedTitle": "Compose failed",
+ "failedDescription": "Could not generate text from this personality."
+ },
+ "persona": {
+ "tooltipActive": "Speaking in character",
+ "tooltipInactive": "Speak in character",
+ "ariaLabelActive": "Speaking in character",
+ "ariaLabelInactive": "Speak in character"
}
},
"main": {
@@ -571,6 +719,8 @@
"tabs": {
"general": "General",
"generation": "Generation",
+ "captures": "Captures",
+ "mcp": "MCP",
"gpu": "GPU",
"logs": "Logs",
"changelog": "Changelog",
@@ -678,6 +828,212 @@
"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 X-Voicebox-Client-Id.",
+ "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 POST /speak for shell scripts, ACP, A2A."
+ }
+ },
"gpu": {
"cpuOnly": "CPU Only",
"vramUsed": "{{mb}} MB VRAM",
diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json
index 006e6e3c..0c90da56 100644
--- a/app/src/i18n/locales/ja/translation.json
+++ b/app/src/i18n/locales/ja/translation.json
@@ -14,6 +14,7 @@
"nav": {
"generate": "生成",
"stories": "ストーリー",
+ "captures": "キャプチャ",
"voices": "ボイス",
"effects": "エフェクト",
"audio": "オーディオ",
@@ -21,6 +22,138 @@
"settings": "設定",
"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 に 「システム設定」→「プライバシーとセキュリティ」→「アクセシビリティ」 の許可が必要です。許可がなくてもディクテーションはキャプチャタブに保存されます。",
+ "openSettings": "設定を開く",
+ "recheck": "有効にしました",
+ "rechecking": "確認中…",
+ "stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
+ },
+ "inputMonitoring": {
+ "title": "グローバルショートカットを有効にするため入力監視の権限を付与してください",
+ "body": "ディクテーションのコードを検出するには、Voicebox に 「システム設定」→「プライバシーとセキュリティ」→「入力監視」 の許可が必要です。トグルは有効ですが、許可されるまで macOS がキーイベントをブロックしています。",
+ "openSettings": "設定を開く",
+ "recheck": "有効にしました",
+ "rechecking": "確認中…",
+ "stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
+ }
+ }
+ },
"voicesTab": {
"title": "ボイス",
"loading": "ボイスを読み込み中…",
@@ -125,7 +258,10 @@
"noPreference": "指定なし",
"defaultEngineHint": "このプロファイルが選ばれたとき、このエンジンを自動で選択します。",
"defaultEffects": "デフォルトエフェクト",
- "defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。"
+ "defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。",
+ "personalityLabel": "パーソナリティ",
+ "personalityPlaceholder": "任意。このボイスがどんな人物で、どのように話すかを記述します。例:「航海の比喩でしか話さない不機嫌な海賊」。生成ページの「Compose」ボタンと、キャラクターになりきって書き換えるトグルに反映されます。",
+ "personalityHint": "空欄にすると、生成ページの「Compose」ボタンとペルソナトグルは表示されません。"
},
"avatar": {
"alt": "アバタープレビュー"
@@ -550,6 +686,18 @@
"effects": {
"none": "エフェクトなし",
"profileDefault": "プロファイルのデフォルト"
+ },
+ "compose": {
+ "tooltip": "Compose",
+ "ariaLabel": "キャラクターになりきって一文を生成",
+ "failedTitle": "Compose に失敗しました",
+ "failedDescription": "このパーソナリティからテキストを生成できませんでした。"
+ },
+ "persona": {
+ "tooltipActive": "キャラクターとして発話中",
+ "tooltipInactive": "キャラクターとして発話",
+ "ariaLabelActive": "キャラクターとして発話中",
+ "ariaLabelInactive": "キャラクターとして発話"
}
},
"main": {
@@ -571,6 +719,8 @@
"tabs": {
"general": "一般",
"generation": "生成",
+ "captures": "キャプチャ",
+ "mcp": "MCP",
"gpu": "GPU",
"logs": "ログ",
"changelog": "変更履歴",
@@ -678,6 +828,212 @@
"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": "バインディングはまだありません。下から追加し、対応する X-Voicebox-Client-Id を送信するように 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 用に POST /speak としても公開されています。"
+ }
+ },
"gpu": {
"cpuOnly": "CPU のみ",
"vramUsed": "VRAM 使用量 {{mb}} MB",
diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json
index 343d24d4..19b990d3 100644
--- a/app/src/i18n/locales/zh-CN/translation.json
+++ b/app/src/i18n/locales/zh-CN/translation.json
@@ -14,6 +14,7 @@
"nav": {
"generate": "生成",
"stories": "故事",
+ "captures": "捕获",
"voices": "声音",
"effects": "效果",
"audio": "音频",
@@ -21,6 +22,138 @@
"settings": "设置",
"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 需要在 系统设置 → 隐私与安全性 → 辅助功能 中获得权限,才能将转录粘贴到其他应用。即使没有此权限,听写仍会保存到「捕获」标签页。",
+ "openSettings": "打开设置",
+ "recheck": "我已启用",
+ "rechecking": "检查中…",
+ "stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
+ },
+ "inputMonitoring": {
+ "title": "授予「输入监控」权限以启用全局快捷键",
+ "body": "Voicebox 需要在 系统设置 → 隐私与安全性 → 输入监控 中获得权限,才能检测您的听写组合键。开关已开启,但在您允许之前 macOS 会拦截按键事件。",
+ "openSettings": "打开设置",
+ "recheck": "我已启用",
+ "rechecking": "检查中…",
+ "stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
+ }
+ }
+ },
"voicesTab": {
"title": "声音",
"loading": "加载声音中…",
@@ -125,7 +258,10 @@
"noPreference": "无偏好",
"defaultEngineHint": "选择该档案时自动使用此引擎。",
"defaultEffects": "默认效果",
- "defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。"
+ "defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。",
+ "personalityLabel": "人物设定",
+ "personalityPlaceholder": "可选。这个声音是谁、说话方式如何。例如「一位脾气暴躁的海盗,只会用航海比喻说话」。会驱动生成页面上的「撰写」按钮和入戏改写开关。",
+ "personalityHint": "留空将隐藏生成页面上的「撰写」按钮和人物设定开关。"
},
"avatar": {
"alt": "头像预览"
@@ -550,6 +686,18 @@
"effects": {
"none": "无效果",
"profileDefault": "档案默认"
+ },
+ "compose": {
+ "tooltip": "撰写",
+ "ariaLabel": "以人物设定撰写一句台词",
+ "failedTitle": "撰写失败",
+ "failedDescription": "无法根据此人物设定生成文本。"
+ },
+ "persona": {
+ "tooltipActive": "以人物设定朗读",
+ "tooltipInactive": "以人物设定朗读",
+ "ariaLabelActive": "以人物设定朗读",
+ "ariaLabelInactive": "以人物设定朗读"
}
},
"main": {
@@ -571,6 +719,8 @@
"tabs": {
"general": "常规",
"generation": "生成",
+ "captures": "捕获",
+ "mcp": "MCP",
"gpu": "GPU",
"logs": "日志",
"changelog": "更新日志",
@@ -678,6 +828,212 @@
"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 客户端配置为发送匹配的 X-Voicebox-Client-Id。",
+ "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": "也以 POST /speak 暴露,可用于 shell 脚本、ACP、A2A。"
+ }
+ },
"gpu": {
"cpuOnly": "仅 CPU",
"vramUsed": "{{mb}} MB 显存",
diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json
index 894f97ed..5c66ecf5 100644
--- a/app/src/i18n/locales/zh-TW/translation.json
+++ b/app/src/i18n/locales/zh-TW/translation.json
@@ -14,6 +14,7 @@
"nav": {
"generate": "生成",
"stories": "故事",
+ "captures": "擷取",
"voices": "聲音",
"effects": "效果",
"audio": "音訊",
@@ -21,6 +22,138 @@
"settings": "設定",
"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 需要 系統設定 → 私隱與安全性 → 輔助使用 才能將轉錄文字貼到其他 App。即使沒有此權限,口述內容仍會出現在「擷取」分頁。",
+ "openSettings": "開啟設定",
+ "recheck": "我已啟用",
+ "rechecking": "檢查中…",
+ "stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
+ },
+ "inputMonitoring": {
+ "title": "授予輸入監控權限以啟用全域快捷鍵",
+ "body": "Voicebox 需要 系統設定 → 私隱與安全性 → 輸入監控 才能偵測您的口述組合鍵。功能已開啟,但 macOS 在您允許前會封鎖按鍵事件。",
+ "openSettings": "開啟設定",
+ "recheck": "我已啟用",
+ "rechecking": "檢查中…",
+ "stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
+ }
+ }
+ },
"voicesTab": {
"title": "聲音",
"loading": "載入聲音中…",
@@ -125,7 +258,10 @@
"noPreference": "無偏好",
"defaultEngineHint": "選擇此檔案時自動使用此引擎。",
"defaultEffects": "預設效果",
- "defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。"
+ "defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。",
+ "personalityLabel": "個性",
+ "personalityPlaceholder": "選填。描述這個聲音是誰以及他們如何說話。例如:「一位脾氣暴躁的海盜,只會用航海比喻說話」。會驅動生成頁面上的「撰寫」按鈕和角色化重寫切換。",
+ "personalityHint": "留空則在生成頁面隱藏「撰寫」按鈕和角色切換。"
},
"avatar": {
"alt": "頭像預覽"
@@ -550,6 +686,18 @@
"effects": {
"none": "無效果",
"profileDefault": "檔案預設"
+ },
+ "compose": {
+ "tooltip": "撰寫",
+ "ariaLabel": "以角色撰寫一句台詞",
+ "failedTitle": "撰寫失敗",
+ "failedDescription": "無法從此個性生成文字。"
+ },
+ "persona": {
+ "tooltipActive": "以角色發話中",
+ "tooltipInactive": "以角色發話",
+ "ariaLabelActive": "以角色發話中",
+ "ariaLabelInactive": "以角色發話"
}
},
"main": {
@@ -571,6 +719,8 @@
"tabs": {
"general": "一般",
"generation": "生成",
+ "captures": "擷取",
+ "mcp": "MCP",
"gpu": "GPU",
"logs": "日誌",
"changelog": "更新日誌",
@@ -678,6 +828,212 @@
"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 客戶端設定為傳送對應的 X-Voicebox-Client-Id。",
+ "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": "也提供 POST /speak 介面,供 shell 指令稿、ACP、A2A 使用。"
+ }
+ },
"gpu": {
"cpuOnly": "僅 CPU",
"vramUsed": "{{mb}} MB 顯示記憶體",
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index fcfcef74..2034d3ad 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -131,8 +131,9 @@ class ApiClient {
}
// ── Personality-driven text generation ─────────────────────────────
- // compose + rewrite power the generate-box buttons. Respond and speak
- // are API-only for now — if a UI use appears, add methods here.
+ // Compose produces a fresh in-character utterance the UI drops into
+ // the generate textarea. Rewrite now happens server-side inside
+ // `/generate` when `personality: true` is passed in the request body.
async composeWithPersonality(profileId: string): Promise {
return this.request(`/profiles/${profileId}/compose`, {
@@ -140,16 +141,6 @@ class ApiClient {
});
}
- async rewriteWithPersonality(
- profileId: string,
- text: string,
- ): Promise {
- return this.request(`/profiles/${profileId}/rewrite`, {
- method: 'POST',
- body: JSON.stringify({ text }),
- });
- }
-
async addProfileSample(
profileId: string,
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 {
return this.request('/mcp/bindings');
}
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index e887831e..4ff25285 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -12,7 +12,7 @@ export interface VoiceProfileCreate {
preset_voice_id?: string;
design_prompt?: 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;
}
@@ -35,7 +35,7 @@ export interface VoiceProfileResponse {
updated_at: string;
}
-/** Response returned by /profiles/{id}/compose | /rewrite | /respond. */
+/** Response returned by /profiles/{id}/compose. */
export interface PersonalityTextResponse {
text: string;
model_size: string;
@@ -80,6 +80,8 @@ export interface GenerationRequest {
| 'tada'
| 'kokoro';
instruct?: string;
+ /** When true and the profile has a personality prompt, input text is rewritten in-character before TTS. */
+ personality?: boolean;
max_chunk_chars?: number;
crossfade_ms?: number;
normalize?: boolean;
@@ -496,7 +498,7 @@ export interface MCPClientBinding {
label: string | null;
profile_id: string | null;
default_engine: string | null;
- default_intent: 'respond' | 'rewrite' | 'compose' | null;
+ default_personality: boolean;
last_seen_at: string | null;
created_at: string;
updated_at: string;
@@ -507,7 +509,7 @@ export interface MCPClientBindingUpsert {
label?: string | null;
profile_id?: string | null;
default_engine?: string | null;
- default_intent?: 'respond' | 'rewrite' | 'compose' | null;
+ default_personality?: boolean;
}
export interface MCPClientBindingListResponse {
diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts
index 9e427ca0..e90320e9 100644
--- a/app/src/lib/hooks/useGenerationForm.ts
+++ b/app/src/lib/hooks/useGenerationForm.ts
@@ -29,6 +29,7 @@ const generationSchema = z.object({
'kokoro',
])
.optional(),
+ personality: z.boolean().optional(),
});
export type GenerationFormValues = z.infer;
@@ -66,6 +67,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: '1.7B',
instruct: '',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
+ personality: false,
...options.defaultValues,
},
});
@@ -150,6 +152,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct: supportsInstruct ? data.instruct || undefined : undefined,
+ personality: data.personality || undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
@@ -167,6 +170,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
+ personality: data.personality,
});
options.onSuccess?.(result.id);
} catch (error) {
diff --git a/app/src/lib/utils/format.ts b/app/src/lib/utils/format.ts
index ba98eb0e..fe69b03d 100644
--- a/app/src/lib/utils/format.ts
+++ b/app/src/lib/utils/format.ts
@@ -40,6 +40,16 @@ export function formatDate(date: string | Date): string {
}).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 = {
qwen: 'Qwen',
luxtts: 'LuxTTS',
diff --git a/backend/database/migrations.py b/backend/database/migrations.py
index c4b2b48a..fedba13a 100644
--- a/backend/database/migrations.py
+++ b/backend/database/migrations.py
@@ -35,6 +35,7 @@ def run_migrations(engine) -> None:
_migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
+ _migrate_mcp_bindings(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
@@ -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:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
diff --git a/backend/database/models.py b/backend/database/models.py
index 277468b4..2b5b6e72 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -33,9 +33,10 @@ class VoiceProfile(Base):
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
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
- # Free-form character prompt used by the compose / rewrite / respond / speak
- # endpoints. Describes *what* this voice says and how, orthogonal to how
- # it sounds (which is handled by the preset / cloning metadata above).
+ # Free-form character prompt used by the compose button and the
+ # personality-rewrite path on /generate. Describes *what* this voice
+ # says and how, orthogonal to how it sounds (handled by the preset /
+ # cloning metadata above).
personality = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -71,9 +72,10 @@ class Generation(Base):
status = Column(String, default="completed")
error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False)
- # Origin of this generation — "manual" for regular /generate calls,
- # "personality_speak" for rows created by POST /profiles/{id}/speak.
- # Future sources (bulk import, agent replies, etc.) can extend this.
+ # Origin of this generation — "manual" for plain /generate calls,
+ # "personality_speak" for rows whose text was rewritten through the
+ # profile's personality LLM before TTS. Future sources (bulk import,
+ # agent replies, etc.) can extend this.
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow)
@@ -228,7 +230,7 @@ class GenerationSettings(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
speaks in "Morgan," Cursor in "Scarlett." The MCP client identifies
@@ -243,8 +245,9 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True) # display name
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True)
- # "respond" | "rewrite" | "compose" — null means plain TTS (no LLM transform).
- default_intent = Column(String, nullable=True)
+ # When true, voicebox.speak routes through the profile's personality LLM
+ # (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)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
diff --git a/backend/mcp_server/tools.py b/backend/mcp_server/tools.py
index e617a0c4..40d6cc33 100644
--- a/backend/mcp_server/tools.py
+++ b/backend/mcp_server/tools.py
@@ -12,7 +12,7 @@ import base64 as b64
import logging
import tempfile
from pathlib import Path
-from typing import Any, Literal
+from typing import Any
from fastmcp import FastMCP
@@ -47,7 +47,7 @@ def register_tools(mcp: FastMCP) -> None:
text: str,
profile: str | None = None,
engine: str | None = None,
- intent: Literal["respond", "rewrite", "compose"] | None = None,
+ personality: bool | None = None,
language: str | None = None,
) -> dict[str, Any]:
"""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
MCP client, then falls back to the global default voice.
- ``intent`` only matters for profiles that have a personality prompt —
- when set, the text is first transformed by the LLM (respond to it,
- rewrite it in character, or compose a fresh utterance). Leave unset
- for plain TTS.
+ ``personality`` only matters for profiles that have a personality
+ prompt — when true, the text is first rewritten in character by the
+ LLM before TTS. When omitted, the per-client binding's
+ ``default_personality`` flag decides; when that is unset, the
+ default is plain TTS.
"""
+ from ..database.models import MCPClientBinding
+
db = next(get_db())
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:
raise ValueError(
"No voice profile resolved. Pass `profile=` with a "
@@ -71,24 +75,24 @@ def register_tools(mcp: FastMCP) -> None:
"Voicebox → Settings → MCP."
)
- # Persona path if intent requested and personality present.
- if intent is not None and vp.personality:
- return await _speak_with_persona(
- profile_id=vp.id,
- profile_name=vp.name,
- text=text,
- engine=engine,
- intent=intent,
- language=language,
- db=db,
+ resolved_personality = personality
+ if resolved_personality is None and client_id:
+ binding = (
+ db.query(MCPClientBinding)
+ .filter(MCPClientBinding.client_id == client_id)
+ .first()
)
+ 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_name=vp.name,
text=text,
engine=engine,
language=language,
+ personality=use_persona,
db=db,
)
finally:
@@ -200,19 +204,21 @@ def register_tools(mcp: FastMCP) -> None:
db.close()
-# ─── Speak helpers ─────────────────────────────────────────────────────────
+# ─── Speak helper ──────────────────────────────────────────────────────────
-async def _speak_plain(
+async def _speak(
*,
profile_id: str,
profile_name: str,
text: str,
engine: str | None,
language: str | None,
+ personality: bool,
db,
) -> 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
req = models.GenerationRequest(
@@ -220,35 +226,12 @@ async def _speak_plain(
text=text,
language=language or "en",
engine=engine or "qwen",
+ personality=personality,
)
generation = await generate_speech(req, db)
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(
generation, profile_name: str, *, source: str
) -> dict[str, Any]:
diff --git a/backend/models.py b/backend/models.py
index 1e041353..6d56f9f7 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -81,6 +81,10 @@ class GenerationRequest(BaseModel):
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
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)$")
+ 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(
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):
- """Per-MCP-client voice binding — what voice / engine / intent the server
- should use when a given client_id calls voicebox.speak without args."""
+ """Per-MCP-client voice binding — what voice / engine the server should
+ use when a given client_id calls voicebox.speak without args, plus an
+ opt-in personality-rewrite default."""
client_id: str
label: Optional[str] = None
@@ -307,9 +312,7 @@ class MCPClientBindingResponse(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
- default_intent: Optional[str] = Field(
- None, pattern="^(respond|rewrite|compose)$"
- )
+ default_personality: bool = False
last_seen_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@@ -328,9 +331,7 @@ class MCPClientBindingUpsert(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
- default_intent: Optional[str] = Field(
- None, pattern="^(respond|rewrite|compose)$"
- )
+ default_personality: bool = False
class MCPClientBindingListResponse(BaseModel):
@@ -349,8 +350,9 @@ class SpeakRequest(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
- intent: Optional[str] = Field(
- None, pattern="^(respond|rewrite|compose)$"
+ personality: Optional[bool] = Field(
+ 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(
None,
@@ -380,53 +382,20 @@ class LLMGenerateResponse(BaseModel):
model_size: str
-# ── Profile personality endpoints ─────────────────────────────────────
-# compose / rewrite / respond return raw text; /speak chains LLM → TTS
-# and either persists as a generation (persist=true) or streams audio
-# back transiently.
-
-
-class PersonalityTextRequest(BaseModel):
- """Body for ``/profiles/{id}/rewrite`` and ``/profiles/{id}/respond``."""
-
- text: str = Field(..., min_length=1, max_length=10000)
+# ── Profile personality endpoint ──────────────────────────────────────
+# The sole standalone personality endpoint is ``/profiles/{id}/compose``,
+# which produces a fresh in-character utterance the UI drops into the
+# generate textarea. Rewrite is now reached via ``/generate`` with
+# ``personality=true``.
class PersonalityTextResponse(BaseModel):
- """Response returned by compose / rewrite / respond endpoints."""
+ """Response returned by the ``/profiles/{id}/compose`` endpoint."""
text: 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):
"""Per-model entry in the dictation readiness checklist.
diff --git a/backend/routes/generations.py b/backend/routes/generations.py
index 18553239..a79e8492 100644
--- a/backend/routes/generations.py
+++ b/backend/routes/generations.py
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
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 ..services.generation import run_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
+ 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(
profile_id=data.profile_id,
- text=data.text,
+ text=text,
language=data.language,
audio_path="",
duration=0,
@@ -60,12 +72,13 @@ async def generate_speech(
status="generating",
engine=engine,
model_size=model_size if engine_has_model_sizes(engine) else None,
+ source=source,
)
task_manager.start_generation(
task_id=generation_id,
profile_id=data.profile_id,
- text=data.text,
+ text=text,
)
effects_chain_config = None
@@ -86,7 +99,7 @@ async def generate_speech(
run_generation(
generation_id=generation_id,
profile_id=data.profile_id,
- text=data.text,
+ text=text,
language=data.language,
engine=engine,
model_size=model_size,
diff --git a/backend/routes/mcp_bindings.py b/backend/routes/mcp_bindings.py
index 1078f99a..c4bd67e9 100644
--- a/backend/routes/mcp_bindings.py
+++ b/backend/routes/mcp_bindings.py
@@ -55,7 +55,7 @@ async def upsert_mcp_binding(
row.label = data.label
row.profile_id = data.profile_id
row.default_engine = data.default_engine
- row.default_intent = data.default_intent
+ row.default_personality = data.default_personality
row.updated_at = datetime.utcnow()
db.commit()
db.refresh(row)
diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py
index 572c4771..e0f7f7fd 100644
--- a/backend/routes/profiles.py
+++ b/backend/routes/profiles.py
@@ -4,7 +4,6 @@ import io
import json as _json
import logging
import tempfile
-import uuid
from datetime import datetime
from pathlib import Path
@@ -15,7 +14,7 @@ from sqlalchemy.orm import Session
from .. import config, models
from ..app import safe_content_disposition
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
logger = logging.getLogger(__name__)
@@ -364,31 +363,12 @@ async def update_profile_effects(
return _profile_to_response(profile)
-# ── Personality endpoints ─────────────────────────────────────────────
-# compose / rewrite / respond / speak. All four require a non-empty
-# personality on the profile; the service layer raises ValueError which
-# we translate to HTTP 400. compose and rewrite power the generate-box
-# UI; respond is API-only for conversational / agent-style callers;
-# speak chains LLM → TTS in one call.
-
-
-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"
- )
+# ── Personality endpoint ──────────────────────────────────────────────
+# Only ``/profiles/{id}/compose`` remains — the UI's compose button
+# produces a fresh in-character utterance the user can edit before
+# speaking. Rewrite now happens inside ``/generate`` (and ``/speak``)
+# when ``personality=true``; there is no standalone rewrite/respond/speak
+# endpoint.
@router.post(
@@ -400,7 +380,9 @@ async def compose_in_character(
db: Session = Depends(get_db),
):
"""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:
result = await personality.compose_as_profile(profile.personality)
except ValueError as e:
@@ -408,161 +390,3 @@ async def compose_in_character(
return models.PersonalityTextResponse(
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
diff --git a/backend/routes/speak.py b/backend/routes/speak.py
index 59cf3d7b..5afb2de5 100644
--- a/backend/routes/speak.py
+++ b/backend/routes/speak.py
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
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.resolve import resolve_profile
@@ -52,34 +52,29 @@ async def speak(
),
)
- # Persona path if intent requested AND profile has a personality prompt.
- if data.intent is not None and profile.personality:
- from .profiles import speak_in_character
-
- generation = await speak_in_character(
- profile.id,
- models.PersonalitySpeakRequest(
- text=data.text,
- persist=True,
- language=data.language,
- engine=data.engine,
- intent=data.intent,
- ),
- db,
+ # Resolve per-client personality default when the caller didn't pin it.
+ personality_flag = data.personality
+ if personality_flag is None and client_id:
+ binding = (
+ db.query(MCPClientBinding)
+ .filter(MCPClientBinding.client_id == client_id)
+ .first()
)
- else:
- # Plain TTS path — matches POST /generate.
- from .generations import generate_speech
+ if binding is not None:
+ personality_flag = bool(binding.default_personality)
- generation = await generate_speech(
- models.GenerationRequest(
- profile_id=profile.id,
- text=data.text,
- language=data.language or "en",
- engine=data.engine or "qwen",
- ),
- db,
- )
+ from .generations import generate_speech
+
+ generation = await generate_speech(
+ models.GenerationRequest(
+ profile_id=profile.id,
+ text=data.text,
+ language=data.language or "en",
+ engine=data.engine or "qwen",
+ personality=bool(personality_flag),
+ ),
+ db,
+ )
mcp_events.publish(
"speak-start",
diff --git a/backend/services/personality.py b/backend/services/personality.py
index d14942a9..a9027847 100644
--- a/backend/services/personality.py
+++ b/backend/services/personality.py
@@ -1,26 +1,22 @@
"""
-Personality-driven text generation — lets a voice profile "speak" or "reply"
-using an LLM that takes on the character described by the profile's
-``personality`` prompt.
+Personality-driven text generation — lets a voice profile "speak" or
+restate text using an LLM that takes on the character described by the
+profile's ``personality`` prompt.
-Three entry points:
+Two entry points:
- :func:`compose_as_profile` — zero-input, the character produces a fresh
- utterance. Wired to the "Compose" UI button (fill an empty generate box)
- and to the ``/profiles/{id}/compose`` endpoint.
+ utterance. Wired to the Compose button in the generate box and to the
+ ``/profiles/{id}/compose`` endpoint.
- :func:`rewrite_as_profile` — takes user text, restates it in the
- character's voice while keeping every idea. Wired to the "Rewrite"
- button and the ``/profiles/{id}/rewrite`` endpoint.
-- :func:`respond_as_profile` — takes user text and produces the
- 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"``.
+ character's voice while keeping every idea. Invoked by ``POST /generate``
+ (and ``POST /speak``) when ``personality=true`` and the profile has a
+ personality prompt set.
-All three reuse the same local Qwen3 instance that refinement uses — no
-extra model downloads, no extra warm-up. Temperature is tuned per mode:
-compose runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to
-the user's ideas, respond mid-range (0.7) so the character feels alive
-without drifting.
+Both reuse the same local Qwen3 instance that refinement uses — no extra
+model downloads, no extra warm-up. Temperature is tuned per mode: compose
+runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to the user's
+ideas.
"""
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."""
-_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
class PersonalityResult:
"""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:
if not personality or not personality.strip():
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
@@ -125,28 +118,3 @@ async def rewrite_as_profile(
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)
diff --git a/backend/tests/test_personality_samples.py b/backend/tests/test_personality_samples.py
index cea35752..6a7ef62d 100644
--- a/backend/tests/test_personality_samples.py
+++ b/backend/tests/test_personality_samples.py
@@ -1,14 +1,15 @@
"""
Personality-service sanity sweep — spins up a throwaway profile with a
-fake personality, hits ``/profiles/{id}/compose``, ``/rewrite``, and
-``/respond``, and scores each output against a handful of deterministic
-heuristics so a person can eyeball quality.
+fake personality, exercises ``/profiles/{id}/compose`` and the rewrite
+path on ``/generate`` (``personality=true``), and scores each output
+against a handful of deterministic heuristics so a person can eyeball
+quality.
Same philosophy as ``test_refinement_samples.py``: LLM output is
non-deterministic, "correctness" is subjective, so this is interactive
evaluation — not a CI pass/fail. Gross failures (prompt-echo, refusal,
-empty output, user-text echoing for respond) trip heuristic flags. A
-human still reads the final column.
+empty output) trip heuristic flags. A human still reads the final
+column.
Usage:
# Backend server must be running.
@@ -49,9 +50,9 @@ class Personality:
description: str
"""Free-form character prompt saved to the profile."""
sample_text: str
- """Input used for rewrite / respond. Picked so each personality has
- something distinctive to say about it — an ill fit between text and
- personality makes the transformation more obvious."""
+ """Input used for rewrite. Picked so each personality has something
+ distinctive to say about it — an ill fit between text and personality
+ makes the transformation more obvious."""
PERSONALITIES: tuple[Personality, ...] = (
@@ -121,14 +122,13 @@ class Scorecard:
endpoint: str
model: str
input_text: str
- """Empty for compose, the sample_text for rewrite/respond."""
+ """Empty for compose."""
refined: str
latency_ms: int
length_chars: int = 0
prompt_leak: Optional[str] = None
refusal: Optional[str] = None
stage_directions: list[str] = field(default_factory=list)
- echoed_input: bool = False
flags: list[str] = field(default_factory=list)
@@ -141,20 +141,6 @@ def first_match(patterns, text: str) -> Optional[str]:
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(
personality: Personality,
endpoint: str,
@@ -175,8 +161,6 @@ def score(
refusal=first_match(REFUSAL_PHRASES, refined),
stage_directions=STAGE_DIRECTION_RE.findall(refined)[:3],
)
- if endpoint == "respond":
- card.echoed_input = check_echo(input_text, refined)
if not refined.strip():
card.flags.append("empty-output")
@@ -186,8 +170,6 @@ def score(
card.flags.append(f"refusal({card.refusal!r})")
if card.stage_directions:
card.flags.append(f"stage-directions={card.stage_directions}")
- if card.echoed_input:
- card.flags.append("echoed-input")
return card
@@ -198,10 +180,10 @@ def score(
DEFAULT_PORTS = (8000, 8765, 8899, 17493)
THROWAWAY_PROFILE_PREFIX = "personality-harness-"
KOKORO_PROBE_VOICE = "af_heart"
-"""Any valid kokoro voice id works — compose/rewrite/respond never
-actually call into TTS, they just need a profile row with a personality
-attached. We pick a known-shipping Kokoro voice so the throwaway
-profile satisfies the preset-engine validator on creation."""
+"""Any valid kokoro voice id works — compose never calls into TTS, it
+just needs a profile row with a personality attached. We pick a
+known-shipping Kokoro voice so the throwaway profile satisfies the
+preset-engine validator on creation."""
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})")
-def hit_endpoint(
+def hit_compose(
client: httpx.Client,
port: int,
profile_id: str,
- endpoint: str,
- text: Optional[str],
) -> tuple[str, int]:
start = time.monotonic()
- url = f"http://127.0.0.1:{port}/profiles/{profile_id}/{endpoint}"
- if endpoint == "compose":
- resp = client.post(url, timeout=180.0)
- else:
- resp = client.post(url, json={"text": text}, timeout=180.0)
+ url = f"http://127.0.0.1:{port}/profiles/{profile_id}/compose"
+ resp = client.post(url, timeout=180.0)
latency_ms = int((time.monotonic() - start) * 1000)
resp.raise_for_status()
return resp.json().get("text", "").strip(), latency_ms
@@ -334,29 +311,22 @@ def main() -> int:
print(f" [{personality.name}] ", end="", flush=True)
profile_id = create_throwaway_profile(client, port, personality, model)
try:
- for endpoint, input_text in (
- ("compose", None),
- ("rewrite", personality.sample_text),
- ("respond", personality.sample_text),
- ):
- try:
- text, latency = hit_endpoint(
- client, port, profile_id, endpoint, input_text
- )
- except Exception as e:
- print(f" {endpoint}:ERR ({e})", end="")
- continue
- card = score(
- personality=personality,
- endpoint=endpoint,
- model=model,
- 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="")
+ try:
+ text, latency = hit_compose(client, port, profile_id)
+ except Exception as e:
+ print(f" compose:ERR ({e})", end="")
+ continue
+ card = score(
+ personality=personality,
+ endpoint="compose",
+ model=model,
+ input_text="",
+ refined=text,
+ latency_ms=latency,
+ )
+ cards.append(card)
+ status = "ok" if not card.flags else "⚠"
+ print(f" compose:{status} ({latency}ms)", end="")
print()
finally:
delete_profile(client, port, profile_id)
diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx
index 2cbc73a6..d6dd39e8 100644
--- a/docs/content/docs/index.mdx
+++ b/docs/content/docs/index.mdx
@@ -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
- **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
- **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
@@ -42,5 +42,5 @@ you own. Everything runs on your hardware.
- [Installation](/overview/installation) — download and install Voicebox
- [Quick Start](/overview/quick-start) — get up and running in 5 minutes
- [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
diff --git a/docs/content/docs/overview/captures.mdx b/docs/content/docs/overview/captures.mdx
index e00e731e..3cc78e01 100644
--- a/docs/content/docs/overview/captures.mdx
+++ b/docs/content/docs/overview/captures.mdx
@@ -185,8 +185,8 @@ of your machine can talk to.
The global hotkey flow that feeds most captures.
- Per-profile compose / rewrite / respond modes for captures you want to
- transform, not just transcribe.
+ Per-profile compose button and persona rewrite toggle for captures you
+ want to transform, not just transcribe.
Promote a capture into a voice sample on a profile.
diff --git a/docs/content/docs/overview/dictation.mdx b/docs/content/docs/overview/dictation.mdx
index 897a8355..cca52e24 100644
--- a/docs/content/docs/overview/dictation.mdx
+++ b/docs/content/docs/overview/dictation.mdx
@@ -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 same local LLM doubles as per-profile compose / rewrite / respond.
+ The same local LLM powers per-profile compose and persona rewrite.
Developer-level details on Whisper, Whisper Turbo, and the STT backend.
diff --git a/docs/content/docs/overview/introduction.mdx b/docs/content/docs/overview/introduction.mdx
index daf60be1..de16aa18 100644
--- a/docs/content/docs/overview/introduction.mdx
+++ b/docs/content/docs/overview/introduction.mdx
@@ -14,7 +14,7 @@ accounts:
- **Agents talk back** — any MCP-aware agent can call Voicebox to speak in
one of your cloned voices
- **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)
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
for when you don't want to clone (see [Preset Voices](/overview/preset-voices))
- **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))
- **Post-processing effects** — pitch shift, reverb, delay, chorus,
compression, filters (Spotify's Pedalboard)
@@ -66,7 +66,7 @@ between dictation, the Captures tab, and per-profile personality modes:
| Layer | Models |
|---|---|
| **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.
diff --git a/docs/content/docs/overview/mcp-server.mdx b/docs/content/docs/overview/mcp-server.mdx
index 1f39e98f..4163cdd6 100644
--- a/docs/content/docs/overview/mcp-server.mdx
+++ b/docs/content/docs/overview/mcp-server.mdx
@@ -117,7 +117,7 @@ voicebox.speak({
text: "Deploy complete.",
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
- 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",
})
```
@@ -134,10 +134,9 @@ Returns:
}
```
-- **Plain TTS** — omit `intent`. Text is spoken as-is.
-- **Persona mode** — pass `intent` and the profile must have a personality set.
- The LLM transforms the text (respond to it, rewrite it in character, or
- compose a fresh utterance) before TTS. See [Voice Personalities](/overview/voice-personalities).
+- **Plain TTS** — `personality: false` (or omitted + binding default is false). Text is spoken as-is.
+- **Persona mode** — `personality: true` and the profile must have a personality prompt set.
+ The LLM rewrites the text in character before TTS. See [Voice Personalities](/overview/voice-personalities).
### `voicebox.transcribe`
@@ -196,7 +195,7 @@ carries:
| `label` | Display name in the Settings UI (e.g. "Claude Code"). |
| `profile_id` | The voice this client uses when `profile` isn't passed. |
| `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` 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`,
-`intent`, `language`. Returns a `GenerationResponse` — the same shape as
+`personality`, `language`. Returns a `GenerationResponse` — the same shape as
`POST /generate`.
## Debugging
@@ -286,7 +285,7 @@ For the full developer-facing tour of the code layout, see
- Persona mode (`intent=respond/rewrite/compose`) for agents that should
+ Persona mode (`personality: true`) for agents that should
transform text in-character before speaking.
diff --git a/docs/content/docs/overview/voice-personalities.mdx b/docs/content/docs/overview/voice-personalities.mdx
index b0c4aa63..65c797d3 100644
--- a/docs/content/docs/overview/voice-personalities.mdx
+++ b/docs/content/docs/overview/voice-personalities.mdx
@@ -1,18 +1,19 @@
---
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
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
-and three new actions appear on the profile, each powered by a bundled
-Qwen3 LLM running entirely locally:
+and two new controls appear next to the generate button, both powered by a
+bundled Qwen3 LLM running entirely locally:
-- **Compose** — generate a fresh utterance in this character's voice
-- **Rewrite** — restate your text in their voice while preserving every idea
-- **Respond** — treat your text as a prompt and produce the character's reply
+- **Compose** — drop a fresh in-character line into the textarea. Click
+ again for a different take.
+- **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,
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
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.
### Compose
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
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
register
-### Rewrite
+### Speak in character (rewrite)
-Take your input text and restate it in the character's voice while
-preserving every idea. High-fidelity mode — the content doesn't change, only
-the voice does.
+Flip the persona toggle and whatever you type (or dictate) gets rewritten in
+the character's voice before TTS — every idea preserved, only the phrasing
+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
a plain-English script into a specific voice without editing by hand
- **Temperature:** cold — faithfulness wins
- **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
-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
action tags (`*sighs*`, `[laughs]`), no meta-commentary, no markdown
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 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
-set. For each:
+Both controls appear on the floating generate box when the selected profile
+has a personality set.
-
- 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.
-
- The LLM runs, then the result fills the generate box.
-
-
- The TTS engine speaks the LLM output in the profile's voice. The result
- lands in generation history as a normal generation.
+
+ Type (or dictate) what you want said. Flip the wand toggle on. Hit
+ generate — Voicebox runs the text through the personality LLM first,
+ then TTS speaks the rewritten version. Leave the toggle off for plain
+ TTS.
-Each button also has an inline regenerate affordance — click again to
-resample. Compose will give you something totally different; Rewrite and
-Respond will give you a variation on the same content.
+Compose always gives you something different on re-click. The persona
+toggle, on the other hand, is a mode — it applies to every generate call
+until you flip it back off.
## Use cases
-- **Agents that speak in a voice you own.** Combine Respond with the
- built-in [MCP Server](/overview/mcp-server) so Claude Code, Cursor,
+- **Agents that speak in a voice you own.** Combine the persona toggle with
+ 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
- personality. The agent calls `voicebox.speak({ text, profile, intent:
- "respond" })` and Voicebox produces in-character speech in your cloned
- voice.
+ personality. The agent calls `voicebox.speak({ text, profile, personality:
+ true })` and Voicebox rewrites the text in character before speaking.
- **Interactive characters.** Games, narrative tools, accessibility
experiences. A character with a personality description plus a cloned
voice becomes a reusable prop.
- **Accessibility.** People who can't speak in their original voice can
- keep a personality description of how they used to sound and use Rewrite
- to turn typed input into in-character speech.
-- **Creative drafting.** Write a plain outline, Rewrite line-by-line into
- the character's voice, drop the audio into a Story.
+ keep a personality description of how they used to sound and use the
+ rewrite toggle to turn typed input into in-character speech.
+- **Creative drafting.** Write a plain outline, flip the persona toggle,
+ generate line-by-line into the character's voice, drop the audio into a
+ Story.
## API surface
-Personalities and the three modes are accessible via REST:
+Personalities are accessible via REST:
| Method | Endpoint | Body |
|---|---|---|
| `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
-`voicebox.speak` tool calls when that ships. Scripts and agents can use it
-directly today.
+`POST /generate` with `personality: true` is the same primitive MCP's
+`voicebox.speak` tool uses when you pass `personality: true`. Scripts and
+agents can use it directly.
## Limits and gotchas
diff --git a/docs/plans/MCP_SERVER.md b/docs/plans/MCP_SERVER.md
index 9249c684..243b0ffb 100644
--- a/docs/plans/MCP_SERVER.md
+++ b/docs/plans/MCP_SERVER.md
@@ -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.
- **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:
- - `voicebox.speak(text, profile?, engine?, intent?, language?)`
+ - `voicebox.speak(text, profile?, engine?, personality?, language?)`
- `voicebox.transcribe(audio_base64?, audio_path?, language?, model?)`
- `voicebox.list_captures(limit, offset)`
- `voicebox.list_profiles()`
@@ -111,7 +111,7 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True)
profile_id = Column(String, ForeignKey("profiles.id"), 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)
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_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/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
@@ -191,11 +191,12 @@ Tools are registered with **dotted names** (`voicebox.speak`, etc.) to match the
async def speak(text: str,
profile: str | None = None, # name OR id
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:
"""Speak text in a voice profile. Returns {generation_id, status, profile, poll}."""
- # resolve profile via precedence, call speak_in_character (profiles.py:453)
- # with persist=True so it lands in history.
+ # resolve profile via precedence, delegate to generate_speech — the
+ # route honors `personality=True` by running rewrite_as_profile on
+ # the input before running the normal TTS pipeline.
@mcp.tool(name="voicebox.transcribe")
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")
profile = resolve_profile(data.profile, client_id, db)
if profile is None: raise HTTPException(400, "No voice profile resolved.")
- persist_req = PersonalitySpeakRequest(text=data.text, persist=True, language=data.language,
- engine=data.engine, intent=data.intent or "respond")
- return await speak_in_character(profile.id, persist_req, db)
+ req = GenerationRequest(profile_id=profile.id, text=data.text,
+ language=data.language or "en",
+ 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`)
@@ -265,7 +268,7 @@ PyInstaller spec keeps only `mcp`, `httpx`, `anyio`, `click` — target binary <
## Settings UI (`MCPBindings.tsx`)
- **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:
HTTP form (primary):