personality: bool API, i18n across the app

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

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
This commit is contained in:
Jamie Pine
2026-04-23 17:31:23 -07:00
parent 7c50e189cb
commit abf5dfda8c
41 changed files with 2146 additions and 1193 deletions
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
@@ -81,6 +82,7 @@ export function useAccessibilityPermission() {
* already granted.
*/
export function AccessibilityNotice() {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false);
@@ -98,26 +100,23 @@ export function AccessibilityNotice() {
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
Grant Accessibility permission to enable auto-paste
{t('captures.permissions.accessibility.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings Privacy &amp; Security Accessibility
to paste transcriptions into other apps. Your dictation still lands
in the Captures tab without it.
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.permissions.accessibility.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking' : "I've enabled it"}
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
Still not detected. macOS usually requires quitting and reopening
Voicebox after toggling the permission.
{t('captures.permissions.accessibility.stillMissing')}
</p>
)}
</div>
+14 -10
View File
@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils/cn';
/**
@@ -15,12 +16,12 @@ export type PillState =
| 'rest'
| 'error';
const PILL_LABELS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'Recording',
transcribing: 'Transcribing',
refining: 'Refining',
speaking: 'Speaking',
completed: 'Done',
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'captures.pill.recording',
transcribing: 'captures.pill.transcribing',
refining: 'captures.pill.refining',
speaking: 'captures.pill.speaking',
completed: 'captures.pill.completed',
};
function barModeFor(
@@ -87,10 +88,12 @@ export function CapturePill({
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (state === 'error') {
return (
<ErrorPill
message={errorMessage ?? 'Something went wrong'}
message={errorMessage ?? t('captures.pill.errorFallback')}
onDismiss={onDismiss}
className={className}
/>
@@ -98,7 +101,7 @@ export function CapturePill({
}
const visible = state !== 'rest';
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
const barMode = barModeFor(state);
const dot = (
@@ -114,7 +117,7 @@ export function CapturePill({
<button
type="button"
onClick={onStop}
aria-label="Stop recording"
aria-label={t('captures.pill.stopAria')}
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{dot}
@@ -161,6 +164,7 @@ function ErrorPill({
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const handleClick = async () => {
try {
await navigator.clipboard.writeText(message);
@@ -175,7 +179,7 @@ function ErrorPill({
<button
type="button"
onClick={handleClick}
title="Click to copy error"
title={t('captures.pill.errorCopyTooltip')}
className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-black/65 backdrop-blur-md text-red-300',
+67 -104
View File
@@ -20,6 +20,7 @@ import {
Volume2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { Badge } from '@/components/ui/badge';
@@ -48,25 +49,12 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const diffMs = Date.now() - then;
const mins = Math.round(diffMs / 60_000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
const days = Math.round(hrs / 24);
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return new Date(iso).toLocaleDateString();
}
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
@@ -75,20 +63,6 @@ function formatDuration(ms?: number | null): string {
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function snippetOf(capture: CaptureResponse): string {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || '(no transcript)';
}
function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null;
return (
@@ -114,8 +88,14 @@ function ChordKeys({ keys }: { keys: string[] }) {
}
function SourceBadge({ source }: { source: CaptureSource }) {
const { t } = useTranslation();
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
const label =
source === 'dictation'
? t('captures.source.dictation')
: source === 'recording'
? t('captures.source.recording')
: t('captures.source.file');
return (
<Badge
variant="secondary"
@@ -154,26 +134,18 @@ function FakeWaveform({ seed = 1, className }: { seed?: number; className?: stri
type PlaybackState = 'idle' | 'generating' | 'playing';
function voiceGradient(profileId: string): string {
const gradients = [
'from-blue-400 to-indigo-500',
'from-emerald-400 to-teal-500',
'from-purple-500 to-fuchsia-500',
'from-amber-400 to-rose-500',
'from-rose-400 to-pink-500',
'from-cyan-400 to-sky-500',
];
let hash = 0;
for (let i = 0; i < profileId.length; i++) hash = (hash * 31 + profileId.charCodeAt(i)) | 0;
return gradients[Math.abs(hash) % gradients.length];
}
export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const snippetOf = (capture: CaptureResponse): string => {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || t('captures.snippetEmpty');
};
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
@@ -278,14 +250,14 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' });
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error('Capture has no transcript yet');
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an
@@ -308,14 +280,14 @@ export function CapturesTab() {
apiClient.getAudioUrl(result.id),
result.id,
voice.id,
`${voice.name} · ${capture.id.slice(0, 8)}`,
t('captures.playerVoiceLabel', { voice: voice.name, captureId: capture.id.slice(0, 8) }),
);
setPlaybackState('playing');
}
},
onError: (err: Error) => {
setPlaybackState('idle');
toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' });
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
},
});
@@ -339,7 +311,7 @@ export function CapturesTab() {
apiClient.getCaptureAudioUrl(selected.id),
`capture-${selected.id}`,
null,
`Capture · ${formatDate(selected.created_at)}`,
t('captures.captureCardLabel', { when: formatAbsoluteDate(selected.created_at) }),
);
};
@@ -350,9 +322,9 @@ export function CapturesTab() {
: selected.transcript_raw;
try {
await navigator.clipboard.writeText(text || '');
toast({ title: 'Transcript copied' });
toast({ title: t('captures.toast.transcriptCopied') });
} catch {
toast({ title: 'Copy failed', variant: 'destructive' });
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
}
};
@@ -361,8 +333,8 @@ export function CapturesTab() {
const target = voice ?? playAsVoice;
if (!target) {
toast({
title: 'No voice profile',
description: 'Create a voice profile before using Play as.',
title: t('captures.toast.noVoice'),
description: t('captures.toast.noVoiceDescription'),
variant: 'destructive',
});
return;
@@ -395,17 +367,17 @@ export function CapturesTab() {
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center mb-3">
<h1 className="text-2xl px-4 font-bold">Captures</h1>
<h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
Beta
{t('captures.beta')}
</Badge>
</div>
<div className="relative">
<Input
placeholder="Search transcripts..."
placeholder={t('captures.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -427,9 +399,9 @@ export function CapturesTab() {
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>No captures match "{search}"</p>
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>No captures yet.</p>
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
@@ -450,7 +422,7 @@ export function CapturesTab() {
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatRelative(capture.created_at)}
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
@@ -468,7 +440,7 @@ export function CapturesTab() {
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
Refined
{t('captures.transcript.refined')}
</Badge>
)}
</div>
@@ -490,9 +462,10 @@ export function CapturesTab() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span>
Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)}
<span className="mx-1.5 text-muted-foreground/40">·</span>
Qwen3 · {llmModel}
{t('captures.header.modelSummary', {
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
llm: llmModel,
})}
</span>
</div>
<div className="flex-1" />
@@ -510,7 +483,7 @@ export function CapturesTab() {
<Button variant="outline" asChild>
<Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" />
Configure
{t('captures.actions.configure')}
</Link>
</Button>
{readiness.allReady && (
@@ -524,7 +497,7 @@ export function CapturesTab() {
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? 'Uploading...' : 'Import'}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
</Button>
)}
</>
@@ -542,12 +515,12 @@ export function CapturesTab() {
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop
{t('captures.actions.stop')}
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
Dictate
{t('captures.actions.dictate')}
</>
)}
</Button>
@@ -564,7 +537,7 @@ export function CapturesTab() {
>
{/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatDate(selected.created_at)}</span>
<span>{formatAbsoluteDate(selected.created_at)}</span>
{selected.language && (
<>
<span className="text-muted-foreground/40">·</span>
@@ -611,7 +584,7 @@ export function CapturesTab() {
)}
>
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Refined
{t('captures.transcript.refined')}
</button>
<button
type="button"
@@ -624,15 +597,15 @@ export function CapturesTab() {
)}
>
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Raw
{t('captures.transcript.raw')}
</button>
</div>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{showRefined && selected.transcript_refined
? `Refined with Qwen3 · ${selected.llm_model ?? llmModel}`
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
: selected.stt_model
? `Transcribed with Whisper ${selected.stt_model}`
? t('captures.transcript.rawHint', { model: selected.stt_model })
: null}
</span>
</div>
@@ -665,29 +638,24 @@ export function CapturesTab() {
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15',
)}
>
{playAsVoice && (
<div
className={cn(
'h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(playAsVoice.id),
playbackState === 'playing' && 'animate-pulse',
)}
/>
)}
{playbackState === 'generating' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating
{t('captures.actions.playAsGenerating')}
</>
) : playbackState === 'playing' ? (
<>
<Square className="h-3 w-3 fill-current" />
Stop · {playAsVoice?.name ?? 'Voice'}
{playAsVoice
? t('captures.actions.playAsStop', { name: playAsVoice.name })
: t('captures.actions.playAsStopFallback')}
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5" />
{playAsVoice ? `Play as ${playAsVoice.name}` : 'Play as…'}
{playAsVoice
? t('captures.actions.playAs', { name: playAsVoice.name })
: t('captures.actions.playAsFallback')}
</>
)}
</Button>
@@ -708,21 +676,15 @@ export function CapturesTab() {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Play transcript as
{t('captures.actions.playAsDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="gap-2.5 py-2"
className="py-2"
>
<div
className={cn(
'h-7 w-7 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(v.id),
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
@@ -739,7 +701,7 @@ export function CapturesTab() {
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
{t('captures.actions.copy')}
</Button>
<Button
variant="outline"
@@ -752,11 +714,13 @@ export function CapturesTab() {
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
{selected.transcript_refined ? 'Re-refine' : 'Refine'}
{selected.transcript_refined
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" />
Send to
{t('captures.actions.sendTo')}
</Button>
<div className="flex-1" />
<Button
@@ -771,7 +735,7 @@ export function CapturesTab() {
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
Delete
{t('captures.actions.delete')}
</Button>
</div>
</div>
@@ -780,12 +744,12 @@ export function CapturesTab() {
{capturesLoading ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Loading captures</p>
<p className="text-sm">{t('captures.empty.loading')}</p>
</div>
) : captures.length ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Pick a capture to see the transcript.</p>
<p className="text-sm">{t('captures.empty.pickOne')}</p>
</div>
) : hotkeyEnabled && !readiness.allReady ? (
<DictationReadinessChecklist readiness={readiness} />
@@ -796,7 +760,7 @@ export function CapturesTab() {
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={pushToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Hold to record
{t('captures.empty.holdToRecord')}
</span>
</div>
) : null}
@@ -804,25 +768,24 @@ export function CapturesTab() {
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={toggleToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Toggle hands-free
{t('captures.empty.toggleHandsFree')}
</span>
</div>
) : null}
</div>
<p className="text-sm">
Press the shortcut anywhere on your machine to start your first capture.
{t('captures.empty.pressShortcut')}
</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">No captures yet.</p>
<p className="text-sm">{t('captures.empty.none')}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Turn on the global shortcut to dictate from anywhere or click
Dictate above for an in-app capture.
{t('captures.empty.turnOnShortcut')}
</p>
<Button asChild variant="outline" size="sm">
<Link to="/settings/captures">Open Captures settings</Link>
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
</Button>
</div>
)}
@@ -10,6 +10,7 @@ import {
Loader2,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
@@ -74,6 +75,7 @@ function progressPercent(task: ActiveDownloadTask | undefined): number | null {
* "stuck pill" failure mode of pressing the chord with a missing model.
*/
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
@@ -123,13 +125,13 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({
title: 'Download started',
description: `${displayName} is downloading. The shortcut will arm itself when it finishes.`,
title: t('captures.readiness.downloadStarted'),
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
});
},
onError: (err: Error) => {
toast({
title: 'Download failed',
title: t('captures.readiness.downloadFailed'),
description: err.message,
variant: 'destructive',
});
@@ -159,12 +161,14 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
{downloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null ? `Downloading… ${pct}%` : 'Downloading…'}
{pct != null
? t('captures.readiness.downloadingPercent', { pct })
: t('captures.readiness.downloading')}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
{t('captures.readiness.downloadButton')}
</>
)}
</Button>
@@ -175,21 +179,23 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
<div className="w-full max-w-md mx-auto space-y-2.5">
<div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground">
A few things before you can dictate
{t('captures.readiness.title')}
</h2>
<p className="text-xs text-muted-foreground">
The shortcut stays off until everything below is ready.
{t('captures.readiness.subheading')}
</p>
</div>
{readiness.stt && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.stt.display_name} (speech-to-text)`}
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={
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 && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.llm.display_name} (refinement)`}
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={
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
<ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />}
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={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.readiness.inputMonitoring.openSettings')}
</Button>
}
/>
<ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />}
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={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.readiness.accessibility.openSettings')}
</Button>
}
/>
@@ -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({
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? 'Capturing' : 'Press your shortcut'}
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
{displayKeys.length === 0 ? (
<span className="text-sm text-muted-foreground italic">
No keys yet
{t('captures.chord.noKeys')}
</span>
) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />)
@@ -166,8 +168,7 @@ export function ChordPicker({
</div>
{unsupportedAttempt ? (
<p className="text-xs text-destructive">
"{unsupportedAttempt}" isn't supported in chords. Try a modifier
or letter key.
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
</p>
) : null}
</div>
@@ -175,10 +176,10 @@ export function ChordPicker({
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}>
Save
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { 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({
/>
</motion.div>
<div className="relative shrink-0">
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
<div className="flex items-start gap-2 shrink-0">
{/* Compose — fills the textarea with a fresh in-character line. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
disabled={composeMutation.isPending || !selectedProfileId}
onClick={async () => {
const result = await composeMutation.mutateAsync();
form.setValue('text', result.text, { shouldDirty: true });
setIsExpanded(true);
}}
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
aria-label={t('generation.compose.ariaLabel')}
>
{composeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Dices className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{t('generation.compose.tooltip')}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<FormField
control={form.control}
name="personality"
render={({ field }) => {
const active = !!field.value;
return (
<FormItem className="space-y-0">
<FormControl>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => field.onChange(!active)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
active
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
aria-pressed={active}
>
<Wand2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
</span>
</div>
</FormControl>
</FormItem>
);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence>
@@ -369,7 +444,6 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<div className="group relative">
<Button
@@ -399,6 +473,35 @@ export function FloatingGenerateBox({
</motion.div>
)}
</AnimatePresence>
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
</div>
</div>
@@ -463,6 +566,7 @@ export function FloatingGenerateBox({
</div>
)}
<FormField
control={form.control}
name="language"
@@ -1,315 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { Loader2, Mic, RefreshCw, Sparkles } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { toast } = useToast();
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
// ── Personality-driven text generation ─────────────────────────────
// Compose fills the empty textarea with a fresh in-character line.
// Rewrite restates whatever's in the textarea in the profile's voice.
// Both buttons hide entirely when the selected profile has no
// personality set — nothing to drive the LLM with otherwise.
const personality = selectedProfile?.personality?.trim() || '';
const hasPersonality = personality.length > 0;
const currentText = form.watch('text');
const textHasContent = (currentText || '').trim().length > 0;
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Compose failed',
description: err.message || 'Could not generate text from this personality.',
variant: 'destructive',
});
},
});
const rewriteMutation = useMutation({
mutationFn: async (text: string) => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.rewriteWithPersonality(selectedProfileId, text);
},
onSuccess: (result) => {
form.setValue('text', result.text, { shouldDirty: true, shouldValidate: true });
},
onError: (err: Error) => {
toast({
title: 'Rewrite failed',
description: err.message || 'Could not rewrite the text in this voice.',
variant: 'destructive',
});
},
});
return (
<Card>
<CardHeader>
<CardTitle>Generate Speech</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Voice Profile</FormLabel>
{selectedProfile ? (
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<Mic className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{selectedProfile.name}</span>
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
</div>
) : (
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
Click on a profile card above to select a voice profile
</div>
)}
</div>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder="Enter text... type / for effects like [laugh], [sigh]"
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
/>
) : (
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
)}
</FormControl>
<FormDescription>
{form.watch('engine') === 'chatterbox_turbo'
? 'Max 5000 characters. Type / to insert sound effects.'
: 'Max 5000 characters'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{hasPersonality && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
textHasContent ||
composeMutation.isPending ||
rewriteMutation.isPending
}
onClick={() => composeMutation.mutate()}
>
{composeMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
Compose
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={
!selectedProfileId ||
!textHasContent ||
rewriteMutation.isPending ||
composeMutation.isPending
}
onClick={() => rewriteMutation.mutate(currentText || '')}
>
{rewriteMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Rewrite in voice
</Button>
<span className="text-xs text-muted-foreground">
Uses the profile's personality.
</span>
</div>
)}
{form.watch('engine') === 'qwen_custom_voice' && (
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion,
pace). Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<EngineModelSelector form={form} selectedProfile={selectedProfile} />
<FormDescription>
{getEngineDescription(form.watch('engine') || 'qwen')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
return (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="seed"
render={({ field }) => (
<FormItem>
<FormLabel>Seed (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Random"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormDescription>For reproducible results</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { 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 }) {
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
Grant Input Monitoring to enable the global shortcut
{t('captures.permissions.inputMonitoring.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
Voicebox needs System Settings Privacy &amp; Security Input
Monitoring to detect your dictation chord. The toggle is on, but
macOS is blocking key events until you allow it.
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
{t('captures.permissions.inputMonitoring.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking' : "I've enabled it"}
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
Still not detected. macOS usually requires quitting and reopening
Voicebox after toggling the permission.
{t('captures.permissions.inputMonitoring.stillMissing')}
</p>
)}
</div>
+103 -90
View File
@@ -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 <span className="text-xs text-muted-foreground italic">Not set</span>;
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
}
return (
<div className="flex items-center gap-1">
@@ -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() {
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title="Dictation"
description="Capture from anywhere on your machine with a global shortcut."
title={t('settings.captures.dictation.title')}
description={t('settings.captures.dictation.description')}
>
<div>
<SettingRow
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."
title={t('settings.captures.dictation.globalShortcut.title')}
description={t('settings.captures.dictation.globalShortcut.description')}
htmlFor="hotkeyEnabled"
action={
<Toggle
@@ -195,10 +198,11 @@ export function CapturesPage() {
.filter(Boolean)
.join(' and ');
toast({
title: 'Shortcut on, but not yet armed',
description: `${names} still need${
missingModels.length === 1 ? 's' : ''
} to download. Open the Captures tab to start.`,
title: t('captures.toast.shortcutNotArmed'),
description: t('captures.toast.shortcutNotArmedDescription', {
names,
count: missingModels.length,
}),
});
}}
/>
@@ -208,8 +212,8 @@ export function CapturesPage() {
</div>
<SettingRow
title="Push-to-talk shortcut"
description="Hold these keys anywhere on your system to record. Release to stop and transcribe."
title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} />
@@ -220,15 +224,15 @@ export function CapturesPage() {
onClick={() => setChordEditor('push')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change
{t('settings.captures.dictation.pushToTalk.change')}
</Button>
</div>
}
/>
<SettingRow
title="Toggle shortcut"
description="Press once to start a hands-free recording. Press again to stop. Usually push-to-talk plus Space."
title={t('settings.captures.dictation.toggle.title')}
description={t('settings.captures.dictation.toggle.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} />
@@ -239,7 +243,7 @@ export function CapturesPage() {
onClick={() => setChordEditor('toggle')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change
{t('settings.captures.dictation.toggle.change')}
</Button>
</div>
}
@@ -247,8 +251,8 @@ export function CapturesPage() {
<ChordPicker
open={chordEditor === 'push'}
title="Set push-to-talk shortcut"
description="Hold the keys you want to use, then release and click Save. The right-hand modifier badge shows whether a key is the left or right variant."
title={t('settings.captures.dictation.chordPicker.pttTitle')}
description={t('settings.captures.dictation.chordPicker.pttDescription')}
initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
@@ -259,8 +263,8 @@ export function CapturesPage() {
<ChordPicker
open={chordEditor === 'toggle'}
title="Set toggle shortcut"
description="Hold the keys you want to use, then release and click Save. Pick something distinct from your push-to-talk chord."
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
@@ -270,15 +274,15 @@ export function CapturesPage() {
/>
<SettingRow
title="Preview"
description="What appears on screen while you're holding the shortcut."
title={t('settings.captures.dictation.preview.title')}
description={t('settings.captures.dictation.preview.description')}
>
<HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow>
<SettingRow
title="Copy transcript to clipboard"
description="The cleaned transcript lands on your clipboard when the capture finishes."
title={t('settings.captures.dictation.copyToClipboard.title')}
description={t('settings.captures.dictation.copyToClipboard.description')}
htmlFor="copyToClipboard"
action={
<Toggle
@@ -292,8 +296,8 @@ export function CapturesPage() {
<div>
<SettingRow
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."
title={t('settings.captures.dictation.autoPaste.title')}
description={t('settings.captures.dictation.autoPaste.description')}
htmlFor="autoPaste"
action={
<Toggle
@@ -309,12 +313,12 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Transcription"
description="Pick which speech-to-text model runs on your captures."
title={t('settings.captures.transcription.title')}
description={t('settings.captures.transcription.description')}
>
<SettingRow
title="Transcription model"
description="Whisper ships with Voicebox and runs entirely on your machine."
title={t('settings.captures.transcription.model.title')}
description={t('settings.captures.transcription.model.description')}
action={
<Select
value={sttModel}
@@ -324,16 +328,20 @@ export function CapturesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">Whisper Base · 74M · Fast</SelectItem>
<SelectItem value="small">Whisper Small · 244M · Balanced</SelectItem>
<SelectItem value="base">
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
</SelectItem>
<SelectItem value="small">
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
</SelectItem>
<SelectItem value="medium">
Whisper Medium · 769M · Higher accuracy
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
</SelectItem>
<SelectItem value="large">
Whisper Large · 1.5B · Best accuracy
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
</SelectItem>
<SelectItem value="turbo">
Whisper Turbo · Pruned Large v3 · Near-best, fast
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
</SelectItem>
</SelectContent>
</Select>
@@ -341,42 +349,42 @@ export function CapturesPage() {
/>
<SettingRow
title="Language"
description="Auto-detect works for most captures. Lock it if you're always speaking the same language."
title={t('settings.captures.transcription.language.title')}
description={t('settings.captures.transcription.language.description')}
action={
<Select value={language} onValueChange={(v) => update({ language: v })}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
<SelectItem value="hi">Hindi</SelectItem>
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Archive audio"
description="Keep the original recording alongside every transcript."
title={t('settings.captures.transcription.archive.title')}
description={t('settings.captures.transcription.archive.description')}
htmlFor="archiveAudio"
action={<Toggle id="archiveAudio" checked={archiveAudio} onCheckedChange={setArchiveAudio} />}
/>
</SettingSection>
<SettingSection
title="Refinement"
description="Optionally run a local LLM over transcripts to clean filler words, punctuation, and self-corrections."
title={t('settings.captures.refinement.title')}
description={t('settings.captures.refinement.description')}
>
<SettingRow
title="Refine transcripts automatically"
description="Runs after every capture. You can still toggle between raw and refined in the Captures tab."
title={t('settings.captures.refinement.auto.title')}
description={t('settings.captures.refinement.auto.description')}
htmlFor="autoRefine"
action={
<Toggle
@@ -388,8 +396,8 @@ export function CapturesPage() {
/>
<SettingRow
title="Refinement model"
description="Larger models are slower but handle subtle self-corrections and technical vocabulary better."
title={t('settings.captures.refinement.model.title')}
description={t('settings.captures.refinement.model.description')}
action={
<Select
value={llmModel}
@@ -400,17 +408,23 @@ export function CapturesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.6B">Qwen3 · 0.6B · 400 MB · Very fast</SelectItem>
<SelectItem value="1.7B">Qwen3 · 1.7B · 1.1 GB · Fast</SelectItem>
<SelectItem value="4B">Qwen3 · 4B · 2.5 GB · Full quality</SelectItem>
<SelectItem value="0.6B">
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
</SelectItem>
<SelectItem value="1.7B">
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
</SelectItem>
<SelectItem value="4B">
{t('settings.captures.refinement.model.size4', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Smart cleanup"
description="Remove filler words (um, uh, like), restore punctuation, and fix capitalization without rephrasing."
title={t('settings.captures.refinement.smartCleanup.title')}
description={t('settings.captures.refinement.smartCleanup.description')}
htmlFor="smartCleanup"
action={
<Toggle
@@ -423,8 +437,8 @@ export function CapturesPage() {
/>
<SettingRow
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.'}
title={t('settings.captures.refinement.selfCorrection.title')}
description={t('settings.captures.refinement.selfCorrection.description')}
htmlFor="selfCorrection"
action={
<Toggle
@@ -437,8 +451,8 @@ export function CapturesPage() {
/>
<SettingRow
title="Preserve technical terms"
description="Keep code identifiers, command names, and acronyms exactly as spoken. Turn on when you dictate into a code prompt."
title={t('settings.captures.refinement.preserveTechnical.title')}
description={t('settings.captures.refinement.preserveTechnical.description')}
htmlFor="preserveTechnical"
action={
<Toggle
@@ -452,12 +466,12 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Playback"
description='Default voice for the "Play as" action in the Captures tab.'
title={t('settings.captures.playback.title')}
description={t('settings.captures.playback.description')}
>
<SettingRow
title="Default voice"
description="Used when you click Play as without picking a voice first. You can change it per capture."
title={t('settings.captures.playback.defaultVoice.title')}
description={t('settings.captures.playback.defaultVoice.description')}
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -480,7 +494,9 @@ export function CapturesPage() {
</>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0 ? 'No cloned voices yet' : 'None selected'}
{voices.length === 0
? t('settings.captures.playback.defaultVoice.noClonedVoices')
: t('settings.captures.playback.defaultVoice.noneSelected')}
</span>
)}
</div>
@@ -489,7 +505,7 @@ export function CapturesPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Cloned voices
{t('settings.captures.playback.defaultVoice.clonedVoices')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{voices.map((v) => (
@@ -522,30 +538,30 @@ export function CapturesPage() {
</SettingSection>
<SettingSection
title="Storage"
description="Captures are saved as paired audio and transcript files in your Voicebox data directory."
title={t('settings.captures.storage.title')}
description={t('settings.captures.storage.description')}
>
<SettingRow
title="Retention"
description="How long to keep captures. Applies to both audio and transcripts."
title={t('settings.captures.storage.retention.title')}
description={t('settings.captures.storage.retention.description')}
action={
<Select value={retention} onValueChange={setRetention}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="forever">Keep forever</SelectItem>
<SelectItem value="90d">90 days</SelectItem>
<SelectItem value="30d">30 days</SelectItem>
<SelectItem value="7d">7 days</SelectItem>
<SelectItem value="forever">{t('settings.captures.storage.retention.forever')}</SelectItem>
<SelectItem value="90d">{t('settings.captures.storage.retention.d90')}</SelectItem>
<SelectItem value="30d">{t('settings.captures.storage.retention.d30')}</SelectItem>
<SelectItem value="7d">{t('settings.captures.storage.retention.d7')}</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Clear all captures"
description="Permanently delete every capture and its audio. This cannot be undone."
title={t('settings.captures.storage.clearAll.title')}
description={t('settings.captures.storage.clearAll.description')}
action={
<Button
variant="outline"
@@ -553,7 +569,7 @@ export function CapturesPage() {
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Clear captures
{t('settings.captures.storage.clearAll.action')}
</Button>
}
/>
@@ -562,41 +578,38 @@ export function CapturesPage() {
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About Captures</h3>
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
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')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">What's different</h3>
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Fully local.</span>{' '}
Whisper and the refinement LLM run on your hardware. No cloud,
no accounts, your voice never leaves the machine.
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
{t('settings.captures.sidebar.local.body')}
</span>
</li>
<li className="flex gap-2.5">
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Play as any voice.
{t('settings.captures.sidebar.playAs.title')}
</span>{' '}
Transcripts can be read back in any profile you've cloned.
{t('settings.captures.sidebar.playAs.body')}
</span>
</li>
<li className="flex gap-2.5">
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Cross-platform.
{t('settings.captures.sidebar.crossPlatform.title')}
</span>{' '}
Same shortcut, same flow on macOS, Windows, and Linux.
{t('settings.captures.sidebar.crossPlatform.body')}
</span>
</li>
</ul>
+40 -50
View File
@@ -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() {
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title="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."
title={t('settings.mcp.install.title')}
description={t('settings.mcp.install.description')}
>
<SnippetRow
title="HTTP (recommended)"
description="For clients that speak HTTP MCP — Claude Code, Cursor, Windsurf, VS Code."
title={t('settings.mcp.install.http.title')}
description={t('settings.mcp.install.http.description')}
snippet={JSON.stringify(
{
mcpServers: {
@@ -67,13 +70,13 @@ export function MCPPage() {
)}
/>
<SnippetRow
title="Claude Code one-liner"
description="Registers via the Claude Code CLI."
title={t('settings.mcp.install.claudeCode.title')}
description={t('settings.mcp.install.claudeCode.description')}
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
/>
<SnippetRow
title="Stdio (fallback)"
description="For clients that only spawn stdio processes. The shim binary ships with the app."
title={t('settings.mcp.install.stdio.title')}
description={t('settings.mcp.install.stdio.description')}
snippet={JSON.stringify(
{
mcpServers: {
@@ -91,12 +94,12 @@ export function MCPPage() {
</SettingSection>
<SettingSection
title="Default voice"
description="Used when an agent calls voicebox.speak without a specific profile and has no per-client binding."
title={t('settings.mcp.defaultVoice.title')}
description={t('settings.mcp.defaultVoice.description')}
>
<SettingRow
title="Default playback voice"
description="Shared with the Captures-tab 'Play as voice' dropdown — one default voice for passive playback."
title={t('settings.mcp.defaultVoice.label')}
description={t('settings.mcp.defaultVoice.labelHint')}
action={
<select
value={defaultProfileId}
@@ -107,7 +110,7 @@ export function MCPPage() {
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
>
<option value="">(none)</option>
<option value="">{t('settings.mcp.defaultVoice.none')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -119,13 +122,12 @@ export function MCPPage() {
</SettingSection>
<SettingSection
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)."
title={t('settings.mcp.bindings.title')}
description={t('settings.mcp.bindings.description')}
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 italic">
No bindings yet. Add one below, then configure your MCP client to
send the matching <code>X-Voicebox-Client-Id</code>.
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
</p>
) : (
<div className="divide-y divide-border/60">
@@ -142,12 +144,12 @@ export function MCPPage() {
<code className="text-[11px]">{b.client_id}</code>
{' · '}
{b.last_seen_at ? (
<span title={`Last seen ${b.last_seen_at}`}>
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
last seen {formatRelative(b.last_seen_at)}
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
</span>
) : (
<span>never connected</span>
<span>{t('settings.mcp.bindings.neverConnected')}</span>
)}
</div>
</div>
@@ -162,7 +164,7 @@ export function MCPPage() {
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
>
<option value="">(default)</option>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{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 })}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -183,18 +185,18 @@ export function MCPPage() {
)}
<div className="pt-4 space-y-2">
<div className="text-sm font-medium">Add a binding</div>
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
type="text"
placeholder="client id (e.g. claude-code)"
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<input
type="text"
placeholder="label (optional)"
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
@@ -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]"
>
<option value="">(default)</option>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -217,7 +219,7 @@ export function MCPPage() {
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
Add binding
{t('settings.mcp.bindings.add.action')}
</Button>
</div>
</SettingSection>
@@ -225,39 +227,36 @@ export function MCPPage() {
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About MCP</h3>
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
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')}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">Available tools</h3>
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</h3>
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<li>
<code className="text-accent">voicebox.speak</code>
<div>Speak text in a voice profile.</div>
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
</li>
<li>
<code className="text-accent">voicebox.transcribe</code>
<div>Whisper STT on a clip.</div>
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_captures</code>
<div>Recent dictations / recordings.</div>
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_profiles</code>
<div>Available voice profiles.</div>
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
</li>
</ul>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waypoints className="h-3.5 w-3.5 text-accent" />
<span>
Also exposed as <code>POST /speak</code> for shell scripts, ACP,
A2A.
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
</span>
</div>
</aside>
@@ -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 ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
{t('settings.mcp.install.copied')}
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
{t('settings.mcp.install.copy')}
</>
)}
</Button>
@@ -312,13 +312,3 @@ function SnippetRow({
</div>
);
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();
const diff = Math.max(0, now - then);
if (diff < 60_000) return 'just now';
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} h ago`;
return `${Math.floor(diff / 86400_000)} d ago`;
}
+2 -2
View File
@@ -23,8 +23,8 @@ interface SettingsTab {
const tabs: SettingsTab[] = [
{ 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' },
+1 -1
View File
@@ -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' },
@@ -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 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
{profile.personality?.trim() && (
<Wand2 className="h-3.5 w-3.5 text-accent" />
)}
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
@@ -1197,16 +1197,16 @@ export function ProfileForm() {
name="personality"
render={({ field }) => (
<FormItem>
<FormLabel>Personality</FormLabel>
<FormLabel>{t('profileForm.fields.personalityLabel')}</FormLabel>
<FormControl>
<Textarea
placeholder="Optional. Who this voice is and how they talk. E.g. &quot;a grumpy pirate who only speaks in nautical metaphors&quot;. Used by Compose, Rewrite, and the Speak API."
placeholder={t('profileForm.fields.personalityPlaceholder')}
className="min-h-[96px]"
{...field}
/>
</FormControl>
<FormDescription>
Leave blank to hide the Compose and Rewrite buttons on the generate page.
{t('profileForm.fields.personalityHint')}
</FormDescription>
<FormMessage />
</FormItem>
+357 -1
View File
@@ -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 <path>System Settings → Privacy & Security → Accessibility</path> to paste transcriptions into other apps. Your dictation still lands in the Captures tab without it.",
"openSettings": "Open Settings",
"recheck": "I've enabled it",
"rechecking": "Checking…",
"stillMissing": "Still not detected. macOS usually requires quitting and reopening Voicebox after toggling the permission."
},
"inputMonitoring": {
"title": "Grant Input Monitoring to enable the global shortcut",
"body": "Voicebox needs <path>System Settings → Privacy & Security → Input Monitoring</path> to detect your dictation chord. The toggle is on, but macOS is blocking key events until you allow it.",
"openSettings": "Open Settings",
"recheck": "I've enabled it",
"rechecking": "Checking…",
"stillMissing": "Still not detected. macOS usually requires quitting and reopening Voicebox after toggling the permission."
}
}
},
"voicesTab": {
"title": "Voices",
"loading": "Loading voices…",
@@ -125,7 +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 <code>X-Voicebox-Client-Id</code>.",
"lastSeen": "last seen {{when}}",
"lastSeenTitle": "Last seen {{when}}",
"neverConnected": "never connected",
"defaultOption": "(default)",
"removeAria": "Remove binding for {{client}}",
"add": {
"title": "Add a binding",
"clientIdPlaceholder": "client id (e.g. claude-code)",
"labelPlaceholder": "label (optional)",
"action": "Add binding"
}
},
"sidebar": {
"aboutTitle": "About MCP",
"aboutBody": "Model Context Protocol lets your AI coding agent — Claude Code, Cursor, Windsurf — call Voicebox tools. Speak in a cloned voice, transcribe audio, browse captures.",
"toolsTitle": "Available tools",
"tools": {
"speak": "Speak text in a voice profile.",
"transcribe": "Whisper STT on a clip.",
"listCaptures": "Recent dictations / recordings.",
"listProfiles": "Available voice profiles."
},
"postSpeak": "Also exposed as <code>POST /speak</code> for shell scripts, ACP, A2A."
}
},
"gpu": {
"cpuOnly": "CPU Only",
"vramUsed": "{{mb}} MB VRAM",
+357 -1
View File
@@ -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 に <path>「システム設定」→「プライバシーとセキュリティ」→「アクセシビリティ」</path> の許可が必要です。許可がなくてもディクテーションはキャプチャタブに保存されます。",
"openSettings": "設定を開く",
"recheck": "有効にしました",
"rechecking": "確認中…",
"stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
},
"inputMonitoring": {
"title": "グローバルショートカットを有効にするため入力監視の権限を付与してください",
"body": "ディクテーションのコードを検出するには、Voicebox に <path>「システム設定」→「プライバシーとセキュリティ」→「入力監視」</path> の許可が必要です。トグルは有効ですが、許可されるまで macOS がキーイベントをブロックしています。",
"openSettings": "設定を開く",
"recheck": "有効にしました",
"rechecking": "確認中…",
"stillMissing": "まだ検出されません。macOS では権限を切り替えた後、Voicebox を終了して再起動する必要があります。"
}
}
},
"voicesTab": {
"title": "ボイス",
"loading": "ボイスを読み込み中…",
@@ -125,7 +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": "バインディングはまだありません。下から追加し、対応する <code>X-Voicebox-Client-Id</code> を送信するように MCP クライアントを設定してください。",
"lastSeen": "最終接続 {{when}}",
"lastSeenTitle": "最終接続 {{when}}",
"neverConnected": "未接続",
"defaultOption": "(デフォルト)",
"removeAria": "{{client}} のバインディングを削除",
"add": {
"title": "バインディングを追加",
"clientIdPlaceholder": "クライアント ID(例:claude-code)",
"labelPlaceholder": "ラベル(任意)",
"action": "バインディングを追加"
}
},
"sidebar": {
"aboutTitle": "MCP について",
"aboutBody": "Model Context Protocol を使うと、Claude Code、Cursor、Windsurf などの AI コーディングエージェントから Voicebox のツールを呼び出せます。クローンしたボイスで発話したり、音声を文字起こししたり、キャプチャを参照したりできます。",
"toolsTitle": "利用可能なツール",
"tools": {
"speak": "ボイスプロファイルでテキストを発話します。",
"transcribe": "クリップに対して Whisper STT を実行します。",
"listCaptures": "最近のディクテーション/録音。",
"listProfiles": "利用可能なボイスプロファイル。"
},
"postSpeak": "シェルスクリプト、ACP、A2A 用に <code>POST /speak</code> としても公開されています。"
}
},
"gpu": {
"cpuOnly": "CPU のみ",
"vramUsed": "VRAM 使用量 {{mb}} MB",
+357 -1
View File
@@ -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 需要在 <path>系统设置 → 隐私与安全性 → 辅助功能</path> 中获得权限,才能将转录粘贴到其他应用。即使没有此权限,听写仍会保存到「捕获」标签页。",
"openSettings": "打开设置",
"recheck": "我已启用",
"rechecking": "检查中…",
"stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
},
"inputMonitoring": {
"title": "授予「输入监控」权限以启用全局快捷键",
"body": "Voicebox 需要在 <path>系统设置 → 隐私与安全性 → 输入监控</path> 中获得权限,才能检测您的听写组合键。开关已开启,但在您允许之前 macOS 会拦截按键事件。",
"openSettings": "打开设置",
"recheck": "我已启用",
"rechecking": "检查中…",
"stillMissing": "仍未检测到。切换权限后,macOS 通常需要退出并重新打开 Voicebox。"
}
}
},
"voicesTab": {
"title": "声音",
"loading": "加载声音中…",
@@ -125,7 +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 客户端配置为发送匹配的 <code>X-Voicebox-Client-Id</code>。",
"lastSeen": "最后活跃 {{when}}",
"lastSeenTitle": "最后活跃 {{when}}",
"neverConnected": "从未连接",
"defaultOption": "(默认)",
"removeAria": "移除 {{client}} 的绑定",
"add": {
"title": "添加绑定",
"clientIdPlaceholder": "客户端 ID(例如 claude-code)",
"labelPlaceholder": "标签(可选)",
"action": "添加绑定"
}
},
"sidebar": {
"aboutTitle": "关于 MCP",
"aboutBody": "Model Context Protocol 让您的 AI 编程代理——Claude Code、Cursor、Windsurf——可以调用 Voicebox 工具。以克隆的声音朗读、转录音频、浏览捕获。",
"toolsTitle": "可用工具",
"tools": {
"speak": "用声音档案朗读文本。",
"transcribe": "对音频片段运行 Whisper 转录。",
"listCaptures": "最近的听写 / 录制。",
"listProfiles": "可用的声音档案。"
},
"postSpeak": "也以 <code>POST /speak</code> 暴露,可用于 shell 脚本、ACP、A2A。"
}
},
"gpu": {
"cpuOnly": "仅 CPU",
"vramUsed": "{{mb}} MB 显存",
+357 -1
View File
@@ -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 需要 <path>系統設定 → 私隱與安全性 → 輔助使用</path> 才能將轉錄文字貼到其他 App。即使沒有此權限,口述內容仍會出現在「擷取」分頁。",
"openSettings": "開啟設定",
"recheck": "我已啟用",
"rechecking": "檢查中…",
"stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
},
"inputMonitoring": {
"title": "授予輸入監控權限以啟用全域快捷鍵",
"body": "Voicebox 需要 <path>系統設定 → 私隱與安全性 → 輸入監控</path> 才能偵測您的口述組合鍵。功能已開啟,但 macOS 在您允許前會封鎖按鍵事件。",
"openSettings": "開啟設定",
"recheck": "我已啟用",
"rechecking": "檢查中…",
"stillMissing": "仍未偵測到。macOS 通常需要在切換權限後結束並重新開啟 Voicebox。"
}
}
},
"voicesTab": {
"title": "聲音",
"loading": "載入聲音中…",
@@ -125,7 +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 客戶端設定為傳送對應的 <code>X-Voicebox-Client-Id</code>。",
"lastSeen": "最後出現於 {{when}}",
"lastSeenTitle": "最後出現於 {{when}}",
"neverConnected": "從未連線",
"defaultOption": "(預設)",
"removeAria": "移除 {{client}} 的綁定",
"add": {
"title": "新增綁定",
"clientIdPlaceholder": "客戶端 ID(例如 claude-code)",
"labelPlaceholder": "標籤(選填)",
"action": "新增綁定"
}
},
"sidebar": {
"aboutTitle": "關於 MCP",
"aboutBody": "Model Context Protocol 讓您的 AI 程式碼代理——Claude Code、Cursor、Windsurf——可以呼叫 Voicebox 工具。以複製的聲音說話、轉錄音訊、瀏覽擷取。",
"toolsTitle": "可用工具",
"tools": {
"speak": "以聲音檔案說出文字。",
"transcribe": "對片段執行 Whisper STT。",
"listCaptures": "近期口述 / 錄音。",
"listProfiles": "可用的聲音檔案。"
},
"postSpeak": "也提供 <code>POST /speak</code> 介面,供 shell 指令稿、ACP、A2A 使用。"
}
},
"gpu": {
"cpuOnly": "僅 CPU",
"vramUsed": "{{mb}} MB 顯示記憶體",
+4 -13
View File
@@ -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<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
@@ -140,16 +141,6 @@ class ApiClient {
});
}
async rewriteWithPersonality(
profileId: string,
text: string,
): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/rewrite`, {
method: 'POST',
body: JSON.stringify({ text }),
});
}
async addProfileSample(
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<MCPClientBindingListResponse> {
return this.request<MCPClientBindingListResponse>('/mcp/bindings');
}
+6 -4
View File
@@ -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 {
+4
View File
@@ -29,6 +29,7 @@ const generationSchema = z.object({
'kokoro',
])
.optional(),
personality: z.boolean().optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -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) {
+10
View File
@@ -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<string, string> = {
qwen: 'Qwen',
luxtts: 'LuxTTS',