feat(capture): dictation, personalities, 0.5.0

Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-22 18:49:16 -07:00
co-authored by Claude Opus 4.7
parent ed2eec591a
commit 87c582ad54
84 changed files with 11043 additions and 512 deletions
+22
View File
@@ -1,11 +1,13 @@
import { RouterProvider } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { apiClient } from '@/lib/api/client';
import type { HealthResponse } from '@/lib/api/types';
import { useChordSync } from '@/lib/hooks/useChordSync';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
@@ -13,6 +15,11 @@ import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import { useServerStore } from '@/stores/serverStore';
function isDictateView(): boolean {
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).get('view') === 'dictate';
}
/**
* Validate that a health response has the expected Voicebox-specific shape.
* Prevents misidentifying an unrelated service on the same port.
@@ -64,6 +71,17 @@ const LOADING_MESSAGES = [
];
function App() {
// The dictate window runs in a separate Tauri webview that must skip
// server bootstrap (the main window owns that lifecycle) and render only
// the floating recording surface. Split into a sibling component so the
// main app's hooks are not called on the dictate path.
if (isDictateView()) {
return <DictateWindow />;
}
return <MainApp />;
}
function MainApp() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
@@ -73,6 +91,10 @@ function App() {
// Automatically check for app updates on startup and show toast notifications
useAutoUpdater({ checkOnMount: true, showToast: true });
// Replay the saved chord into the Rust hotkey listener every time
// capture_settings resolves or the user edits the chord.
useChordSync();
// Sync stored setting to Rust on startup
useEffect(() => {
if (platform.metadata.isTauri) {
@@ -0,0 +1,127 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Accessibility permission state. Without this permission the
* global chord can still record, but the synthetic-⌘V paste silently drops —
* so callers can surface an inline prompt instead of relying on the
* system-level permission dialog (which only fires once, the first time the
* app tries to post a keystroke).
*
* Triggered on three signals:
* - app mount in Tauri
* - `system:accessibility-missing` event from the dictate window's paste
* failure handler
* - window focus (cheap way to re-check after the user flips the toggle in
* System Settings and alt-tabs back)
*/
export function useAccessibilityPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_accessibility_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[accessibility] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
let unlisten: UnlistenFn | null = null;
listen('system:accessibility-missing', () => {
setNeedsPermission(true);
})
.then((fn) => {
unlisten = fn;
})
.catch(() => {});
return () => {
if (unlisten) unlisten();
};
}, [platform.metadata.isTauri]);
const openSettings = useCallback(async () => {
try {
await invoke('open_accessibility_settings');
} catch (err) {
console.warn('[accessibility] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered next to the auto-paste setting when macOS
* Accessibility permission is missing. Returns null when the permission is
* already granted.
*/
export function AccessibilityNotice() {
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
Grant Accessibility permission to enable auto-paste
</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.
</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
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? 'Checking…' : "I've enabled it"}
</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.
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,190 @@
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
/**
* Pill state machine shared between the settings preview and the live
* recording pill in the Captures tab.
*/
export type PillState =
| 'recording'
| 'transcribing'
| 'refining'
| 'completed'
| 'rest'
| 'error';
const PILL_LABELS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'Recording',
transcribing: 'Transcribing',
refining: 'Refining',
completed: 'Done',
};
function barModeFor(
state: Exclude<PillState, 'error'>,
): 'generating' | 'playing' | 'idle' {
if (state === 'recording') return 'playing';
if (state === 'completed' || state === 'rest') return 'idle';
return 'generating';
}
export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) {
return (
<div className="flex items-center gap-[2px] h-5 shrink-0">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', mode === 'idle' ? 'bg-accent/30' : 'bg-accent')}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
/**
* Floating pill shown during capture. `state` drives the label, dot animation,
* and bar motion; `elapsedMs` freezes at whatever the caller last passed in
* (recording advances the timer, transcribing/refining hold the final value).
* The ``error`` state renders a destructive variant — a clickable pill that
* copies its message to the clipboard on press and calls ``onDismiss``.
*/
export function CapturePill({
state,
elapsedMs,
onStop,
errorMessage,
onDismiss,
className,
}: {
state: PillState;
elapsedMs: number;
onStop?: () => void;
errorMessage?: string | null;
onDismiss?: () => void;
className?: string;
}) {
if (state === 'error') {
return (
<ErrorPill
message={errorMessage ?? 'Something went wrong'}
onDismiss={onDismiss}
className={className}
/>
);
}
const visible = state !== 'rest';
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
const barMode = barModeFor(state);
const dot = (
<span className="relative flex h-2 w-2 shrink-0">
{state === 'recording' && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
)}
<span className="relative rounded-full h-2 w-2 bg-accent" />
</span>
);
const stopButton = onStop && state === 'recording' ? (
<button
type="button"
onClick={onStop}
aria-label="Stop recording"
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{dot}
</button>
) : dot;
// Completed gets an inset accent stroke (via box-shadow, not Tailwind's
// ring — ring utility doesn't compose with arbitrary shadow-[…]) to mark
// the success moment without changing the pill's dimensions.
const completedStroke =
state === 'completed'
? 'shadow-[inset_0_0_0_2px_hsl(var(--accent)/0.6)]'
: null;
return (
<div
className={cn(
'inline-flex items-center gap-3 px-4 h-10 rounded-full',
'bg-black/55 backdrop-blur-md text-accent',
completedStroke,
'transition-opacity duration-300 ease-out',
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
className,
)}
>
{stopButton}
<span className="text-sm font-medium shrink-0" style={{ minWidth: '104px' }}>
{labelText}
</span>
<PillAudioBars mode={barMode} />
<span className="text-xs tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
{formatElapsed(elapsedMs)}
</span>
</div>
);
}
function ErrorPill({
message,
onDismiss,
className,
}: {
message: string;
onDismiss?: () => void;
className?: string;
}) {
const handleClick = async () => {
try {
await navigator.clipboard.writeText(message);
} catch {
// Clipboard access can be denied in rare webview configs — ignore,
// we still want the dismiss to land.
}
onDismiss?.();
};
return (
<button
type="button"
onClick={handleClick}
title="Click to copy error"
className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-black/65 backdrop-blur-md text-red-300',
'max-w-[380px] hover:bg-black/80 transition-colors',
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
className,
)}
>
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm font-medium truncate">{message}</span>
</button>
);
}
@@ -0,0 +1,768 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import {
Captions,
Check,
ChevronDown,
CircleDot,
Copy,
FileAudio,
Loader2,
Mic,
Play,
Send,
Settings2,
Sparkles,
Square,
Trash2,
Upload,
Volume2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
VoiceProfileResponse,
} from '@/lib/api/types';
import type { LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
const diffMs = Date.now() - then;
const mins = Math.round(diffMs / 60_000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
const days = Math.round(hrs / 24);
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return new Date(iso).toLocaleDateString();
}
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function snippetOf(capture: CaptureResponse): string {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || '(no transcript)';
}
function SourceBadge({ source }: { source: CaptureSource }) {
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label = source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
return (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
>
<Icon className="h-2.5 w-2.5" />
{label}
</Badge>
);
}
function FakeWaveform({ seed = 1, className }: { seed?: number; className?: string }) {
const bars = useMemo(() => {
return Array.from({ length: 72 }).map((_, i) => {
const h =
28 +
Math.sin(i * 0.35 + seed) * 22 +
Math.cos(i * 0.81 + seed * 2) * 14 +
Math.sin(i * 1.7 + seed * 3) * 8;
return Math.max(6, Math.min(96, h));
});
}, [seed]);
return (
<div className={cn('flex items-center gap-[2px] h-10', className)}>
{bars.map((h, i) => (
<div
key={i}
className="w-[3px] rounded-full bg-foreground/25"
style={{ height: `${h}%` }}
/>
))}
</div>
);
}
type PlaybackState = 'idle' | 'generating' | 'playing';
function voiceGradient(profileId: string): string {
const gradients = [
'from-blue-400 to-indigo-500',
'from-emerald-400 to-teal-500',
'from-purple-500 to-fuchsia-500',
'from-amber-400 to-rose-500',
'from-rose-400 to-pink-500',
'from-cyan-400 to-sky-500',
];
let hash = 0;
for (let i = 0; i < profileId.length; i++) hash = (hash * 31 + profileId.charCodeAt(i)) | 0;
return gradients[Math.abs(hash) % gradients.length];
}
export function CapturesTab() {
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
const [playAsVoiceId, setPlayAsVoiceId] = useState<string | null>(null);
const [playbackState, setPlaybackState] = useState<PlaybackState>('idle');
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const audioUrl = usePlayerStore((s) => s.audioUrl);
const isPlayerVisible = !!audioUrl;
const { settings: captureSettings } = useCaptureSettings();
const sttModel = captureSettings?.stt_model ?? 'turbo';
const llmModel = captureSettings?.llm_model ?? '0.6B';
const session = useCaptureRecordingSession({
onCaptureCreated: (capture) => setSelectedId(capture.id),
});
const { data: capturesData, isLoading: capturesLoading } = useQuery({
queryKey: ['captures'],
queryFn: () => apiClient.listCaptures(200, 0),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const captures = capturesData?.items ?? [];
// Keep a selection. If the current selection disappears (e.g. deletion),
// fall through to the first capture, then to null.
useEffect(() => {
if (!captures.length) {
if (selectedId !== null) setSelectedId(null);
return;
}
if (!selectedId || !captures.find((c) => c.id === selectedId)) {
setSelectedId(captures[0].id);
}
}, [captures, selectedId]);
// Default the Play-as voice to the first profile we see.
useEffect(() => {
if (!playAsVoiceId && profiles && profiles.length) {
setPlayAsVoiceId(profiles[0].id);
}
}, [profiles, playAsVoiceId]);
// Live sync from sibling Tauri webviews (the floating dictate window).
// ``capture:created`` carries the full row so we can seed the cache before
// the refetch lands and focus the new capture in one shot — without the
// seed, the selection-guard effect would snap back to ``captures[0]`` in
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
const capture = event.payload?.capture;
if (capture) {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
});
setSelectedId(capture.id);
}
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
unlistens.push(
listen('capture:updated', () => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return captures;
return captures.filter((c) => {
const raw = (c.transcript_raw || '').toLowerCase();
const refined = (c.transcript_refined || '').toLowerCase();
return raw.includes(q) || refined.includes(q);
});
}, [search, captures]);
const selected = captures.find((c) => c.id === selectedId) ?? null;
const playAsVoice = profiles?.find((p) => p.id === playAsVoiceId) ?? null;
const deleteMutation = useMutation({
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' });
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error('Capture has no transcript yet');
const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| undefined;
const result = await apiClient.generateSpeech({
profile_id: voice.id,
text,
language,
engine,
});
return { capture, voice, result };
},
onSuccess: ({ capture, voice, result }) => {
if (result.audio_path && result.id) {
setAudioWithAutoPlay(
apiClient.getAudioUrl(result.id),
result.id,
voice.id,
`${voice.name} · ${capture.id.slice(0, 8)}`,
);
setPlaybackState('playing');
}
},
onError: (err: Error) => {
setPlaybackState('idle');
toast({ title: 'Play-as failed', description: err.message, variant: 'destructive' });
},
});
// Pull playback state back to idle when the player closes out.
useEffect(() => {
if (!audioUrl) setPlaybackState('idle');
}, [audioUrl]);
const handleUploadClick = () => uploadInputRef.current?.click();
const handleUploadFile = (e: React.ChangeEvent<HTMLInputElement>, source: CaptureSource) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
session.uploadFile(file, source);
};
const handlePlayOriginal = () => {
if (!selected) return;
setAudioWithAutoPlay(
apiClient.getCaptureAudioUrl(selected.id),
`capture-${selected.id}`,
null,
`Capture · ${formatDate(selected.created_at)}`,
);
};
const handleCopy = async () => {
if (!selected) return;
const text = showRefined
? selected.transcript_refined || selected.transcript_raw
: selected.transcript_raw;
try {
await navigator.clipboard.writeText(text || '');
toast({ title: 'Transcript copied' });
} catch {
toast({ title: 'Copy failed', variant: 'destructive' });
}
};
const handlePlayAs = (voice?: VoiceProfileResponse) => {
if (!selected) return;
const target = voice ?? playAsVoice;
if (!target) {
toast({
title: 'No voice profile',
description: 'Create a voice profile before using Play as.',
variant: 'destructive',
});
return;
}
if (voice && voice.id !== playAsVoiceId) setPlayAsVoiceId(voice.id);
setPlaybackState('generating');
playAsMutation.mutate({ capture: selected, voice: target });
};
return (
<div className="h-full flex gap-0 overflow-hidden -mx-8">
<input
ref={uploadInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
<input
ref={fileInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
{/* Left: capture list */}
<div className="w-[340px] shrink-0 flex flex-col relative overflow-hidden border-r border-border">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center gap-2 mb-5">
<h1 className="text-2xl px-4 font-bold">Captures</h1>
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
Beta
</Badge>
</div>
<div className="relative">
<Input
placeholder="Search transcripts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
<div
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden pt-24',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground space-y-3">
{search ? (
<p>No captures match "{search}"</p>
) : (
<>
<p>No captures yet.</p>
<Button variant="outline" size="sm" onClick={handleUploadClick}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import audio
</Button>
</>
)}
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatRelative(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
Refined
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</div>
</div>
{/* Right: capture detail */}
<div className="flex-1 flex flex-col relative overflow-hidden min-w-0">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Top action bar */}
<div className="absolute top-0 left-0 right-0 z-20 px-8">
<div className="flex items-center gap-3 py-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span>
Whisper {sttModel.charAt(0).toUpperCase() + sttModel.slice(1)}
<span className="mx-1.5 text-muted-foreground/40">·</span>
Qwen3 · {llmModel}
</span>
</div>
<div className="flex-1" />
{session.pillState !== 'hidden' && (
<CapturePill
state={session.pillState}
elapsedMs={session.pillElapsedMs}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
)}
{session.pillState === 'hidden' && (
<>
<Button variant="outline" asChild>
<Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" />
Configure
</Link>
</Button>
<Button
variant="outline"
onClick={handleUploadClick}
disabled={session.isUploading}
>
{session.isUploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? 'Uploading...' : 'Import'}
</Button>
</>
)}
<Button
onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording}
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
>
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
Dictate
</>
)}
</Button>
</div>
</div>
{selected ? (
<div
className={cn(
'flex-1 overflow-y-auto pt-20 px-8 pb-8',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatDate(selected.created_at)}</span>
{selected.language && (
<>
<span className="text-muted-foreground/40">·</span>
<span>{selected.language.toUpperCase()}</span>
</>
)}
<span className="text-muted-foreground/40">·</span>
<SourceBadge source={selected.source} />
</div>
{/* Audio player card */}
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
<div className="flex items-center gap-4">
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full shrink-0"
onClick={handlePlayOriginal}
>
<Play className="h-4 w-4 ml-0.5" />
</Button>
<FakeWaveform
seed={selected.id.charCodeAt(0)}
className="flex-1"
/>
<span className="text-xs tabular-nums text-muted-foreground font-medium">
{formatDuration(selected.duration_ms)}
</span>
</div>
</div>
{/* Transcript header */}
<div className="flex items-center gap-3 mb-3">
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
<button
type="button"
onClick={() => setShowRefined(true)}
disabled={!selected.transcript_refined}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
showRefined && selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground disabled:opacity-40',
)}
>
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
Refined
</button>
<button
type="button"
onClick={() => setShowRefined(false)}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
!showRefined || !selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
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}`
: selected.stt_model
? `Transcribed with Whisper ${selected.stt_model}`
: null}
</span>
</div>
{/* Transcript body */}
<div className="rounded-xl border border-border bg-muted/10">
<Textarea
key={`${selected.id}-${showRefined}`}
defaultValue={
showRefined && selected.transcript_refined
? selected.transcript_refined
: selected.transcript_raw
}
readOnly
className="text-[15px] leading-relaxed min-h-[260px] border-0 bg-transparent resize-none focus-visible:ring-0 focus-visible:ring-offset-0 p-6"
/>
</div>
{/* Bottom actions */}
<div className="flex items-center gap-2 mt-4 flex-wrap">
<div className="inline-flex">
<Button
variant="outline"
size="sm"
onClick={() => handlePlayAs()}
disabled={!playAsVoice || playAsMutation.isPending}
className={cn(
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15',
)}
>
{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
</>
) : playbackState === 'playing' ? (
<>
<Square className="h-3 w-3 fill-current" />
Stop · {playAsVoice?.name ?? 'Voice'}
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5" />
{playAsVoice ? `Play as ${playAsVoice.name}` : 'Play as…'}
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'rounded-l-none px-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 bg-accent/10 hover:bg-accent/15',
)}
disabled={!profiles || !profiles.length}
>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Play transcript as
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="gap-2.5 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">
{v.description || v.language.toUpperCase()}
</div>
</div>
{v.id === playAsVoiceId && (
<Check className="h-3.5 w-3.5 text-accent shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</Button>
<Button
variant="outline"
size="sm"
onClick={() => session.refine(selected.id)}
disabled={session.isRefining}
>
{session.isRefining ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
{selected.transcript_refined ? 'Re-refine' : 'Refine'}
</Button>
<Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" />
Send to
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => deleteMutation.mutate(selected.id)}
disabled={deleteMutation.isPending}
className="text-muted-foreground hover:text-destructive"
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
Delete
</Button>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
{capturesLoading ? (
<p className="text-sm">Loading captures</p>
) : captures.length ? (
<p className="text-sm">Pick a capture to see the transcript.</p>
) : (
<>
<p className="text-sm">No captures yet.</p>
<Button variant="outline" size="sm" onClick={handleUploadClick}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import audio
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,207 @@
import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
canonicalKeyFromEvent,
displayLabelForKey,
modifierSideHint,
sortChordKeys,
} from '@/lib/utils/keyCodes';
import { cn } from '@/lib/utils/cn';
interface ChordPickerProps {
open: boolean;
/** Title shown in the modal — caller picks "push-to-talk" vs "toggle". */
title: string;
description?: string;
/** The chord currently saved, shown as the starting state. */
initialKeys: string[];
onSave: (keys: string[]) => void;
onCancel: () => void;
}
/**
* Modal that captures a key chord from the browser keyboard. Tracks the
* peak set of keys held during the session so the user can release
* before clicking Save (otherwise they'd be saving while still holding
* the shortcut, which is awkward).
*
* Browser limitation: we can only capture keys while Voicebox has key
* focus, so the picker pulls focus to a hidden capture surface inside
* the dialog. The actual chord runs through the Rust global hook —
* this picker only writes the configuration the hook reads.
*/
export function ChordPicker({
open,
title,
description,
initialKeys,
onSave,
onCancel,
}: ChordPickerProps) {
// Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value.
const [pressed, setPressed] = useState<Set<string>>(new Set());
const [captured, setCaptured] = useState<string[]>(initialKeys);
const [unsupportedAttempt, setUnsupportedAttempt] = useState<string | null>(null);
const captureRef = useRef<HTMLDivElement>(null);
// Reset every time the modal re-opens — otherwise the previous picker
// session's peak set leaks into the next open and confuses the user.
useEffect(() => {
if (open) {
setPressed(new Set());
setCaptured(initialKeys);
setUnsupportedAttempt(null);
// Defer focus to the next paint so the dialog is mounted.
const t = window.setTimeout(() => captureRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}
return;
}, [open, initialKeys]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Esc reaches the dialog's onOpenChange and closes the modal — let
// it pass through unmodified.
if (event.key === 'Escape') return;
// Tab cycles focus inside the dialog; capturing it would trap the
// user. Same for the dialog's own keyboard interactions.
if (event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) {
setUnsupportedAttempt(event.code || event.key || 'unknown');
event.preventDefault();
return;
}
event.preventDefault();
event.stopPropagation();
setUnsupportedAttempt(null);
setPressed((prev) => {
if (prev.has(canonical)) return prev;
const next = new Set(prev);
next.add(canonical);
// Update peak whenever the live set grows. Comparing against
// the captured chord (which may be the previous saved value)
// would lose the user's first new keypress.
setCaptured((prevCaptured) => {
const candidate = sortChordKeys(Array.from(next));
return candidate.length >= prevCaptured.length ? candidate : prevCaptured;
});
return next;
});
},
[],
);
const handleKeyUp = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) return;
event.preventDefault();
setPressed((prev) => {
if (!prev.has(canonical)) return prev;
const next = new Set(prev);
next.delete(canonical);
return next;
});
}, []);
// Wire global listeners only while open. Capture phase so Voicebox's
// own command palette / global shortcuts don't swallow the chord first.
useEffect(() => {
if (!open) return;
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
};
}, [open, handleKeyDown, handleKeyUp]);
const displayKeys = pressed.size > 0
? sortChordKeys(Array.from(pressed))
: captured;
const canSave = captured.length > 0;
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<div
ref={captureRef}
tabIndex={-1}
className="rounded-lg border border-border bg-muted/30 p-6 outline-none focus:ring-2 focus:ring-accent"
>
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? 'Capturing…' : 'Press your shortcut'}
</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
</span>
) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />)
)}
</div>
{unsupportedAttempt ? (
<p className="text-xs text-destructive">
"{unsupportedAttempt}" isn't supported in chords. Try a modifier
or letter key.
</p>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ChordKey({ name }: { name: string }) {
const side = modifierSideHint(name);
return (
<span
className={cn(
'relative inline-flex items-center justify-center h-8 min-w-[2rem] px-2',
'rounded-md border border-border bg-background font-mono text-sm font-medium',
'shadow-sm text-foreground',
)}
>
{displayLabelForKey(name)}
{side ? (
<span className="absolute -top-1 -right-1 h-3.5 min-w-[0.875rem] px-0.5 rounded-sm bg-accent text-[8px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
}
@@ -0,0 +1,113 @@
import { invoke } from '@tauri-apps/api/core';
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useRef } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
* Mounted when the URL contains ``?view=dictate``. The main window bypasses
* this branch and renders the full app shell.
*
* The pill is driven entirely by the global chord / toggle shortcut — there
* is no fallback button here because the window is only visible while a
* capture cycle is in flight.
*/
export function DictateWindow() {
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
const prevHtml = document.documentElement.style.background;
const prevBody = document.body.style.background;
document.documentElement.style.background = 'transparent';
document.body.style.background = 'transparent';
return () => {
document.documentElement.style.background = prevHtml;
document.body.style.background = prevBody;
};
}, []);
// Snapshot of the focused UI element at chord-start, shipped over from
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
// the 12 s transcribe + refine window — the paste only fires once the
// final text comes back.
const focusRef = useRef<FocusSnapshot | null>(null);
const session = useCaptureRecordingSession({
onFinalText: async (text, _capture, allowAutoPaste) => {
const focus = focusRef.current;
// Consume-once: a second chord before this fires would overwrite
// focusRef, but nulling it here guards against the late-arriving
// refine-result firing a paste after the user has moved on.
focusRef.current = null;
if (!allowAutoPaste) return;
if (!focus || !text.trim()) return;
try {
await invoke('paste_final_text', { text, focus });
} catch (err) {
// Surface accessibility failures to the main window so it can prompt
// the user to grant permission. Other errors stay swallowed —
// the transcription still landed in the captures list.
const msg = err instanceof Error ? err.message : String(err);
if (/accessibility/i.test(msg)) {
emit('system:accessibility-missing').catch(() => {});
}
console.warn('[dictate] paste_final_text failed:', err);
}
},
});
// Route the chord events emitted from Rust into the session hook. Using a
// ref so the `listen` effect only subscribes once — rebinding every render
// would thrash the Tauri event bridge.
const sessionRef = useRef(session);
sessionRef.current = session;
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
focusRef.current = event.payload?.focus ?? null;
sessionRef.current.startRecording();
}),
);
unlistens.push(
listen('dictate:stop', () => {
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, []);
// When the pill cycle ends, tell Rust to tuck the window away. The Rust
// side is responsible for the hide + park-off-screen + click-through
// combo because calling hide() directly from JS has been unreliable for
// transparent always-on-top windows on macOS. Showing is the reverse —
// the HotkeyMonitor restores position, clicks, and visibility when a
// chord next fires.
useEffect(() => {
if (session.pillState === 'hidden') {
emit('dictate:hide').catch(() => {});
}
}, [session.pillState]);
return (
<div
className="h-screen w-screen flex items-center justify-center px-3"
style={{ background: 'transparent' }}
>
{session.pillState !== 'hidden' ? (
<CapturePill
state={session.pillState}
elapsedMs={session.pillElapsedMs}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
import { Loader2, Mic } from 'lucide-react';
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';
@@ -20,6 +21,8 @@ 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';
import { useProfile } from '@/lib/hooks/useProfiles';
@@ -41,6 +44,7 @@ function getEngineSelectValue(engine: string): string {
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { toast } = useToast();
const { form, handleSubmit, isPending } = useGenerationForm();
@@ -63,6 +67,51 @@ export function GenerationForm() {
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>
@@ -118,6 +167,52 @@ export function GenerationForm() {
)}
/>
{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}
@@ -1,116 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore';
export function GenerationSettings() {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
<CardHeader>
<CardTitle>Generation Settings</CardTitle>
<CardDescription>
Controls for long text generation. These settings apply to all engines.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="normalizeAudio"
className="text-sm font-medium leading-none cursor-pointer"
>
Normalize audio
</label>
<p className="text-sm text-muted-foreground">
Adjusts output volume to a consistent level across generations.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -83,6 +83,12 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
'whisper-turbo':
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
'qwen3-0.6b':
'Qwen3 0.6B — smallest of the Qwen3 instruct family. Very fast on CPU, runs at ~400 MB quantized on Apple Silicon. Good for dictation refinement and short completions.',
'qwen3-1.7b':
'Qwen3 1.7B — balanced size and quality. Handles subtle self-corrections and technical vocabulary better than the 0.6B. Runs at ~1.1 GB quantized on Apple Silicon.',
'qwen3-4b':
'Qwen3 4B — highest quality local refinement and longer-form reasoning. ~2.5 GB quantized on Apple Silicon, ~8 GB at full precision on PyTorch.',
};
function formatDownloads(n: number): string {
@@ -411,11 +417,13 @@ export function ModelManagement() {
m.model_name.startsWith('kokoro'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
const llmModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen3-')) ?? [];
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
{ label: t('models.sections.transcription'), models: whisperModels },
{ label: t('models.sections.languageModels'), models: llmModels },
];
// Get detail modal state for selected model
@@ -0,0 +1,571 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Toggle } from '@/components/ui/toggle';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
import { SettingRow, SettingSection } from './SettingRow';
const VOICE_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',
];
function voiceGradient(voiceId: string): string {
// Stable hash so the same voice always renders with the same gradient —
// avoids the avatar flicker that would happen if we picked by index.
let hash = 0;
for (let i = 0; i < voiceId.length; i += 1) {
hash = (hash * 31 + voiceId.charCodeAt(i)) | 0;
}
return VOICE_GRADIENTS[Math.abs(hash) % VOICE_GRADIENTS.length];
}
function ChordPreview({ keys }: { keys: string[] }) {
if (keys.length === 0) {
return <span className="text-xs text-muted-foreground italic">Not set</span>;
}
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
const PILL_DURATIONS: Partial<Record<PillState, number>> = {
recording: 2600,
transcribing: 1500,
refining: 1500,
rest: 900,
};
function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
const [state, setState] = useState<PillState>('recording');
const [tick, setTick] = useState(0);
// Cycle recording → transcribing → refining → rest → …
useEffect(() => {
const t = window.setTimeout(() => {
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
setState(next);
}, PILL_DURATIONS[state] ?? 1000);
return () => window.clearTimeout(t);
}, [state]);
// Timer only advances while recording; holds its final value through
// transcribing and refining so users see the duration of the clip being
// processed.
useEffect(() => {
if (state !== 'recording') return;
setTick(0);
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
return () => window.clearInterval(iv);
}, [state]);
const elapsedMs = tick * 90;
return (
<div
className={cn(
'relative rounded-xl border overflow-hidden transition-opacity',
'bg-muted/30',
'aspect-[6/1]',
enabled ? 'border-border' : 'border-border/50 opacity-50',
)}
style={{
backgroundImage: `
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px)
`,
backgroundSize: '22px 22px',
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<CapturePill state={state} elapsedMs={elapsedMs} />
</div>
</div>
);
}
export function CapturesPage() {
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const sttModel = settings?.stt_model ?? 'turbo';
const language = settings?.language ?? 'auto';
const autoRefine = settings?.auto_refine ?? true;
const llmModel = settings?.llm_model ?? '0.6B';
const smartCleanup = settings?.smart_cleanup ?? true;
const selfCorrection = settings?.self_correction ?? true;
const preserveTechnical = settings?.preserve_technical ?? true;
const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? ['MetaRight', 'AltGr'];
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? ['MetaRight', 'AltGr', 'Space'];
// Mock-only settings — not yet wired to a backend. Keep local so the UI
// still responds while Phase 7 (hotkey / clipboard / paste) catches up.
const [archiveAudio, setArchiveAudio] = useState(true);
const [hotkeyEnabled, setHotkeyEnabled] = useState(true);
const [copyToClipboard, setCopyToClipboard] = useState(true);
const [retention, setRetention] = useState('forever');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
const voices: VoiceProfileResponse[] = profiles ?? [];
const defaultVoice =
voices.find((v) => v.id === defaultVoiceId) ?? null;
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title="Dictation"
description="Capture from anywhere on your machine with a global shortcut."
>
<SettingRow
title="Global shortcut"
description="Hold the shortcut to record. Release to transcribe. Requires an Accessibility permission the first time you enable it."
htmlFor="hotkeyEnabled"
action={
<Toggle id="hotkeyEnabled" checked={hotkeyEnabled} onCheckedChange={setHotkeyEnabled} />
}
/>
<SettingRow
title="Push-to-talk shortcut"
description="Hold these keys anywhere on your system to record. Release to stop and transcribe."
action={
<div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('push')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
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."
action={
<div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('toggle')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
Change
</Button>
</div>
}
/>
<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."
initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_push_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<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."
initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_toggle_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<SettingRow
title="Preview"
description="What appears on screen while you're holding the shortcut."
>
<HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow>
<SettingRow
title="Copy transcript to clipboard"
description="The cleaned transcript lands on your clipboard when the capture finishes."
htmlFor="copyToClipboard"
action={
<Toggle
id="copyToClipboard"
checked={copyToClipboard}
onCheckedChange={setCopyToClipboard}
disabled={!hotkeyEnabled}
/>
}
/>
<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."
htmlFor="autoPaste"
action={
<Toggle
id="autoPaste"
checked={allowAutoPaste}
onCheckedChange={(v) => update({ allow_auto_paste: v })}
disabled={!hotkeyEnabled}
/>
}
/>
<AccessibilityNotice />
</div>
</SettingSection>
<SettingSection
title="Transcription"
description="Pick which speech-to-text model runs on your captures."
>
<SettingRow
title="Transcription model"
description="Whisper ships with Voicebox and runs entirely on your machine."
action={
<Select
value={sttModel}
onValueChange={(v) => update({ stt_model: v as WhisperModelSize })}
>
<SelectTrigger className="w-[300px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">Whisper Base · 74M · Fast</SelectItem>
<SelectItem value="small">Whisper Small · 244M · Balanced</SelectItem>
<SelectItem value="medium">
Whisper Medium · 769M · Higher accuracy
</SelectItem>
<SelectItem value="large">
Whisper Large · 1.5B · Best accuracy
</SelectItem>
<SelectItem value="turbo">
Whisper Turbo · Pruned Large v3 · Near-best, fast
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Language"
description="Auto-detect works for most captures. Lock it if you're always speaking the same language."
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>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Archive audio"
description="Keep the original recording alongside every transcript."
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."
>
<SettingRow
title="Refine transcripts automatically"
description="Runs after every capture. You can still toggle between raw and refined in the Captures tab."
htmlFor="autoRefine"
action={
<Toggle
id="autoRefine"
checked={autoRefine}
onCheckedChange={(v) => update({ auto_refine: v })}
/>
}
/>
<SettingRow
title="Refinement model"
description="Larger models are slower but handle subtle self-corrections and technical vocabulary better."
action={
<Select
value={llmModel}
onValueChange={(v) => update({ llm_model: v as Qwen3ModelSize })}
disabled={!autoRefine}
>
<SelectTrigger className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.6B">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>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Smart cleanup"
description="Remove filler words (um, uh, like), restore punctuation, and fix capitalization without rephrasing."
htmlFor="smartCleanup"
action={
<Toggle
id="smartCleanup"
checked={smartCleanup}
onCheckedChange={(v) => update({ smart_cleanup: v })}
disabled={!autoRefine}
/>
}
/>
<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.'}
htmlFor="selfCorrection"
action={
<Toggle
id="selfCorrection"
checked={selfCorrection}
onCheckedChange={(v) => update({ self_correction: v })}
disabled={!autoRefine}
/>
}
/>
<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."
htmlFor="preserveTechnical"
action={
<Toggle
id="preserveTechnical"
checked={preserveTechnical}
onCheckedChange={(v) => update({ preserve_technical: v })}
disabled={!autoRefine}
/>
}
/>
</SettingSection>
<SettingSection
title="Playback"
description='Default voice for the "Play as" action in the Captures tab.'
>
<SettingRow
title="Default voice"
description="Used when you click Play as without picking a voice first. You can change it per capture."
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 min-w-[220px] justify-between"
disabled={voices.length === 0}
>
<div className="flex items-center gap-2 min-w-0">
{defaultVoice ? (
<>
<div
className={cn(
'h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(defaultVoice.id),
)}
/>
<span className="truncate">{defaultVoice.name}</span>
</>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0 ? 'No cloned voices yet' : 'None selected'}
</span>
)}
</div>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Cloned voices
</DropdownMenuLabel>
<DropdownMenuSeparator />
{voices.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => update({ default_playback_voice_id: v.id })}
className="gap-2.5 py-2"
>
<div
className={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>
{v.description ? (
<div className="text-[11px] text-muted-foreground truncate">
{v.description}
</div>
) : null}
</div>
{v.id === defaultVoiceId && <Check className="h-3.5 w-3.5 text-accent shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
}
/>
</SettingSection>
<SettingSection
title="Storage"
description="Captures are saved as paired audio and transcript files in your Voicebox data directory."
>
<SettingRow
title="Retention"
description="How long to keep captures. Applies to both audio and transcripts."
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>
</SelectContent>
</Select>
}
/>
<SettingRow
title="Clear all captures"
description="Permanently delete every capture and its audio. This cannot be undone."
action={
<Button
variant="outline"
size="sm"
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
</Button>
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About Captures</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.
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">What's different</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>
</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.
</span>{' '}
Transcripts can be read back in any profile you've cloned.
</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.
</span>{' '}
Same shortcut, same flow on macOS, Windows, and Linux.
</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
+59 -14
View File
@@ -1,9 +1,10 @@
import { FolderOpen } from 'lucide-react';
import { FolderOpen, Languages, Mic, Zap } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Toggle } from '@/components/ui/toggle';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
@@ -12,14 +13,11 @@ export function GenerationPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
const { settings, update } = useGenerationSettings();
const maxChunkChars = settings?.max_chunk_chars ?? 800;
const crossfadeMs = settings?.crossfade_ms ?? 50;
const normalizeAudio = settings?.normalize_audio ?? true;
const autoplayOnGenerate = settings?.autoplay_on_generate ?? true;
const [opening, setOpening] = useState(false);
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
@@ -48,7 +46,8 @@ export function GenerationPage() {
}, [platform, generationsPath]);
return (
<div className="space-y-8 max-w-2xl">
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title={t('settings.generation.title')}
description={t('settings.generation.description')}
@@ -65,7 +64,7 @@ export function GenerationPage() {
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
onValueChange={([value]) => update({ max_chunk_chars: value })}
min={100}
max={5000}
step={50}
@@ -87,7 +86,7 @@ export function GenerationPage() {
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
onValueChange={([value]) => update({ crossfade_ms: value })}
min={0}
max={200}
step={10}
@@ -103,7 +102,7 @@ export function GenerationPage() {
<Toggle
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
onCheckedChange={(v) => update({ normalize_audio: v })}
/>
}
/>
@@ -116,7 +115,7 @@ export function GenerationPage() {
<Toggle
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
/>
}
/>
@@ -137,6 +136,52 @@ export function GenerationPage() {
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About voice generation</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Clone a voice from a short sample, then generate speech in any voice
across any language. Ship TTS into AI agents, games, podcasts, or
long-form narration.
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">What's different</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Mic className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Clone any voice in seconds.
</span>{' '}
A few seconds of reference audio is enough. Multi-sample support
for higher quality when you want it.
</span>
</li>
<li className="flex gap-2.5">
<Languages className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
Seven engines, 23 languages.
</span>{' '}
Pick the tradeoff that fits — quality, speed, or multilingual
coverage.
</span>
</li>
<li className="flex gap-2.5">
<Zap className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Agent-ready.</span>{' '}
REST API with per-profile control — give any AI a voice you've
cloned.
</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
+5 -2
View File
@@ -6,10 +6,12 @@ import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface SettingsTab {
labelKey: string;
labelKey?: string;
label?: string;
path:
| '/settings'
| '/settings/generation'
| '/settings/captures'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
@@ -20,6 +22,7 @@ interface SettingsTab {
const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ label: 'Captures', path: '/settings/captures' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
@@ -54,7 +57,7 @@ export function SettingsLayout() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
)}
>
{t(tab.labelKey)}
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
</Link>
);
})}
+1 -1
View File
@@ -14,7 +14,7 @@ export function SettingSection({
}) {
return (
<div className="space-y-1">
{title && <h3 className="text-sm font-medium">{title}</h3>}
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
{children}
+11 -5
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
@@ -13,12 +13,18 @@ interface SidebarProps {
isMacOS?: boolean;
}
const tabs = [
const tabs: Array<{
id: string;
path: string;
icon: LucideIcon;
labelKey?: string;
label?: string;
}> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'captures', path: '/captures', icon: Captions, label: 'Captures' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'audio', path: '/audio', icon: Speaker, labelKey: 'nav.audio' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
@@ -74,8 +80,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground hover:bg-muted/50',
)}
title={t(tab.labelKey)}
aria-label={t(tab.labelKey)}
title={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
>
{isActive && (
<div
@@ -77,6 +77,7 @@ function makeProfileSchema(t: (key: string) => string) {
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
personality: z.string().max(2000).optional(),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
@@ -100,6 +101,7 @@ type ProfileFormValues = {
name: string;
description?: string;
language: LanguageCode;
personality?: string;
sampleFile?: File;
referenceText?: string;
avatarFile?: File;
@@ -166,6 +168,7 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
@@ -331,6 +334,7 @@ export function ProfileForm() {
name: editingProfile.name,
description: editingProfile.description || '',
language: editingProfile.language as LanguageCode,
personality: editingProfile.personality || '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -344,6 +348,7 @@ export function ProfileForm() {
name: profileFormDraft.name,
description: profileFormDraft.description,
language: profileFormDraft.language as LanguageCode,
personality: profileFormDraft.personality || '',
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
avatarFile: undefined,
@@ -368,6 +373,7 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -493,6 +499,7 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
},
});
@@ -558,6 +565,7 @@ export function ProfileForm() {
preset_engine: selectedPresetEngine,
preset_voice_id: selectedPresetVoiceId,
default_engine: selectedPresetEngine,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Handle avatar upload if provided
@@ -654,6 +662,7 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
@@ -756,6 +765,7 @@ export function ProfileForm() {
name: values.name || '',
description: values.description || '',
language: values.language || 'en',
personality: values.personality || '',
referenceText: values.referenceText || '',
sampleMode,
};
@@ -1182,6 +1192,27 @@ export function ProfileForm() {
)}
/>
<FormField
control={form.control}
name="personality"
render={({ field }) => (
<FormItem>
<FormLabel>Personality</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."
className="min-h-[96px]"
{...field}
/>
</FormControl>
<FormDescription>
Leave blank to hide the Compose and Rewrite buttons on the generate page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
+2 -1
View File
@@ -752,7 +752,8 @@
"unknownSize": "Unknown size",
"sections": {
"voiceGeneration": "Voice Generation",
"transcription": "Transcription"
"transcription": "Transcription",
"languageModels": "Language Models"
},
"status": {
"loaded": "Loaded"
+2 -1
View File
@@ -752,7 +752,8 @@
"unknownSize": "サイズ不明",
"sections": {
"voiceGeneration": "音声生成",
"transcription": "文字起こし"
"transcription": "文字起こし",
"languageModels": "言語モデル"
},
"status": {
"loaded": "読み込み済み"
+2 -1
View File
@@ -752,7 +752,8 @@
"unknownSize": "未知大小",
"sections": {
"voiceGeneration": "语音生成",
"transcription": "语音转录"
"transcription": "语音转录",
"languageModels": "语言模型"
},
"status": {
"loaded": "已加载"
+2 -1
View File
@@ -752,7 +752,8 @@
"unknownSize": "未知大小",
"sections": {
"voiceGeneration": "語音生成",
"transcription": "語音轉錄"
"transcription": "語音轉錄",
"languageModels": "語言模型"
},
"status": {
"loaded": "已載入"
+122
View File
@@ -18,6 +18,7 @@ import type {
ModelDownloadRequest,
ModelStatusListResponse,
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
StoryCreate,
StoryDetailResponse,
@@ -34,6 +35,16 @@ import type {
VoiceProfileCreate,
VoiceProfileResponse,
WhisperModelSize,
CaptureListResponse,
CaptureResponse,
CaptureCreateResponse,
CaptureRefineRequest,
CaptureRetranscribeRequest,
CaptureSettings,
CaptureSettingsUpdate,
CaptureSource,
GenerationSettings,
GenerationSettingsUpdate,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -115,6 +126,26 @@ 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.
async composeWithPersonality(profileId: string): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
method: 'POST',
});
}
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,
@@ -381,6 +412,97 @@ class ApiClient {
return response.json();
}
// Captures
async listCaptures(limit = 50, offset = 0): Promise<CaptureListResponse> {
return this.request<CaptureListResponse>(
`/captures?limit=${limit}&offset=${offset}`,
);
}
async getCapture(captureId: string): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}`);
}
async createCapture(
file: File,
options?: {
source?: CaptureSource;
language?: LanguageCode;
sttModel?: WhisperModelSize;
},
): Promise<CaptureCreateResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('source', options?.source ?? 'file');
if (options?.language) formData.append('language', options.language);
if (options?.sttModel) formData.append('stt_model', options.sttModel);
const url = `${this.getBaseUrl()}/captures`;
const response = await fetch(url, { method: 'POST', body: formData });
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
}
return response.json();
}
async deleteCapture(captureId: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/captures/${captureId}`, {
method: 'DELETE',
});
}
async refineCapture(
captureId: string,
body: CaptureRefineRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/refine`, {
method: 'POST',
body: JSON.stringify(body),
});
}
async retranscribeCapture(
captureId: string,
body: CaptureRetranscribeRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/retranscribe`, {
method: 'POST',
body: JSON.stringify(body),
});
}
getCaptureAudioUrl(captureId: string): string {
return `${this.getBaseUrl()}/captures/${captureId}/audio`;
}
// Settings
async getCaptureSettings(): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures');
}
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
async getGenerationSettings(): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation');
}
async updateGenerationSettings(
patch: GenerationSettingsUpdate,
): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
// Model Management
async getModelStatus(): Promise<ModelStatusListResponse> {
return this.request<ModelStatusListResponse>('/models/status');
+98
View File
@@ -12,6 +12,8 @@ export interface VoiceProfileCreate {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
/** Free-form character prompt used by compose / rewrite / respond / speak. */
personality?: string;
}
export interface VoiceProfileResponse {
@@ -26,12 +28,19 @@ export interface VoiceProfileResponse {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
personality?: string | null;
generation_count: number;
sample_count: number;
created_at: string;
updated_at: string;
}
/** Response returned by /profiles/{id}/compose | /rewrite | /respond. */
export interface PersonalityTextResponse {
text: string;
model_size: string;
}
export interface PresetVoice {
voice_id: string;
name: string;
@@ -127,6 +136,95 @@ export interface HistoryListResponse {
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
export type Qwen3ModelSize = '0.6B' | '1.7B' | '4B';
export type CaptureSource = 'dictation' | 'recording' | 'file';
/**
* Snapshot of the accessibility-focused UI element at chord-start. Emitted
* from Rust as part of the ``dictate:start`` payload so the frontend can
* pass it back to ``paste_final_text`` once the final text is ready.
*/
export interface FocusSnapshot {
pid: number;
bundle_id: string | null;
role: string | null;
}
export interface RefinementFlags {
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
}
export interface CaptureResponse {
id: string;
audio_path: string;
source: CaptureSource;
language?: string | null;
duration_ms?: number | null;
transcript_raw: string;
transcript_refined?: string | null;
stt_model?: string | null;
llm_model?: string | null;
refinement_flags?: RefinementFlags | null;
created_at: string;
}
export interface CaptureListResponse {
items: CaptureResponse[];
total: number;
}
/**
* Response of ``POST /captures``. Adds ``auto_refine`` and ``allow_auto_paste``
* the server's current settings captured at request time so the client
* can decide whether to chain a refine call and whether to fire the
* synthetic-paste pipeline without relying on its own (possibly stale) copy
* of capture_settings.
*/
export interface CaptureCreateResponse extends CaptureResponse {
auto_refine: boolean;
allow_auto_paste: boolean;
}
export interface CaptureRefineRequest {
flags?: RefinementFlags;
model_size?: Qwen3ModelSize;
}
export interface CaptureRetranscribeRequest {
model?: WhisperModelSize;
language?: LanguageCode;
}
export interface CaptureSettings {
stt_model: WhisperModelSize;
language: string;
auto_refine: boolean;
llm_model: Qwen3ModelSize;
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
allow_auto_paste: boolean;
default_playback_voice_id: string | null;
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr"]. */
chord_push_to_talk_keys: string[];
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr","Space"]. */
chord_toggle_to_talk_keys: string[];
}
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
export interface GenerationSettings {
max_chunk_chars: number;
crossfade_ms: number;
normalize_audio: boolean;
autoplay_on_generate: boolean;
}
export type GenerationSettingsUpdate = Partial<GenerationSettings>;
export interface TranscriptionRequest {
language?: LanguageCode;
model?: WhisperModelSize;
+11 -5
View File
@@ -8,7 +8,7 @@ interface UseAudioRecordingOptions {
}
export function useAudioRecording({
maxDurationSeconds = 29,
maxDurationSeconds,
onRecordingComplete,
}: UseAudioRecordingOptions = {}) {
const platform = usePlatform();
@@ -124,8 +124,11 @@ export function useAudioRecording({
console.error('MediaRecorder error:', event);
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
// started with a timeslice, so concatenated blobs fail to parse in
// both AudioContext and ffmpeg. Starting with no timeslice produces
// exactly one dataavailable on stop() with a valid container.
mediaRecorder.start();
setIsRecording(true);
startTimeRef.current = Date.now();
@@ -135,8 +138,11 @@ export function useAudioRecording({
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration
if (elapsed >= maxDurationSeconds) {
// Auto-stop at max duration when the caller opts in — dictation
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to
// keep reference clips short.
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
@@ -0,0 +1,328 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { emit as tauriEmit } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PillState } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
} from '@/lib/api/types';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
/**
* Broadcast to sibling Tauri webviews that the captures list has changed.
* The main CapturesTab listens, seeds its React Query cache, and focuses the
* new row, so uploads from the floating dictate window show up live.
*
* ``capture:created`` carries the full response so the sibling can seed its
* cache before the refetch lands otherwise the selection-guard effect
* would snap back to ``captures[0]`` in the race window between
* ``setSelectedId(new)`` and the list actually containing the new row.
*
* No-op in web mode there are no siblings to notify.
*/
function broadcastCreated(capture: CaptureResponse) {
tauriEmit('capture:created', { capture }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
function broadcastUpdated(id: string) {
tauriEmit('capture:updated', { id }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
const REST_FADE_MS = 900;
// How long the green "Done" pill stays visible after refine (or transcribe,
// when auto-refine is off) completes, before the fade-out begins.
const COMPLETED_DWELL_MS = 2000;
// Long enough to read a full backend stack message and click-to-copy.
const ERROR_PILL_VISIBLE_MS = 6000;
// Short self-explanatory notices (e.g. "Recording too short, canceled") —
// there's nothing to read or copy, so clear out quickly.
const BRIEF_NOTICE_MS = 2000;
// MediaRecorder.start(100) emits its first chunk ~100ms in, but the webm
// container header isn't guaranteed to be finalised that quickly — anything
// under half a second tends to produce a blob neither AudioContext.decode
// nor ffmpeg will accept. Caught client-side and surfaced as a friendly
// "Recording too short, canceled" pill instead of bubbling up a 400.
const MIN_RECORDING_DURATION_S = 0.5;
const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
export type CapturePillState = PillState | 'hidden';
export interface UseCaptureRecordingSessionOptions {
/**
* Fired after a capture row is created on the server. Callers can use this
* to select the new capture or emit a Tauri event to a sibling window.
*/
onCaptureCreated?: (capture: CaptureResponse) => void;
/**
* Fired with the final delivered text refined if ``auto_refine`` was on
* for this capture, raw transcript otherwise. Used by the floating
* dictate window to hand the text off to the Rust auto-paste pipeline.
*
* ``allowAutoPaste`` snapshots the setting at chord-start so a refine that
* lands after the user flips the toggle still uses the value the capture
* was created under.
*/
onFinalText?: (
text: string,
capture: CaptureResponse,
allowAutoPaste: boolean,
) => void;
}
export interface UseCaptureRecordingSessionResult {
pillState: CapturePillState;
pillElapsedMs: number;
errorMessage: string | null;
isRecording: boolean;
isUploading: boolean;
isRefining: boolean;
startRecording: () => void;
stopRecording: () => void;
toggleRecording: () => void;
dismissError: () => void;
uploadFile: (file: File, source: CaptureSource) => void;
refine: (captureId: string) => void;
}
/**
* Owns the full record transcribe refine rest lifecycle behind the
* capture pill. The pill component and the Dictate/Stop button are the only
* consumers; everything else (cache seeding, error toasts, settings reads) is
* internal so the hook can be reused from a floating Tauri window without the
* containing tab.
*/
export function useCaptureRecordingSession(
options: UseCaptureRecordingSessionOptions = {},
): UseCaptureRecordingSessionResult {
const queryClient = useQueryClient();
// Every capture setting is resolved server-side. ``stt_model``,
// ``llm_model`` and refine flags are read from the capture_settings table
// inside POST /captures and /captures/*/refine, and ``auto_refine`` comes
// back on the create response so the client decides whether to chain a
// refine call using a value that can't go stale across sibling webviews.
const [pillState, setPillState] = useState<CapturePillState>('hidden');
const [frozenElapsedMs, setFrozenElapsedMs] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const restTimerRef = useRef<number | null>(null);
const errorTimerRef = useRef<number | null>(null);
// Mutation callbacks close over stale pillState otherwise.
const pillStateRef = useRef<CapturePillState>('hidden');
pillStateRef.current = pillState;
const onCaptureCreatedRef = useRef(options.onCaptureCreated);
onCaptureCreatedRef.current = options.onCaptureCreated;
const onFinalTextRef = useRef(options.onFinalText);
onFinalTextRef.current = options.onFinalText;
// Snapshot of ``allow_auto_paste`` from the capture-create response —
// held so the refine onSuccess (which only sees the plain CaptureResponse)
// can still pass the original setting through to onFinalText.
const allowAutoPasteRef = useRef<boolean>(true);
const clearRestTimer = useCallback(() => {
if (restTimerRef.current !== null) {
window.clearTimeout(restTimerRef.current);
restTimerRef.current = null;
}
}, []);
const clearErrorTimer = useCallback(() => {
if (errorTimerRef.current !== null) {
window.clearTimeout(errorTimerRef.current);
errorTimerRef.current = null;
}
}, []);
const scheduleHidePill = useCallback(() => {
clearRestTimer();
setPillState('completed');
// Two-hop timer: show the green "Done" pill for COMPLETED_DWELL_MS,
// then hand off to the existing rest-fade before unmounting.
restTimerRef.current = window.setTimeout(() => {
setPillState('rest');
restTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
restTimerRef.current = null;
}, REST_FADE_MS);
}, COMPLETED_DWELL_MS);
}, [clearRestTimer]);
const showError = useCallback(
(message: string, durationMs: number = ERROR_PILL_VISIBLE_MS) => {
clearRestTimer();
clearErrorTimer();
setErrorMessage(message || 'Something went wrong');
setPillState('error');
errorTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
setErrorMessage(null);
errorTimerRef.current = null;
}, durationMs);
},
[clearRestTimer, clearErrorTimer],
);
const dismissError = useCallback(() => {
clearErrorTimer();
setPillState('hidden');
setErrorMessage(null);
}, [clearErrorTimer]);
useEffect(
() => () => {
clearRestTimer();
clearErrorTimer();
},
[clearRestTimer, clearErrorTimer],
);
const refineMutation = useMutation({
// Empty body — backend resolves flags and model from capture_settings.
mutationFn: async (captureId: string) => apiClient.refineCapture(captureId, {}),
onSuccess: (data, captureId) => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastUpdated(captureId);
if (pillStateRef.current === 'refining') scheduleHidePill();
const finalText = data.transcript_refined ?? data.transcript_raw;
if (finalText) {
onFinalTextRef.current?.(finalText, data, allowAutoPasteRef.current);
}
},
onError: (err: Error) => {
showError(err.message || 'Refinement failed');
},
});
const uploadMutation = useMutation({
mutationFn: async ({ file, source }: { file: File; source: CaptureSource }) =>
apiClient.createCapture(file, { source }),
onSuccess: (capture) => {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
});
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastCreated(capture);
onCaptureCreatedRef.current?.(capture);
allowAutoPasteRef.current = capture.allow_auto_paste;
if (capture.auto_refine) {
setPillState('refining');
refineMutation.mutate(capture.id);
} else {
if (pillStateRef.current === 'transcribing') scheduleHidePill();
if (capture.transcript_raw) {
onFinalTextRef.current?.(
capture.transcript_raw,
capture,
capture.allow_auto_paste,
);
}
}
},
onError: (err: Error) => {
// Backend's librosa-audioread fallback returns a 400 with this shape
// for tiny/corrupt webm blobs that slip past the client guard —
// translate it to the same friendly message so the user sees one
// consistent cause, not an opaque decode error.
const msg = err.message || '';
if (/could not decode/i.test(msg) || /empty or corrupt/i.test(msg)) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
} else {
showError(msg || 'Upload failed');
}
},
});
const {
isRecording,
duration,
startRecording: beginAudioRecording,
stopRecording,
error: recordError,
} = useAudioRecording({
onRecordingComplete: (blob, recordedDuration) => {
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
// so the blob is empty or unparseable. Surface it as a transient pill
// so the user sees their recording was recognised and canceled.
if (!blob.size || (recordedDuration ?? 0) < MIN_RECORDING_DURATION_S) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
return;
}
setFrozenElapsedMs(Math.round((recordedDuration ?? 0) * 1000));
setPillState('transcribing');
const extension = blob.type.includes('wav')
? 'wav'
: blob.type.includes('webm')
? 'webm'
: 'bin';
const file = new File([blob], `dictation-${Date.now()}.${extension}`, {
type: blob.type,
});
uploadMutation.mutate({ file, source: 'dictation' });
},
});
useEffect(() => {
if (recordError) {
showError(recordError);
}
}, [recordError, showError]);
const startRecording = useCallback(() => {
if (isRecording) return;
clearRestTimer();
setFrozenElapsedMs(0);
setPillState('recording');
beginAudioRecording();
}, [isRecording, beginAudioRecording, clearRestTimer]);
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording();
return;
}
startRecording();
}, [isRecording, startRecording, stopRecording]);
const uploadFile = useCallback(
(file: File, source: CaptureSource) => {
uploadMutation.mutate({ file, source });
},
[uploadMutation],
);
const refine = useCallback(
(captureId: string) => {
refineMutation.mutate(captureId);
},
[refineMutation],
);
const pillElapsedMs =
pillState === 'recording' ? Math.round(duration * 1000) : frozenElapsedMs;
return {
pillState,
pillElapsedMs,
errorMessage,
isRecording,
isUploading: uploadMutation.isPending,
isRefining: refineMutation.isPending,
startRecording,
stopRecording,
toggleRecording,
dismissError,
uploadFile,
refine,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect } from 'react';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Push the user's saved chord into the running Rust `HotkeyMonitor`.
* The monitor boots with hard-coded right-hand defaults; this hook
* replaces them as soon as capture_settings resolves and re-applies on
* every subsequent change so chord edits land without a restart.
*
* Call once from the main app shell multiple call sites would just
* fire redundant invokes, since the chord engine swap is the same value
* either way.
*/
export function useChordSync() {
const platform = usePlatform();
const { settings } = useCaptureSettings();
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (!pushKeys || !toggleKeys) return;
invoke('update_chord_bindings', {
pushToTalk: pushKeys,
toggleToTalk: toggleKeys,
}).catch((err) => {
console.warn('[chord-sync] failed to update bindings:', err);
});
}, [
platform.metadata.isTauri,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','),
toggleKeys?.join(','),
]);
}
+5 -4
View File
@@ -8,8 +8,8 @@ import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
@@ -43,9 +43,10 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const { settings: genSettings } = useGenerationSettings();
const maxChunkChars = genSettings?.max_chunk_chars ?? 800;
const crossfadeMs = genSettings?.crossfade_ms ?? 50;
const normalizeAudio = genSettings?.normalize_audio ?? true;
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
+3 -2
View File
@@ -2,9 +2,9 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
interface GenerationStatusEvent {
id: string;
@@ -26,7 +26,8 @@ export function useGenerationProgress() {
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
const { settings: genSettings } = useGenerationSettings();
const autoplayOnGenerate = genSettings?.autoplay_on_generate ?? true;
// Keep refs to avoid stale closures in EventSource handlers
const isPlayingRef = useRef(isPlaying);
+99
View File
@@ -0,0 +1,99 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
CaptureSettings,
CaptureSettingsUpdate,
GenerationSettings,
GenerationSettingsUpdate,
} from '@/lib/api/types';
const CAPTURE_SETTINGS_KEY = ['settings', 'captures'] as const;
const GENERATION_SETTINGS_KEY = ['settings', 'generation'] as const;
/**
* Hook for capture/refine defaults. Reads from the server and writes partial
* updates with optimistic cache mutation so toggles stay snappy while the
* PUT round-trip settles.
*/
export function useCaptureSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: CAPTURE_SETTINGS_KEY,
queryFn: () => apiClient.getCaptureSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: CaptureSettingsUpdate) => apiClient.updateCaptureSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: CAPTURE_SETTINGS_KEY });
const previous = queryClient.getQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(CAPTURE_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data) => {
if (data) queryClient.setQueryData(CAPTURE_SETTINGS_KEY, data);
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
/**
* Hook for long-form TTS generation defaults. Same optimistic pattern as
* ``useCaptureSettings``.
*/
export function useGenerationSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: GENERATION_SETTINGS_KEY,
queryFn: () => apiClient.getGenerationSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: GenerationSettingsUpdate) =>
apiClient.updateGenerationSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: GENERATION_SETTINGS_KEY });
const previous = queryClient.getQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(GENERATION_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data) => {
if (data) queryClient.setQueryData(GENERATION_SETTINGS_KEY, data);
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Stable key-name vocabulary shared with the Rust `key_codes` module.
*
* The chord persistence layer stores rdev `Key` variant names ("MetaRight",
* "AltGr", "KeyA", ) so the same array round-trips losslessly between
* the picker UI, the SQLite settings row, and the global hotkey listener.
*
* This module owns the conversions between three vocabularies:
* - browser `KeyboardEvent` (`event.code` like "MetaRight" / "AltRight")
* - canonical chord key names (matches rdev variants)
* - human display labels ("⌘", "⌥", "A", )
*/
/**
* Map a `KeyboardEvent` to the canonical key name we persist. Returns
* `null` for keys we don't support in chords (dead keys, IME composition,
* etc.).
*
* Browser quirk: right-Option on macOS is reported as `"AltRight"`; rdev
* calls it `"AltGr"`. Normalize to rdev's name so the Rust side recognizes
* it without an aliasing layer.
*/
export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
const code = event.code;
if (!code) return null;
switch (code) {
case 'AltLeft':
return 'Alt';
case 'AltRight':
return 'AltGr';
case 'BracketLeft':
return 'LeftBracket';
case 'BracketRight':
return 'RightBracket';
case 'Semicolon':
return 'SemiColon';
case 'Backslash':
return 'BackSlash';
case 'Backquote':
return 'BackQuote';
case 'Period':
return 'Dot';
case 'Enter':
return 'Return';
case 'ArrowUp':
return 'UpArrow';
case 'ArrowDown':
return 'DownArrow';
case 'ArrowLeft':
return 'LeftArrow';
case 'ArrowRight':
return 'RightArrow';
default:
// Browser names like "MetaRight", "MetaLeft", "ControlLeft",
// "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the
// rdev variant names directly.
if (
/^(Meta|Control|Shift)(Left|Right)$/.test(code) ||
/^Key[A-Z]$/.test(code) ||
/^Digit[0-9]$/.test(code) ||
/^F([1-9]|1[0-2])$/.test(code) ||
['Space', 'Tab', 'Backspace', 'Delete', 'Escape', 'Insert',
'Home', 'End', 'PageUp', 'PageDown', 'CapsLock', 'Function',
'Minus', 'Equal', 'Quote', 'Comma', 'Slash'].includes(code)
) {
return code;
}
return null;
}
}
const PLATFORM_IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
/**
* Pretty label for a canonical key name. Picks platform-appropriate
* modifier glyphs so macOS users see and Windows/Linux users see Win.
*/
export function displayLabelForKey(name: string): string {
switch (name) {
case 'MetaLeft':
case 'MetaRight':
return PLATFORM_IS_MAC ? '⌘' : 'Win';
case 'Alt':
return PLATFORM_IS_MAC ? '⌥' : 'Alt';
case 'AltGr':
return PLATFORM_IS_MAC ? '⌥' : 'AltGr';
case 'ControlLeft':
case 'ControlRight':
return PLATFORM_IS_MAC ? '⌃' : 'Ctrl';
case 'ShiftLeft':
case 'ShiftRight':
return PLATFORM_IS_MAC ? '⇧' : 'Shift';
case 'CapsLock':
return '⇪';
case 'Function':
return 'fn';
case 'Space':
return 'Space';
case 'Tab':
return '⇥';
case 'Return':
return '↵';
case 'Backspace':
return '⌫';
case 'Delete':
return '⌦';
case 'Escape':
return 'Esc';
case 'UpArrow':
return '↑';
case 'DownArrow':
return '↓';
case 'LeftArrow':
return '←';
case 'RightArrow':
return '→';
}
if (/^Key([A-Z])$/.test(name)) return name.slice(3);
if (/^Num([0-9])$/.test(name)) return name.slice(3);
if (/^F([1-9]|1[0-2])$/.test(name)) return name;
return name;
}
/**
* Side-aware suffix to disambiguate left vs right modifier variants
* the tiny "R" badge that lets a user see the chord defaults to the
* right-hand keys.
*/
export function modifierSideHint(name: string): 'L' | 'R' | null {
if (name === 'MetaRight' || name === 'AltGr' || name === 'ControlRight' || name === 'ShiftRight') {
return 'R';
}
if (name === 'MetaLeft' || name === 'Alt' || name === 'ControlLeft' || name === 'ShiftLeft') {
return 'L';
}
return null;
}
/**
* Sort a chord's keys so the kbd pills always render in a predictable
* order: modifiers first (Ctrl, Opt, Shift, Cmd), main key last. Matches
* how every macOS shortcut docs list the keys.
*/
const SORT_ORDER: Record<string, number> = {
ControlLeft: 0, ControlRight: 0,
Alt: 1, AltGr: 1,
ShiftLeft: 2, ShiftRight: 2,
MetaLeft: 3, MetaRight: 3,
Function: 4,
CapsLock: 5,
};
export function sortChordKeys(keys: string[]): string[] {
return [...keys].sort((a, b) => {
const sa = SORT_ORDER[a] ?? 99;
const sb = SORT_ORDER[b] ?? 99;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
}
+14 -6
View File
@@ -6,11 +6,12 @@ import {
redirect,
} from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { CapturesTab } from '@/components/CapturesTab/CapturesTab';
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { AboutPage } from '@/components/ServerTab/AboutPage';
import { CapturesPage } from '@/components/ServerTab/CapturesPage';
import { ChangelogPage } from '@/components/ServerTab/ChangelogPage';
import { GeneralPage } from '@/components/ServerTab/GeneralPage';
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
@@ -111,11 +112,11 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
// Audio route
const audioRoute = createRoute({
// Captures route (prototype — will replace AudioTab once the new flow is ready)
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audio',
component: AudioTab,
path: '/captures',
component: CapturesTab,
});
// Effects route
@@ -152,6 +153,12 @@ const settingsGenerationRoute = createRoute({
component: GenerationPage,
});
const settingsCapturesRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/captures',
component: CapturesPage,
});
const settingsGpuRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/gpu',
@@ -189,13 +196,14 @@ const serverRedirectRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
voicesRoute,
audioRoute,
effectsRoute,
modelsRoute,
settingsRoute.addChildren([
settingsGeneralRoute,
settingsGenerationRoute,
settingsCapturesRoute,
settingsGpuRoute,
settingsLogsRoute,
settingsChangelogRoute,
-24
View File
@@ -15,18 +15,6 @@ interface ServerStore {
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
maxChunkChars: number;
setMaxChunkChars: (value: number) => void;
crossfadeMs: number;
setCrossfadeMs: (value: number) => void;
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
@@ -60,18 +48,6 @@ export const useServerStore = create<ServerStore>()(
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
maxChunkChars: 800,
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
+1
View File
@@ -5,6 +5,7 @@ export interface ProfileFormDraft {
name: string;
description: string;
language: string;
personality: string;
referenceText: string;
sampleMode: 'upload' | 'record' | 'system';
// Note: File objects can't be persisted, so we store metadata