feat(voice): first-class voice input and local TTS across web, desktop, and mobile (#2018)
Complete rebuild of voice input on a server-authoritative streaming architecture, replacing the legacy Web Speech / whole-blob / WASM engines and the dead voice-agent layer (~4k lines removed). Speech-to-text (dictation): - Client streams 16 kHz mono PCM16 chunks over /api/dictation/ws with seq/ack ordering; buffered audio is retained and replayed on reconnect - Server transcribes and streams live partial transcripts back; segments auto-commit every ~15s with silence suppression and adaptive finalization timeouts - Local provider (default, zero config): sherpa-onnx models in a forked worker process — auto-download with progress, staged extraction with verification, corrupt-model auto-recovery, idle shutdown after 5 min - Model catalog with settings picker (accuracy/speed ratings, sizes, download/delete): Parakeet TDT v2 (English) and v3 (25 European languages, auto-detected), Whisper base and tiny (multilingual, light) - OpenAI-compatible provider for any Whisper endpoint - Composer overlay with live transcript, volume meter, timer, and cancel / insert / insert-and-send actions; failed transcriptions keep their audio for retry or accepting the partial text as-is - Configurable keyboard shortcut (default mod+alt+v) toggles dictation; Enter confirms and Escape cancels while recording - Overlay is pixel-aligned with the composer (measured footer height, matching paddings/typography/gaps) — no layout shift when toggling Text-to-speech: - Local Kokoro provider (English, 11 voices) synthesized in the same worker via /api/dictation/tts/speak, managed by the shared model pipeline; sentence-pipelined playback keeps time-to-first-audio at ~1 sentence regardless of message length, and stop cancels in-flight synthesis - Sanitizer keeps inline-code content (strips backticks only), reads interword slashes aloud, and removes only absolute file paths Settings: - Voice page unified: a single read-aloud toggle owns all playback options (the confusing "Enable Voice Mode" is gone); a new "Enable voice input" toggle (default on, persisted to settings.json) hides the composer mic entirely when disabled Mobile and transport: - iOS/Android microphone permissions added (dictation was previously impossible on mobile) - Fixed Android WebSocket upgrades: the Capacitor WebView origin (https://localhost) was missing from the packaged-client allowlist, 403-ing every WS connection — root cause of the old mobile SSE lock, which is now removed for all transports Security and conventions: - All HTTP routes sit behind the global /api auth gate; the WS upgrade explicitly validates the UI session and origin, with oc_url_token narrowly allowlisted and covered by tests; the dictation socket mints a fresh URL token before connecting - Routes register before the generic OpenCode proxy; the client goes through runtimeFetch/getRuntimeUrlResolver, and runtime switches reset the dictation socket - VS Code deliberately reports dictation as unavailable (no server process in that runtime) CI: workflow Node bumped 20 -> 22 to match the repo engines and fix better-sqlite3 installs broken by node-gyp@latest on Node 20. New dependency: sherpa-onnx-node (prebuilt N-API; macOS/Linux x64+arm64, Windows x64 — Windows-on-ARM falls back to the OpenAI-compatible provider)
This commit is contained in:
committed by
GitHub
parent
3f5151d424
commit
de1b85ac56
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { BrowserVoiceButton } from '@/components/voice';
|
||||
import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -381,7 +381,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
|
||||
};
|
||||
|
||||
const MemoModelControls = React.memo(ModelControls);
|
||||
const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
|
||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||
const MemoStatusRow = React.memo(StatusRow);
|
||||
@@ -2318,6 +2318,33 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
void handleSubmitRef.current();
|
||||
}, []);
|
||||
|
||||
// Dictation: insert the transcript inline; optionally submit immediately.
|
||||
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting
|
||||
// it synchronously lets handleSubmit pick up the text in the same tick.
|
||||
const handleDictationInsert = React.useCallback((text: string) => {
|
||||
setMessage((prev) => {
|
||||
const next = appendInlineText(prev, text);
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.value = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleDictationInsertAndSend = React.useCallback((text: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const next = appendInlineText(textarea?.value ?? messageRef.current, text);
|
||||
if (textarea) {
|
||||
textarea.value = next;
|
||||
}
|
||||
setMessage(next);
|
||||
void handleSubmitRef.current();
|
||||
}, []);
|
||||
|
||||
// Preset chips rendered outside this component (e.g. under the welcome
|
||||
// message on narrow surfaces) request a submit via the input store; consume
|
||||
// it here so it routes through the same command-aware submit path.
|
||||
@@ -4420,6 +4447,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Positioning context for the dictation overlay: covers the
|
||||
text area + footer exactly, excluding MobileSessionStatusBar. */}
|
||||
<div className={cn('relative flex flex-col', isDesktopExpanded && 'flex-1 min-h-0')}>
|
||||
<div className={cn("overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
|
||||
@@ -4559,7 +4589,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 flex-shrink-0">
|
||||
<MemoBrowserVoiceButton />
|
||||
<MemoComposerDictation
|
||||
radius={chatInputRadius}
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
footerPaddingClass={footerPaddingClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
sendIconSizeClass={sendIconSizeClass}
|
||||
onInsert={handleDictationInsert}
|
||||
onInsertAndSend={handleDictationInsertAndSend}
|
||||
/>
|
||||
<ComposerActionButtons
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
@@ -4614,7 +4653,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
<MemoBrowserVoiceButton />
|
||||
<MemoComposerDictation
|
||||
radius={chatInputRadius}
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
footerPaddingClass={footerPaddingClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
sendIconSizeClass={sendIconSizeClass}
|
||||
onInsert={handleDictationInsert}
|
||||
onInsertAndSend={handleDictationInsertAndSend}
|
||||
/>
|
||||
<ComposerActionButtons
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
@@ -4633,6 +4681,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */}
|
||||
{isMobile && <MobileSessionStatusBar />}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Composer dictation controls: a mic button for the composer footer plus a
|
||||
* full-composer overlay while dictation is active (recording, transcribing,
|
||||
* or failed). The overlay mirrors the composer's own layout — the transcript
|
||||
* area uses the same paddings/typography as the textarea and the action row
|
||||
* reuses the footer icon-button styling — so toggling dictation causes no
|
||||
* vertical shift.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useDictation } from '@/hooks/useDictation';
|
||||
import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
|
||||
interface ComposerDictationProps {
|
||||
radius?: number | string;
|
||||
isMobile: boolean;
|
||||
footerIconButtonClass: string;
|
||||
footerPaddingClass: string;
|
||||
iconSizeClass: string;
|
||||
sendIconSizeClass: string;
|
||||
disabled?: boolean;
|
||||
onInsert: (text: string) => void;
|
||||
onInsertAndSend: (text: string) => void;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
return (
|
||||
<div
|
||||
className="h-1.5 w-16 flex-shrink-0 overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: currentTheme.colors.interactive.border }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-75"
|
||||
style={{
|
||||
width: `${Math.round(Math.min(1, volume) * 100)}%`,
|
||||
backgroundColor: currentTheme.colors.primary.base,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Polls the dictation status route while the local model is downloading and
|
||||
* returns the download percent (null while unknown / not downloading).
|
||||
*/
|
||||
const useModelDownloadProgress = (active: boolean): number | null => {
|
||||
const sttLocalModel = useConfigStore((state) => state.sttLocalModel);
|
||||
const [percent, setPercent] = React.useState<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
setPercent(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/dictation/status', {
|
||||
query: { provider: 'local', localModel: sttLocalModel },
|
||||
});
|
||||
if (!response.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const model = Array.isArray(data?.models)
|
||||
? data.models.find((m: { id: string }) => m.id === sttLocalModel)
|
||||
: null;
|
||||
if (!cancelled) {
|
||||
setPercent(typeof model?.downloadProgress === 'number' ? model.downloadProgress : null);
|
||||
}
|
||||
} catch {
|
||||
// Display-only; keep the previous value.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const interval = setInterval(() => {
|
||||
void poll();
|
||||
}, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [active, sttLocalModel]);
|
||||
|
||||
return active ? percent : null;
|
||||
};
|
||||
|
||||
export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
radius,
|
||||
isMobile,
|
||||
footerIconButtonClass,
|
||||
footerPaddingClass,
|
||||
iconSizeClass,
|
||||
sendIconSizeClass,
|
||||
disabled,
|
||||
onInsert,
|
||||
onInsertAndSend,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const dictationEnabled = useConfigStore((state) => state.dictationEnabled);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const dictationShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_dictation', shortcutOverrides));
|
||||
// The dictation server (WebSocket + STT worker) lives in the OpenChamber
|
||||
// web server; the VS Code bridge has no server process for it.
|
||||
const [supported] = React.useState(() => !isVSCodeRuntime() && isDictationCaptureSupported());
|
||||
|
||||
const pendingActionRef = React.useRef<'insert' | 'send' | null>(null);
|
||||
const onInsertRef = React.useRef(onInsert);
|
||||
const onInsertAndSendRef = React.useRef(onInsertAndSend);
|
||||
React.useEffect(() => {
|
||||
onInsertRef.current = onInsert;
|
||||
onInsertAndSendRef.current = onInsertAndSend;
|
||||
}, [onInsert, onInsertAndSend]);
|
||||
|
||||
const dictation = useDictation({
|
||||
onTranscript: (text) => {
|
||||
const action = pendingActionRef.current;
|
||||
pendingActionRef.current = null;
|
||||
if (action === 'send') {
|
||||
onInsertAndSendRef.current(text);
|
||||
} else {
|
||||
onInsertRef.current(text);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
status,
|
||||
partialTranscript,
|
||||
volume,
|
||||
duration,
|
||||
error,
|
||||
errorReason,
|
||||
startDictation,
|
||||
confirmDictation,
|
||||
cancelDictation,
|
||||
retryFailedDictation,
|
||||
acceptPartialTranscript,
|
||||
discardFailedDictation,
|
||||
} = dictation;
|
||||
|
||||
const isModelDownloading = status === 'recording' && errorReason === 'model_download_in_progress';
|
||||
const downloadPercent = useModelDownloadProgress(isModelDownloading);
|
||||
|
||||
const statusRef = React.useRef(status);
|
||||
React.useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
|
||||
// Keyboard shortcut (toggle_dictation): idle -> start recording,
|
||||
// recording -> confirm and insert. Dispatched by useKeyboardShortcuts.
|
||||
React.useEffect(() => {
|
||||
const onToggle = () => {
|
||||
if (statusRef.current === 'idle') {
|
||||
void startDictation();
|
||||
} else if (statusRef.current === 'recording') {
|
||||
pendingActionRef.current = 'insert';
|
||||
void confirmDictation();
|
||||
}
|
||||
};
|
||||
window.addEventListener('openchamber:dictation-toggle', onToggle);
|
||||
return () => window.removeEventListener('openchamber:dictation-toggle', onToggle);
|
||||
}, [startDictation, confirmDictation]);
|
||||
|
||||
// While recording: Enter confirms (insert), Escape cancels. Capture-phase
|
||||
// so the composer's own Enter-to-send never fires underneath the overlay.
|
||||
React.useEffect(() => {
|
||||
if (status !== 'recording') {
|
||||
return;
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pendingActionRef.current = 'insert';
|
||||
void confirmDictation();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void cancelDictation();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
}, [status, confirmDictation, cancelDictation]);
|
||||
|
||||
// Pixel-parity with the composer: the real footer row is taller than our
|
||||
// buttons (its height comes from the tallest control, e.g. the model
|
||||
// picker), so measure it — it stays mounted underneath the overlay — and
|
||||
// give our action row the same height so the icons line up exactly.
|
||||
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [footerHeight, setFooterHeight] = React.useState<number | null>(null);
|
||||
const isActiveStatus = status !== 'idle';
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isActiveStatus) {
|
||||
return;
|
||||
}
|
||||
// The overlay is rendered inside the composer footer itself, so the
|
||||
// real footer is an ancestor, not a sibling.
|
||||
const realFooter = overlayRef.current?.closest<HTMLElement>('[data-chat-input-footer="true"]');
|
||||
if (realFooter && realFooter.offsetHeight > 0) {
|
||||
setFooterHeight(realFooter.offsetHeight);
|
||||
}
|
||||
}, [isActiveStatus]);
|
||||
|
||||
if (!supported || !dictationEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = status !== 'idle';
|
||||
|
||||
const confirmWith = (action: 'insert' | 'send') => {
|
||||
pendingActionRef.current = action;
|
||||
void confirmDictation();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
pendingActionRef.current = 'insert';
|
||||
void retryFailedDictation();
|
||||
};
|
||||
|
||||
const placeholderText = (() => {
|
||||
if (status === 'failed') {
|
||||
return '';
|
||||
}
|
||||
if (status === 'uploading') {
|
||||
return t('chat.dictation.processing');
|
||||
}
|
||||
if (isModelDownloading) {
|
||||
return downloadPercent !== null
|
||||
? t('chat.dictation.downloadingModelProgress', { percent: String(downloadPercent) })
|
||||
: t('chat.dictation.downloadingModel');
|
||||
}
|
||||
return t('chat.dictation.listening');
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
void startDictation();
|
||||
}}
|
||||
disabled={disabled || isActive}
|
||||
title={dictationShortcut ? `${t('chat.dictation.start')} (${dictationShortcut})` : t('chat.dictation.start')}
|
||||
aria-label={t('chat.dictation.start')}
|
||||
>
|
||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
{isActive ? (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
// overflow-x/y split on purpose: mobile.css rewrites the
|
||||
// shorthand `.overflow-hidden` to overflow-y:auto on touch
|
||||
// devices, which painted a phantom scrollbar on Android.
|
||||
className="absolute inset-0 z-50 flex flex-col overflow-x-hidden overflow-y-hidden"
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
// Must match the composer box background exactly so the
|
||||
// overlay reads as the same surface, not a layer on top.
|
||||
backgroundColor: currentTheme.colors.surface.subtle,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={t('chat.dictation.overlayAria')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
// Text paddings match the composer textarea, plus the
|
||||
// 4px (pt-1) attachment-chips row that always renders
|
||||
// above it: desktop 16+4px, mobile 10+4px from the top.
|
||||
// min-h-0 (not a fixed min height): the mobile composer
|
||||
// is shorter than 52px of text area + footer, and a
|
||||
// fixed min pushed the action row 4px below the real
|
||||
// footer. The area must shrink to whatever space the
|
||||
// underlying composer actually has.
|
||||
'flex-1 min-h-0 overflow-y-auto px-3',
|
||||
isMobile ? 'pt-3.5 pb-2.5' : 'pt-5 pb-2',
|
||||
)}
|
||||
>
|
||||
{partialTranscript ? (
|
||||
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
|
||||
{partialTranscript}
|
||||
</p>
|
||||
) : (
|
||||
<p className="typography-markdown md:typography-ui-label" style={{ color: currentTheme.colors.surface.mutedForeground }}>
|
||||
{placeholderText}
|
||||
</p>
|
||||
)}
|
||||
{status === 'failed' ? (
|
||||
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.error }}>
|
||||
{error || t('chat.dictation.failed')}
|
||||
</p>
|
||||
) : null}
|
||||
{status === 'recording' && error && !isModelDownloading ? (
|
||||
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.warning }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn('flex flex-shrink-0 items-center gap-x-3', footerPaddingClass)}
|
||||
style={footerHeight ? { height: footerHeight } : undefined}
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<>
|
||||
<span className="relative ml-1 flex h-2 w-2 flex-shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60"
|
||||
style={{ backgroundColor: currentTheme.colors.status.error }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: currentTheme.colors.status.error }}
|
||||
/>
|
||||
</span>
|
||||
<VolumeMeter volume={volume} />
|
||||
<span className="typography-meta tabular-nums" style={{ color: currentTheme.colors.surface.mutedForeground }}>
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
</>
|
||||
) : status === 'uploading' ? (
|
||||
<Icon name="loader-4" className="ml-1 h-4 w-4 animate-spin" style={{ color: currentTheme.colors.surface.mutedForeground }} />
|
||||
) : null}
|
||||
{/* Same inter-control gap as the composer's right cluster:
|
||||
gap-x-1 on mobile, md:gap-x-3 on desktop. */}
|
||||
<div className={cn('ml-auto flex items-center', isMobile ? 'gap-x-1' : 'gap-x-1.5 md:gap-x-3')}>
|
||||
{status === 'recording' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => {
|
||||
void cancelDictation();
|
||||
}}
|
||||
title={t('chat.dictation.cancel')}
|
||||
aria-label={t('chat.dictation.cancel')}
|
||||
>
|
||||
<Icon name="close" className={iconSizeClass} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => confirmWith('insert')}
|
||||
title={t('chat.dictation.insert')}
|
||||
aria-label={t('chat.dictation.insert')}
|
||||
>
|
||||
<Icon name="check" className={iconSizeClass} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, 'text-primary hover:text-primary')}
|
||||
onClick={() => confirmWith('send')}
|
||||
title={t('chat.dictation.insertAndSend')}
|
||||
aria-label={t('chat.dictation.insertAndSend')}
|
||||
>
|
||||
<Icon name="send-plane-2" className={sendIconSizeClass} />
|
||||
</button>
|
||||
</>
|
||||
) : status === 'uploading' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => {
|
||||
void cancelDictation();
|
||||
}}
|
||||
title={t('chat.dictation.cancel')}
|
||||
aria-label={t('chat.dictation.cancel')}
|
||||
>
|
||||
<Icon name="close" className={iconSizeClass} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={discardFailedDictation}
|
||||
title={t('chat.dictation.discard')}
|
||||
aria-label={t('chat.dictation.discard')}
|
||||
>
|
||||
<Icon name="close" className={iconSizeClass} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={retry}
|
||||
title={t('chat.dictation.retry')}
|
||||
aria-label={t('chat.dictation.retry')}
|
||||
>
|
||||
<Icon name="refresh" className={iconSizeClass} />
|
||||
</button>
|
||||
{partialTranscript.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
pendingActionRef.current = 'insert';
|
||||
acceptPartialTranscript();
|
||||
}}
|
||||
title={t('chat.dictation.insert')}
|
||||
aria-label={t('chat.dictation.insert')}
|
||||
>
|
||||
<Icon name="check" className={iconSizeClass} />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import type { ThemeMode } from '@/types/theme';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
@@ -322,10 +321,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions);
|
||||
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
|
||||
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
|
||||
// Capacitor apps are locked to SSE (native WebSocket streaming is unreliable on mobile);
|
||||
// sync-context forces it too. Show SSE selected and disable the other options here.
|
||||
const isCapacitorAppRuntime = React.useMemo(() => isCapacitorApp(), []);
|
||||
const effectiveMessageStreamTransport = isCapacitorAppRuntime ? 'sse' : messageStreamTransport;
|
||||
const effectiveMessageStreamTransport = messageStreamTransport;
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview);
|
||||
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
|
||||
@@ -1531,7 +1527,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={effectiveMessageStreamTransport === option.id}
|
||||
disabled={isCapacitorAppRuntime && option.id !== 'sse'}
|
||||
className="!font-normal"
|
||||
onClick={() => handleMessageStreamTransportChange(option.id)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useBrowserVoice } from '@/hooks/useBrowserVoice';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
|
||||
@@ -11,103 +10,362 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { audioStreamService } from '@/lib/voice/audioStreamService';
|
||||
import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService';
|
||||
import type { WasmModelStatus } from '@/lib/voice/wasmSttService';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useLocalTTS } from '@/hooks/useLocalTTS';
|
||||
import { disposePreviewAudio } from './voicePreviewAudio';
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'es-ES', label: 'Español' },
|
||||
{ value: 'fr-FR', label: 'Français' },
|
||||
{ value: 'de-DE', label: 'Deutsch' },
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
{ value: 'zh-CN', label: '中文' },
|
||||
{ value: 'pt-BR', label: 'Português' },
|
||||
{ value: 'it-IT', label: 'Italiano' },
|
||||
{ value: 'ko-KR', label: '한국어' },
|
||||
{ value: 'uk-UA', label: 'Українська' },
|
||||
];
|
||||
|
||||
const WasmModelStatusIndicator = ({ modelId }: { modelId: string }) => {
|
||||
const LOCAL_STT_MODELS = [
|
||||
{
|
||||
id: 'parakeet-tdt-0.6b-v2-int8',
|
||||
labelKey: 'settings.voice.page.stt.model.parakeetV2',
|
||||
badgeKey: 'settings.voice.page.stt.badge.bestForEnglish',
|
||||
accuracy: 5,
|
||||
speed: 5,
|
||||
size: '460 MB',
|
||||
},
|
||||
{
|
||||
id: 'parakeet-tdt-0.6b-v3-int8',
|
||||
labelKey: 'settings.voice.page.stt.model.parakeetV3',
|
||||
badgeKey: 'settings.voice.page.stt.badge.bestForMultilingual',
|
||||
accuracy: 5,
|
||||
speed: 4,
|
||||
size: '465 MB',
|
||||
},
|
||||
{
|
||||
id: 'whisper-base-int8',
|
||||
labelKey: 'settings.voice.page.stt.model.whisperBase',
|
||||
badgeKey: null,
|
||||
accuracy: 3,
|
||||
speed: 3,
|
||||
size: '200 MB',
|
||||
},
|
||||
{
|
||||
id: 'whisper-tiny-int8',
|
||||
labelKey: 'settings.voice.page.stt.model.whisperTiny',
|
||||
badgeKey: null,
|
||||
accuracy: 2,
|
||||
speed: 4,
|
||||
size: '115 MB',
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface DictationModelState {
|
||||
id: string;
|
||||
installed: boolean;
|
||||
downloading: boolean;
|
||||
downloadProgress: number | null;
|
||||
downloadError: string | null;
|
||||
}
|
||||
|
||||
const RatingBar = ({ value, label }: { value: number; label: string }) => (
|
||||
<span className="flex items-center gap-1.5" title={`${label}: ${value}/5`}>
|
||||
<span
|
||||
className="h-1.5 w-12 flex-shrink-0 overflow-hidden rounded-full bg-[var(--interactive-border)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="block h-full rounded-full bg-[var(--primary-base)]"
|
||||
style={{ width: `${(value / 5) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="typography-ui-compact text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const LocalModelPicker = ({
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
}: {
|
||||
selectedModelId: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<WasmModelStatus>(wasmSttService.getModelStatus());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const tUnsafe = useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const [models, setModels] = useState<Map<string, DictationModelState>>(new Map());
|
||||
const [requestingId, setRequestingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (s: WasmModelStatus) => setStatus(s);
|
||||
wasmSttService.onModelStatusChange = handler;
|
||||
return () => { wasmSttService.onModelStatusChange = null; };
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/dictation/status', {
|
||||
query: { provider: 'local' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data?.models)) {
|
||||
setModels(new Map(data.models.map((m: DictationModelState) => [m.id, m])));
|
||||
}
|
||||
} catch {
|
||||
// Display-only status; keep the previous state on fetch failure.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const currentModelId = wasmSttService.getCurrentModelId();
|
||||
const isLoadingOrDownloading = status.state === 'downloading' || status.state === 'loading';
|
||||
|
||||
// Reset local loading state when model finishes loading or errors
|
||||
useEffect(() => {
|
||||
if (status.state !== 'downloading' && status.state !== 'loading') {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status.state]);
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true);
|
||||
const anyDownloading = Array.from(models.values()).some((m) => m.downloading);
|
||||
useEffect(() => {
|
||||
if (!anyDownloading) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void refresh();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [anyDownloading, refresh]);
|
||||
|
||||
const handleDownload = async (modelId: string) => {
|
||||
setRequestingId(modelId);
|
||||
try {
|
||||
await wasmSttService.loadModel(modelId);
|
||||
await runtimeFetch(`/api/dictation/models/${encodeURIComponent(modelId)}/download`, {
|
||||
method: 'POST',
|
||||
});
|
||||
await refresh();
|
||||
} catch {
|
||||
// Error is shown via status indicator
|
||||
// Status refresh reports errors.
|
||||
} finally {
|
||||
setRequestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (status.state === 'ready' && currentModelId === modelId) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-compact text-green-600 dark:text-green-400">
|
||||
{t('settings.voice.page.stt.wasmLoaded')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleDelete = async (modelId: string) => {
|
||||
setRequestingId(modelId);
|
||||
try {
|
||||
await runtimeFetch(`/api/dictation/models/${encodeURIComponent(modelId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await refresh();
|
||||
} catch {
|
||||
// Status refresh reports errors.
|
||||
} finally {
|
||||
setRequestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrDownloading) {
|
||||
const progress = status.state === 'downloading' ? Math.round(status.progress) : undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-compact text-muted-foreground">
|
||||
{status.state === 'downloading' ? t('settings.voice.page.stt.wasmDownloading') : t('settings.voice.page.stt.wasmLoading')}
|
||||
{progress !== undefined ? ` (${progress}%)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t('settings.voice.page.field.model')} className="mt-1 space-y-0">
|
||||
{LOCAL_STT_MODELS.map((entry) => {
|
||||
const selected = selectedModelId === entry.id;
|
||||
const state = models.get(entry.id) ?? null;
|
||||
return (
|
||||
<div key={entry.id} className="flex w-full items-start gap-2 py-1.5">
|
||||
<div className="pt-0.5">
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => onSelect(entry.id)}
|
||||
ariaLabel={tUnsafe(entry.labelKey)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(entry.id)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(entry.id); } }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{tUnsafe(entry.labelKey)}
|
||||
</span>
|
||||
{entry.badgeKey ? (
|
||||
<span
|
||||
className="rounded px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-success-background)',
|
||||
color: 'var(--status-success)',
|
||||
border: '1px solid var(--status-success-border)',
|
||||
}}
|
||||
>
|
||||
{tUnsafe(entry.badgeKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-4 gap-y-0.5">
|
||||
<RatingBar value={entry.accuracy} label={t('settings.voice.page.stt.meta.accuracy')} />
|
||||
<RatingBar value={entry.speed} label={t('settings.voice.page.stt.meta.speed')} />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">{entry.size}</span>
|
||||
</div>
|
||||
{state?.downloadError ? (
|
||||
<p className="mt-0.5 typography-meta text-[var(--status-error)]">{state.downloadError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-14 flex-shrink-0 items-center justify-end gap-1 pt-0.5">
|
||||
{state?.installed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={requestingId === entry.id}
|
||||
onClick={() => { void handleDelete(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
</>
|
||||
) : state?.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requestingId === entry.id}
|
||||
onClick={() => { void handleDownload(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (status.state === 'error') {
|
||||
// Partial download (cached progress): show retry button.
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-compact text-destructive">{status.error}</span>
|
||||
<Button variant="chip" size="xs" disabled={loading} onClick={handleDownload}>
|
||||
{t('settings.voice.page.stt.wasmRetry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
/** Kokoro en_v0_19 speaker ids in sherpa-onnx order. */
|
||||
const KOKORO_VOICE_OPTIONS = [
|
||||
{ id: 0, label: 'Alloy (af)' },
|
||||
{ id: 1, label: 'Bella (af)' },
|
||||
{ id: 2, label: 'Nicole (af)' },
|
||||
{ id: 3, label: 'Sarah (af)' },
|
||||
{ id: 4, label: 'Sky (af)' },
|
||||
{ id: 5, label: 'Adam (am)' },
|
||||
{ id: 6, label: 'Michael (am)' },
|
||||
{ id: 7, label: 'Emma (bf)' },
|
||||
{ id: 8, label: 'Isabella (bf)' },
|
||||
{ id: 9, label: 'George (bm)' },
|
||||
{ id: 10, label: 'Lewis (bm)' },
|
||||
];
|
||||
|
||||
const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19';
|
||||
|
||||
const LocalTtsModelStatus = () => {
|
||||
const { t } = useI18n();
|
||||
const [model, setModel] = useState<DictationModelState | null>(null);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/dictation/status', { query: { provider: 'local' } });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const entry = Array.isArray(data?.ttsModels)
|
||||
? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID)
|
||||
: null;
|
||||
if (entry) {
|
||||
setModel(entry);
|
||||
}
|
||||
} catch {
|
||||
// Display-only status; keep the previous state on fetch failure.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model?.downloading) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void refresh();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [model?.downloading, refresh]);
|
||||
|
||||
const request = async (method: 'POST' | 'DELETE') => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
const path = method === 'POST'
|
||||
? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download`
|
||||
: `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`;
|
||||
await runtimeFetch(path, { method });
|
||||
await refresh();
|
||||
} catch {
|
||||
// Status refresh reports errors.
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-compact text-muted-foreground">
|
||||
{t('settings.voice.page.stt.wasmNotLoaded')}
|
||||
</span>
|
||||
<Button variant="chip" size="xs" disabled={loading} onClick={handleDownload}>
|
||||
{t('settings.voice.page.stt.wasmDownload')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Kokoro</span>
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">305 MB</span>
|
||||
{model.installed ? (
|
||||
<>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={requesting}
|
||||
onClick={() => { void request('DELETE'); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : model.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requesting}
|
||||
onClick={() => { void request('POST'); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{model.downloadError ? (
|
||||
<span className="typography-meta text-[var(--status-error)]">{model.downloadError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -131,11 +389,6 @@ const OPENAI_VOICE_OPTIONS = [
|
||||
export const VoiceSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const {
|
||||
isSupported,
|
||||
language,
|
||||
setLanguage,
|
||||
} = useBrowserVoice();
|
||||
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||
const setVoiceProvider = useConfigStore((state) => state.setVoiceProvider);
|
||||
const speechRate = useConfigStore((state) => state.speechRate);
|
||||
@@ -146,6 +399,22 @@ export const VoiceSettings: React.FC = () => {
|
||||
const setSpeechVolume = useConfigStore((state) => state.setSpeechVolume);
|
||||
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||
const setSayVoice = useConfigStore((state) => state.setSayVoice);
|
||||
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
|
||||
const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId);
|
||||
const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS();
|
||||
|
||||
const previewLocalVoice = useCallback(() => {
|
||||
if (isLocalTtsPlaying) {
|
||||
stopLocalTts();
|
||||
return;
|
||||
}
|
||||
const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label
|
||||
?? String(localTtsVoiceId);
|
||||
void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), {
|
||||
speakerId: localTtsVoiceId,
|
||||
speed: useConfigStore.getState().speechRate,
|
||||
});
|
||||
}, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]);
|
||||
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
|
||||
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||
@@ -172,19 +441,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
const setSttApiKey = useConfigStore((state) => state.setSttApiKey);
|
||||
const sttModel = useConfigStore((state) => state.sttModel);
|
||||
const setSttModel = useConfigStore((state) => state.setSttModel);
|
||||
const wasmSttModel = useConfigStore((state) => state.wasmSttModel);
|
||||
const setWasmSttModel = useConfigStore((state) => state.setWasmSttModel);
|
||||
const sttLocalModel = useConfigStore((state) => state.sttLocalModel);
|
||||
const setSttLocalModel = useConfigStore((state) => state.setSttLocalModel);
|
||||
const sttLanguage = useConfigStore((state) => state.sttLanguage);
|
||||
const setSttLanguage = useConfigStore((state) => state.setSttLanguage);
|
||||
const sttSilenceThresholdDb = useConfigStore((state) => state.sttSilenceThresholdDb);
|
||||
const setSttSilenceThresholdDb = useConfigStore((state) => state.setSttSilenceThresholdDb);
|
||||
const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
|
||||
const setSttSilenceHoldMs = useConfigStore((state) => state.setSttSilenceHoldMs);
|
||||
const sttTranscribeOnStop = useConfigStore((state) => state.sttTranscribeOnStop);
|
||||
const setSttTranscribeOnStop = useConfigStore((state) => state.setSttTranscribeOnStop);
|
||||
const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons);
|
||||
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||
const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
|
||||
const dictationEnabled = useConfigStore((state) => state.dictationEnabled);
|
||||
const setDictationEnabled = useConfigStore((state) => state.setDictationEnabled);
|
||||
|
||||
const [isSayAvailable, setIsSayAvailable] = useState(false);
|
||||
const [sayVoices, setSayVoices] = useState<Array<{ name: string; locale: string }>>([]);
|
||||
@@ -274,7 +537,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
}, [isBrowserPreviewPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceModeEnabled || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) {
|
||||
if (!showMessageTTSButtons || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) {
|
||||
setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
|
||||
return;
|
||||
}
|
||||
@@ -292,10 +555,10 @@ export const VoiceSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
checkOpenAIAvailability();
|
||||
}, [openaiApiKey, voiceModeEnabled, voiceProvider]);
|
||||
}, [openaiApiKey, showMessageTTSButtons, voiceProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceModeEnabled) {
|
||||
if (!showMessageTTSButtons) {
|
||||
setIsSayAvailable(false);
|
||||
setSayVoices([]);
|
||||
return;
|
||||
@@ -317,7 +580,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
.catch(() => {
|
||||
setIsSayAvailable(false);
|
||||
});
|
||||
}, [voiceModeEnabled]);
|
||||
}, [showMessageTTSButtons]);
|
||||
|
||||
const previewVoice = useCallback(async () => {
|
||||
if (previewAudio) {
|
||||
@@ -498,11 +761,11 @@ export const VoiceSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Voice Setup */}
|
||||
<div data-settings-item="voice.voice-setup" className="mb-8">
|
||||
{/* Playback (read messages aloud) */}
|
||||
<div data-settings-item="voice.playback" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.voice.page.section.voiceSetup')}
|
||||
{t('settings.voice.page.section.playbackAndSummary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -512,15 +775,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={voiceModeEnabled}
|
||||
onClick={() => setVoiceModeEnabled(!voiceModeEnabled)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
|
||||
aria-pressed={showMessageTTSButtons}
|
||||
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
|
||||
>
|
||||
<Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceModeAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceMode')}</span>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
{showMessageTTSButtons && (
|
||||
<>
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
@@ -533,6 +796,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.localTts')}</li>
|
||||
<li><strong>OpenAI:</strong> {t('settings.voice.page.tooltip.openai')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
|
||||
@@ -550,6 +814,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
>
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'local'}
|
||||
onClick={() => setVoiceProvider('local')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.local')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
@@ -706,10 +979,39 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local (Kokoro) TTS model status */}
|
||||
{voiceProvider === 'local' && <LocalTtsModelStatus />}
|
||||
|
||||
{/* Voice Selection */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.voice')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{voiceProvider === 'local' && (
|
||||
<>
|
||||
<Select
|
||||
value={String(localTtsVoiceId)}
|
||||
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
|
||||
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KOKORO_VOICE_OPTIONS.map((v) => (
|
||||
<SelectItem key={v.id} value={String(v.id)}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewLocalVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isLocalTtsPlaying ? <Icon name="stop" className="w-3.5 h-3.5" /> : <Icon name="play" className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
{localTtsError ? (
|
||||
<span className="typography-meta text-[var(--status-error)]">{localTtsError}</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'openai' && isOpenAIAvailable && (
|
||||
<>
|
||||
<Select value={openaiVoice} onValueChange={setOpenaiVoice}>
|
||||
@@ -775,7 +1077,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechRate')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -784,7 +1086,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechPitch')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -793,7 +1095,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechVolume')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />}
|
||||
{isMobile ? (
|
||||
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
|
||||
) : (
|
||||
@@ -804,20 +1106,34 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.language')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<Select value={language} onValueChange={setLanguage} disabled={!isSupported}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectLanguagePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>{lang.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* TTS input mode */}
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.voice.page.field.ttsInputMode')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'sanitized'}
|
||||
onClick={() => setTtsInputMode('sanitized')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeSanitized')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'raw'}
|
||||
onClick={() => setTtsInputMode('raw')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeRaw')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -826,8 +1142,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Speech Recognition */}
|
||||
{voiceModeEnabled && (
|
||||
<div data-settings-item="voice.speech-recognition" className="mb-8">
|
||||
<div data-settings-item="voice.speech-recognition" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.voice.page.section.speechRecognition')}
|
||||
@@ -835,6 +1150,19 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={dictationEnabled}
|
||||
onClick={() => setDictationEnabled(!dictationEnabled)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setDictationEnabled(!dictationEnabled); } }}
|
||||
>
|
||||
<Checkbox checked={dictationEnabled} onChange={setDictationEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceInputAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceInput')}</span>
|
||||
</div>
|
||||
|
||||
{dictationEnabled && (<>
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -845,7 +1173,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.sttBrowser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
@@ -855,53 +1183,34 @@ export const VoiceSettings: React.FC = () => {
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sttProvider === 'browser'}
|
||||
onClick={() => setSttProvider('browser')}
|
||||
aria-pressed={sttProvider === 'local'}
|
||||
onClick={() => setSttProvider('local')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
{t('settings.voice.page.provider.local')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sttProvider === 'server'}
|
||||
onClick={() => setSttProvider('server')}
|
||||
aria-pressed={sttProvider === 'openai-compatible'}
|
||||
onClick={() => setSttProvider('openai-compatible')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.server')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sttProvider === 'wasm'}
|
||||
onClick={() => setSttProvider('wasm')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.wasm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sttProvider === 'server' && (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={sttTranscribeOnStop}
|
||||
onClick={() => setSttTranscribeOnStop(!sttTranscribeOnStop)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSttTranscribeOnStop(!sttTranscribeOnStop); } }}
|
||||
>
|
||||
<Checkbox checked={sttTranscribeOnStop} onChange={setSttTranscribeOnStop} ariaLabel={t('settings.voice.page.field.transcribeOnStopAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.transcribeOnStop')}</span>
|
||||
</div>
|
||||
{sttProvider === 'local' && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<LocalModelPicker selectedModelId={sttLocalModel} onSelect={setSttLocalModel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!audioStreamService.isSupported() && (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.voice.page.field.sttBrowserSupportError')}
|
||||
</p>
|
||||
)}
|
||||
{sttProvider === 'openai-compatible' && (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
@@ -979,129 +1288,14 @@ export const VoiceSettings: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={-60} max={-20} step={1} value={sttSilenceThresholdDb} onChange={(e) => setSttSilenceThresholdDb(Number(e.target.value))} className={sliderClass} />}
|
||||
<span className="typography-ui-label text-foreground tabular-nums min-w-[3.5rem] text-right">
|
||||
{sttSilenceThresholdDb} dB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceHold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={500} max={3000} step={100} value={sttSilenceHoldMs} onChange={(e) => setSttSilenceHoldMs(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={sttSilenceHoldMs} onValueChange={setSttSilenceHoldMs} min={500} max={3000} step={100} className="w-20 tabular-nums" />
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.millisecondsUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sttProvider === 'wasm' && (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className="typography-ui-label text-muted-foreground">
|
||||
{t('settings.voice.page.stt.wasmModel')}
|
||||
</span>
|
||||
<Select value={wasmSttModel} onValueChange={setWasmSttModel}>
|
||||
<SelectTrigger className="mt-0.5">
|
||||
<SelectValue placeholder={t('settings.voice.page.stt.wasmModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASM_MODELS.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{m.name}</span>
|
||||
<span className="typography-ui-compact text-muted-foreground">
|
||||
{m.size} · {m.languages}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="typography-ui-compact text-muted-foreground mt-0.5">
|
||||
{WASM_MODELS.find((m) => m.id === wasmSttModel)?.description}
|
||||
</p>
|
||||
</div>
|
||||
{/* Model status indicator */}
|
||||
<WasmModelStatusIndicator modelId={wasmSttModel} />
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback & Summarization */}
|
||||
<div data-settings-item="voice.playback" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.voice.page.section.playbackAndSummary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showMessageTTSButtons}
|
||||
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
|
||||
>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.voice.page.field.ttsInputMode')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'sanitized'}
|
||||
onClick={() => setTtsInputMode('sanitized')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeSanitized')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'raw'}
|
||||
onClick={() => setTtsInputMode('raw')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeRaw')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{voiceModeEnabled && isSupported && (
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.voice.page.hint.shiftClickPrefix')}
|
||||
{' '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd>
|
||||
{' + '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd>
|
||||
{' '}
|
||||
{t('settings.voice.page.hint.shiftClickSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1030,7 +1030,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{(page.slug === 'voice' || page.slug === 'tunnel') && (
|
||||
{page.slug === 'tunnel' && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* BrowserVoiceButton Component
|
||||
*
|
||||
* Voice toggle button for browser-based voice chat with language selection.
|
||||
* Shows visual state indicators for different voice modes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BrowserVoiceButton />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBrowserVoice } from '@/hooks/useBrowserVoice';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { VoiceStatusIndicator } from './VoiceStatusIndicator';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Status text for accessibility and labels
|
||||
const statusLabels: Record<string, string> = {
|
||||
idle: 'Start Voice',
|
||||
listening: 'Listening',
|
||||
processing: 'Processing',
|
||||
speaking: 'AI Speaking',
|
||||
error: 'Voice Error',
|
||||
};
|
||||
|
||||
// iOS Safari detection utility
|
||||
const isIOSSafari = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isIOS = /iphone|ipad|ipod/i.test(userAgent);
|
||||
const isSafari = /safari/i.test(userAgent) && !/chrome|crios|crmo/i.test(userAgent);
|
||||
return isIOS && isSafari;
|
||||
};
|
||||
|
||||
const normalizeVoiceErrorMessage = (error: string): string => {
|
||||
const isMediaDevicesError =
|
||||
error.includes('getUserMedia') ||
|
||||
error.includes('mediaDevices') ||
|
||||
error.includes('Cannot read properties of undefined');
|
||||
|
||||
if (!isMediaDevicesError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.isSecureContext) {
|
||||
return 'Voice requires a secure connection (HTTPS) or localhost. Please use HTTPS or access via localhost.';
|
||||
}
|
||||
|
||||
return 'Microphone access is unavailable in this runtime. On desktop, check System Settings -> Privacy & Security -> Microphone for OpenChamber.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser Voice Button with language selection
|
||||
*/
|
||||
export function BrowserVoiceButton() {
|
||||
const { t } = useI18n();
|
||||
const voiceModeEnabled = useConfigStore((s) => s.voiceModeEnabled);
|
||||
const sttProvider = useConfigStore((s) => s.sttProvider);
|
||||
const sttTranscribeOnStop = useConfigStore((s) => s.sttTranscribeOnStop);
|
||||
|
||||
const {
|
||||
status,
|
||||
isSupported,
|
||||
error,
|
||||
|
||||
startVoice,
|
||||
stopVoice,
|
||||
finishVoiceInput,
|
||||
conversationMode,
|
||||
toggleConversationMode,
|
||||
isMobile,
|
||||
} = useBrowserVoice();
|
||||
|
||||
const [isPressing, setIsPressing] = useState(false);
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const buttonSizeClass = isMobile ? 'h-8 w-8 min-h-[32px] min-w-[32px]' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
||||
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
||||
const continuousIconSizeClass = 'size-[18px]';
|
||||
const clearHoverBackgroundClass = 'bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent';
|
||||
|
||||
// Refs for touch handling
|
||||
const touchHandledRef = useRef(false);
|
||||
const isIOSSafariRef = useRef(false);
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const longPressTriggeredRef = useRef(false);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize iOS detection on mount
|
||||
useEffect(() => {
|
||||
isIOSSafariRef.current = isIOSSafari();
|
||||
}, []);
|
||||
|
||||
// NOTE: Do NOT pre-request microphone permission on mount.
|
||||
// Permission is requested when the user explicitly taps the mic button.
|
||||
// Pre-requesting causes an unwanted permission prompt on mobile page load.
|
||||
|
||||
// Determine active states
|
||||
const isActive = status === 'listening' || status === 'speaking' || status === 'processing';
|
||||
const isError = status === 'error';
|
||||
const isIdle = status === 'idle';
|
||||
|
||||
const isSpeaking = status === 'speaking';
|
||||
// WASM STT always needs finishVoiceInput to flush the recorder and transcribe.
|
||||
// Server STT uses it when sttTranscribeOnStop is enabled.
|
||||
const canTranscribeOnStop = sttProvider === 'wasm' || (sttProvider === 'server' && sttTranscribeOnStop);
|
||||
const isListeningWithTranscribeOnStop = status === 'listening' && canTranscribeOnStop;
|
||||
|
||||
// Show toast notification when voice error occurs
|
||||
useEffect(() => {
|
||||
if (isError && error) {
|
||||
if (lastToastedErrorRef.current === error) {
|
||||
return;
|
||||
}
|
||||
lastToastedErrorRef.current = error;
|
||||
const displayError = normalizeVoiceErrorMessage(error);
|
||||
|
||||
toast.error(displayError, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isError) {
|
||||
lastToastedErrorRef.current = null;
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
// Status text for accessibility
|
||||
const statusText = isError
|
||||
? error || 'Voice Error'
|
||||
: isListeningWithTranscribeOnStop
|
||||
? t('voice.action.finishAndTranscribe')
|
||||
: conversationMode && status === 'idle'
|
||||
? 'Start Voice (Continuous mode on)'
|
||||
: statusLabels[status] || 'Start Voice';
|
||||
|
||||
// Tooltip content based on state
|
||||
const getTooltipContent = () => {
|
||||
if (isError && error) {
|
||||
return normalizeVoiceErrorMessage(error);
|
||||
}
|
||||
if (isListeningWithTranscribeOnStop) {
|
||||
return t('voice.action.finishAndTranscribe');
|
||||
}
|
||||
if (isActive) {
|
||||
return 'Stop voice conversation';
|
||||
}
|
||||
if (isMobile) {
|
||||
return 'Start voice conversation';
|
||||
}
|
||||
return `Start voice conversation (Shift+Click for continuous mode) • Cmd/Ctrl+Shift+V to toggle`;
|
||||
};
|
||||
|
||||
// Handle voice activation (used by both click and touch)
|
||||
const activateVoice = useCallback(async () => {
|
||||
if (isActive) {
|
||||
if (status === 'listening' && canTranscribeOnStop) {
|
||||
finishVoiceInput();
|
||||
return;
|
||||
}
|
||||
stopVoice();
|
||||
} else if (status !== 'error') {
|
||||
// On mobile, we must NOT do any async operations before calling startVoice()
|
||||
// because iOS Safari requires SpeechRecognition.start() to be called
|
||||
// synchronously within the user gesture handler
|
||||
if (isMobile) {
|
||||
// Start voice immediately - no await before this!
|
||||
// Audio unlock is now handled inside startVoice() for mobile
|
||||
startVoice();
|
||||
} else {
|
||||
// Desktop can use async path
|
||||
try {
|
||||
await startVoice();
|
||||
} catch (err) {
|
||||
console.error('Failed to start voice:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset from error state
|
||||
if (isMobile) {
|
||||
startVoice();
|
||||
} else {
|
||||
try {
|
||||
await startVoice();
|
||||
} catch (err) {
|
||||
console.error('Failed to start voice:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isActive, status, canTranscribeOnStop, finishVoiceInput, startVoice, stopVoice, isMobile]);
|
||||
|
||||
// Handle Shift+Click to toggle conversation mode
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
// Prevent double-firing if touch already handled this
|
||||
if (touchHandledRef.current) {
|
||||
touchHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Click toggles conversation mode
|
||||
if (e.shiftKey) {
|
||||
toggleConversationMode();
|
||||
return;
|
||||
}
|
||||
|
||||
await activateVoice();
|
||||
}, [activateVoice, toggleConversationMode]);
|
||||
|
||||
// Handle touch start for mobile devices
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// Prevent default to stop mouse event emulation
|
||||
e.preventDefault();
|
||||
|
||||
// Mark that touch handled this interaction
|
||||
touchHandledRef.current = true;
|
||||
longPressTriggeredRef.current = false;
|
||||
|
||||
// Immediate visual feedback
|
||||
setIsPressing(true);
|
||||
|
||||
// Set up long-press timer for toggling conversation mode (500ms)
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
longPressTriggeredRef.current = true;
|
||||
toggleConversationMode();
|
||||
// Haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
setIsPressing(false);
|
||||
}, 500);
|
||||
}, [toggleConversationMode]);
|
||||
|
||||
// Handle touch end
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
// Clear long-press timer
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Only activate voice if long-press wasn't triggered
|
||||
if (!longPressTriggeredRef.current) {
|
||||
activateVoice();
|
||||
}
|
||||
|
||||
setIsPressing(false);
|
||||
}, [activateVoice]);
|
||||
|
||||
// Handle touch cancel
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
setIsPressing(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleConversationMode = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleConversationMode();
|
||||
}, [toggleConversationMode]);
|
||||
|
||||
// If voice mode is disabled, don't render anything
|
||||
if (!voiceModeEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If not supported, show disabled button with tooltip
|
||||
if (!isSupported) {
|
||||
const supportDetails = browserVoiceService.getSupportDetails();
|
||||
const tooltipMessage = !supportDetails.secureContext
|
||||
? 'Voice requires HTTPS or localhost. Please use a secure connection.'
|
||||
: !supportDetails.recognition
|
||||
? 'Speech recognition not supported in this browser. Try Chrome, Edge, or Safari.'
|
||||
: !supportDetails.synthesis
|
||||
? 'Speech synthesis not supported in this browser.'
|
||||
: 'Voice not supported in this browser';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled
|
||||
aria-label={tooltipMessage}
|
||||
className={`${buttonSizeClass} p-0 ${clearHoverBackgroundClass}`}
|
||||
>
|
||||
<Icon name="mic-off" className={`${iconSizeClass} opacity-50`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
<p className="max-w-[200px] text-center">{tooltipMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-1.5'}`}>
|
||||
{/* Status indicator with label - show when active, simplified on mobile */}
|
||||
{isActive && !isMobile && (
|
||||
<VoiceStatusIndicator
|
||||
status={status}
|
||||
showLabel
|
||||
size="sm"
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Voice button with tooltip */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
aria-label={statusText}
|
||||
className={`
|
||||
relative
|
||||
${buttonSizeClass}
|
||||
p-0
|
||||
${clearHoverBackgroundClass}
|
||||
touch-manipulation
|
||||
${isPressing ? 'scale-95 opacity-80' : ''}
|
||||
${conversationMode && isIdle && isMobile ? 'ring-1 ring-primary/50' : ''}
|
||||
`}
|
||||
style={{
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
isSpeaking ? (
|
||||
// Green speaker icon when AI is speaking
|
||||
<Icon name="volume-up" className={`${iconSizeClass} text-green-400 animate-pulse`} />
|
||||
) : (
|
||||
// Red stop icon for listening/processing (both mobile and desktop)
|
||||
<Icon name="stop-circle" className={`${iconSizeClass} text-[var(--status-error)]`} />
|
||||
)
|
||||
) : (
|
||||
<VoiceStatusIndicator
|
||||
status={isError ? 'idle' : status}
|
||||
size={isMobile || isVSCode ? 'sm' : 'md'}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
<p className="max-w-[200px] text-center">{getTooltipContent()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Conversation mode toggle button */}
|
||||
{(status === 'idle' || status === 'error') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
onClick={handleToggleConversationMode}
|
||||
aria-label={conversationMode ? 'Continuous mode on' : 'Continuous mode off'}
|
||||
title={conversationMode ? 'Continuous mode on' : 'Continuous mode off'}
|
||||
className={
|
||||
`${buttonSizeClass} p-0 ${clearHoverBackgroundClass} ${conversationMode ? 'text-[var(--status-info)] hover:text-[var(--status-info)]' : 'text-muted-foreground hover:text-foreground'}`
|
||||
}
|
||||
>
|
||||
<Icon name="voice-recognition" className={continuousIconSizeClass} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useVoiceContext } from '@/hooks/useVoiceContext';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
|
||||
const VoiceContextBridge = React.memo(function VoiceContextBridge() {
|
||||
useVoiceContext();
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider component that initializes voice context sync.
|
||||
* Wrap the app with this to enable voice session awareness.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <VoiceProvider>
|
||||
* <App />
|
||||
* </VoiceProvider>
|
||||
* ```
|
||||
*/
|
||||
export function VoiceProvider({ children }: { children: React.ReactNode }) {
|
||||
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||
|
||||
return (
|
||||
<>
|
||||
{voiceModeEnabled ? <VoiceContextBridge /> : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* VoiceStatusIndicator Component
|
||||
*
|
||||
* Reusable visual indicator for voice mode states with icons, animations,
|
||||
* and optional status text labels.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage - icon only
|
||||
* <VoiceStatusIndicator status="listening" />
|
||||
*
|
||||
* // With label
|
||||
* <VoiceStatusIndicator status="listening" showLabel />
|
||||
*
|
||||
* // Different size
|
||||
* <VoiceStatusIndicator status="processing" size="lg" />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { BrowserVoiceStatus } from '@/hooks/useBrowserVoice';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
export interface VoiceStatusIndicatorProps {
|
||||
/** Current voice status */
|
||||
status: BrowserVoiceStatus;
|
||||
/** Show text label next to icon */
|
||||
showLabel?: boolean;
|
||||
/** Size of the indicator */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Optional className for styling */
|
||||
className?: string;
|
||||
/** Whether conversation mode is active (shows indicator dot when idle) */
|
||||
conversationMode?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
icon: 'w-4 h-4',
|
||||
container: 'gap-1.5',
|
||||
},
|
||||
md: {
|
||||
icon: 'w-5 h-5',
|
||||
container: 'gap-2',
|
||||
},
|
||||
lg: {
|
||||
icon: 'w-6 h-6',
|
||||
container: 'gap-2.5',
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig: Record<
|
||||
BrowserVoiceStatus,
|
||||
{
|
||||
icon: IconName;
|
||||
color: string;
|
||||
labelKey:
|
||||
| 'voice.status.idle'
|
||||
| 'voice.status.listening'
|
||||
| 'voice.status.processing'
|
||||
| 'voice.status.speaking'
|
||||
| 'voice.status.error';
|
||||
animation?: string;
|
||||
}
|
||||
> = {
|
||||
idle: {
|
||||
icon: "mic-off",
|
||||
color: 'text-muted-foreground',
|
||||
labelKey: 'voice.status.idle',
|
||||
},
|
||||
listening: {
|
||||
icon: "mic",
|
||||
color: 'text-primary',
|
||||
labelKey: 'voice.status.listening',
|
||||
animation: 'animate-pulse',
|
||||
},
|
||||
processing: {
|
||||
icon: "loader-4",
|
||||
color: 'text-primary',
|
||||
labelKey: 'voice.status.processing',
|
||||
animation: 'animate-spin',
|
||||
},
|
||||
speaking: {
|
||||
icon: "volume-up",
|
||||
color: 'text-green-500',
|
||||
labelKey: 'voice.status.speaking',
|
||||
},
|
||||
error: {
|
||||
icon: "alert",
|
||||
color: 'text-destructive',
|
||||
labelKey: 'voice.status.error',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceStatusIndicator - Visual indicator for voice mode states
|
||||
*/
|
||||
export function VoiceStatusIndicator({
|
||||
status,
|
||||
showLabel = false,
|
||||
size = 'md',
|
||||
className = '',
|
||||
conversationMode = false,
|
||||
}: VoiceStatusIndicatorProps) {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[status];
|
||||
const statusIconName = config.icon;
|
||||
const sizeClass = sizeClasses[size];
|
||||
const containerClass = showLabel ? sizeClass.container : '';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${containerClass} ${className}`}>
|
||||
<div className="relative">
|
||||
<Icon name={statusIconName}
|
||||
className={`
|
||||
${sizeClass.icon}
|
||||
${config.color}
|
||||
${config.animation || ''}
|
||||
`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Conversation mode indicator dot - only when idle and conversation mode is on */}
|
||||
{conversationMode && status === 'idle' && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-green-500 rounded-full"
|
||||
aria-label={t('voice.status.conversationModeActiveAria')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className={`typography-meta ${config.color}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { VoiceProvider } from './VoiceProvider';
|
||||
export { BrowserVoiceButton } from './BrowserVoiceButton';
|
||||
Reference in New Issue
Block a user