mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 06:10:43 -07:00
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:
co-authored by
Claude Opus 4.7
parent
ed2eec591a
commit
87c582ad54
@@ -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 & 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 1–2 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. "a grumpy pirate who only speaks in nautical metaphors". 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"
|
||||
|
||||
Reference in New Issue
Block a user