Adds an option to transcribe server STT audio when stopping voice input. (#1219)

* feat(voice): add transcribe-on-stop recognition option

* fix(voice): tighten transcribe-on-stop cleanup

* fix(voice): address transcribe-on-stop review feedback

* fix(voice): scope transcribe-on-stop to server STT

---------

Co-authored-by: Konstantin Zolin <kzolin@alfabank.ru>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-05-13 15:30:25 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent 9347f97a98
commit c1948fe691
19 changed files with 173 additions and 12 deletions
@@ -92,6 +92,8 @@ export const VoiceSettings: React.FC = () => {
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);
@@ -769,6 +771,18 @@ export const VoiceSettings: React.FC = () => {
{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>
{!audioStreamService.isSupported() && (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.voice.page.field.sttBrowserSupportError')}
@@ -25,6 +25,7 @@ import {
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> = {
@@ -65,7 +66,10 @@ const normalizeVoiceErrorMessage = (error: string): string => {
* 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,
@@ -74,6 +78,7 @@ export function BrowserVoiceButton() {
startVoice,
stopVoice,
finishVoiceInput,
conversationMode,
toggleConversationMode,
isMobile,
@@ -108,6 +113,8 @@ export function BrowserVoiceButton() {
const isIdle = status === 'idle';
const isSpeaking = status === 'speaking';
const canTranscribeOnStop = sttProvider === 'server' && sttTranscribeOnStop;
const isListeningWithTranscribeOnStop = status === 'listening' && canTranscribeOnStop;
// Show toast notification when voice error occurs
useEffect(() => {
@@ -131,6 +138,8 @@ export function BrowserVoiceButton() {
// 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';
@@ -140,6 +149,9 @@ export function BrowserVoiceButton() {
if (isError && error) {
return normalizeVoiceErrorMessage(error);
}
if (isListeningWithTranscribeOnStop) {
return t('voice.action.finishAndTranscribe');
}
if (isActive) {
return 'Stop voice conversation';
}
@@ -152,6 +164,10 @@ export function BrowserVoiceButton() {
// 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()
@@ -181,7 +197,7 @@ export function BrowserVoiceButton() {
}
}
}
}, [isActive, status, startVoice, stopVoice, isMobile]);
}, [isActive, status, canTranscribeOnStop, finishVoiceInput, startVoice, stopVoice, isMobile]);
// Handle Shift+Click to toggle conversation mode
const handleClick = useCallback(async (e: React.MouseEvent) => {