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:
committed by
GitHub
co-authored by
Konstantin Zolin
Bohdan Triapitsyn
parent
9347f97a98
commit
c1948fe691
@@ -92,6 +92,8 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
const setSttSilenceThresholdDb = useConfigStore((state) => state.setSttSilenceThresholdDb);
|
const setSttSilenceThresholdDb = useConfigStore((state) => state.setSttSilenceThresholdDb);
|
||||||
const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
|
const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
|
||||||
const setSttSilenceHoldMs = useConfigStore((state) => state.setSttSilenceHoldMs);
|
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 setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons);
|
||||||
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||||
const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
|
const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
|
||||||
@@ -769,6 +771,18 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
|
|
||||||
{sttProvider === 'server' && (
|
{sttProvider === 'server' && (
|
||||||
<div className="py-1.5 space-y-2">
|
<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() && (
|
{!audioStreamService.isSupported() && (
|
||||||
<p className="typography-meta text-[var(--status-error)]">
|
<p className="typography-meta text-[var(--status-error)]">
|
||||||
{t('settings.voice.page.field.sttBrowserSupportError')}
|
{t('settings.voice.page.field.sttBrowserSupportError')}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { VoiceStatusIndicator } from './VoiceStatusIndicator';
|
import { VoiceStatusIndicator } from './VoiceStatusIndicator';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Status text for accessibility and labels
|
// Status text for accessibility and labels
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -65,7 +66,10 @@ const normalizeVoiceErrorMessage = (error: string): string => {
|
|||||||
* Browser Voice Button with language selection
|
* Browser Voice Button with language selection
|
||||||
*/
|
*/
|
||||||
export function BrowserVoiceButton() {
|
export function BrowserVoiceButton() {
|
||||||
|
const { t } = useI18n();
|
||||||
const voiceModeEnabled = useConfigStore((s) => s.voiceModeEnabled);
|
const voiceModeEnabled = useConfigStore((s) => s.voiceModeEnabled);
|
||||||
|
const sttProvider = useConfigStore((s) => s.sttProvider);
|
||||||
|
const sttTranscribeOnStop = useConfigStore((s) => s.sttTranscribeOnStop);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -74,6 +78,7 @@ export function BrowserVoiceButton() {
|
|||||||
|
|
||||||
startVoice,
|
startVoice,
|
||||||
stopVoice,
|
stopVoice,
|
||||||
|
finishVoiceInput,
|
||||||
conversationMode,
|
conversationMode,
|
||||||
toggleConversationMode,
|
toggleConversationMode,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -108,6 +113,8 @@ export function BrowserVoiceButton() {
|
|||||||
const isIdle = status === 'idle';
|
const isIdle = status === 'idle';
|
||||||
|
|
||||||
const isSpeaking = status === 'speaking';
|
const isSpeaking = status === 'speaking';
|
||||||
|
const canTranscribeOnStop = sttProvider === 'server' && sttTranscribeOnStop;
|
||||||
|
const isListeningWithTranscribeOnStop = status === 'listening' && canTranscribeOnStop;
|
||||||
|
|
||||||
// Show toast notification when voice error occurs
|
// Show toast notification when voice error occurs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,6 +138,8 @@ export function BrowserVoiceButton() {
|
|||||||
// Status text for accessibility
|
// Status text for accessibility
|
||||||
const statusText = isError
|
const statusText = isError
|
||||||
? error || 'Voice Error'
|
? error || 'Voice Error'
|
||||||
|
: isListeningWithTranscribeOnStop
|
||||||
|
? t('voice.action.finishAndTranscribe')
|
||||||
: conversationMode && status === 'idle'
|
: conversationMode && status === 'idle'
|
||||||
? 'Start Voice (Continuous mode on)'
|
? 'Start Voice (Continuous mode on)'
|
||||||
: statusLabels[status] || 'Start Voice';
|
: statusLabels[status] || 'Start Voice';
|
||||||
@@ -140,6 +149,9 @@ export function BrowserVoiceButton() {
|
|||||||
if (isError && error) {
|
if (isError && error) {
|
||||||
return normalizeVoiceErrorMessage(error);
|
return normalizeVoiceErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
if (isListeningWithTranscribeOnStop) {
|
||||||
|
return t('voice.action.finishAndTranscribe');
|
||||||
|
}
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
return 'Stop voice conversation';
|
return 'Stop voice conversation';
|
||||||
}
|
}
|
||||||
@@ -152,6 +164,10 @@ export function BrowserVoiceButton() {
|
|||||||
// Handle voice activation (used by both click and touch)
|
// Handle voice activation (used by both click and touch)
|
||||||
const activateVoice = useCallback(async () => {
|
const activateVoice = useCallback(async () => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
if (status === 'listening' && canTranscribeOnStop) {
|
||||||
|
finishVoiceInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
stopVoice();
|
stopVoice();
|
||||||
} else if (status !== 'error') {
|
} else if (status !== 'error') {
|
||||||
// On mobile, we must NOT do any async operations before calling startVoice()
|
// 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
|
// Handle Shift+Click to toggle conversation mode
|
||||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export interface UseBrowserVoiceReturn {
|
|||||||
startVoice: () => void;
|
startVoice: () => void;
|
||||||
/** Stop voice mode */
|
/** Stop voice mode */
|
||||||
stopVoice: () => void;
|
stopVoice: () => void;
|
||||||
|
/** Finish current voice input and process it */
|
||||||
|
finishVoiceInput: () => void;
|
||||||
/** Whether conversation mode is active */
|
/** Whether conversation mode is active */
|
||||||
conversationMode: boolean;
|
conversationMode: boolean;
|
||||||
/** Toggle conversation mode */
|
/** Toggle conversation mode */
|
||||||
@@ -834,6 +836,55 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [stopServerTTS, stopSayTTS]);
|
}, [stopServerTTS, stopSayTTS]);
|
||||||
|
|
||||||
|
const finishVoiceInput = useCallback(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingResumeOnVisibleRef.current = false;
|
||||||
|
if (deviceChangeRestartTimerRef.current) {
|
||||||
|
clearTimeout(deviceChangeRestartTimerRef.current);
|
||||||
|
deviceChangeRestartTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus('processing');
|
||||||
|
|
||||||
|
if (sttProvider === 'server') {
|
||||||
|
void audioStreamService.finishListening().then(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingFinalTranscriptRef.current || finalTranscriptTimerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (processingMessageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isActiveRef.current = false;
|
||||||
|
processingMessageRef.current = false;
|
||||||
|
setStatus('idle');
|
||||||
|
}, FINAL_TRANSCRIPT_SETTLE_MS + 200);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserVoiceService.stopListening();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingFinalTranscriptRef.current || finalTranscriptTimerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (processingMessageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isActiveRef.current = false;
|
||||||
|
processingMessageRef.current = false;
|
||||||
|
setStatus('idle');
|
||||||
|
}, FINAL_TRANSCRIPT_SETTLE_MS + 300);
|
||||||
|
}, [sttProvider]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -863,6 +914,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
startVoice,
|
startVoice,
|
||||||
stopVoice,
|
stopVoice,
|
||||||
|
finishVoiceInput,
|
||||||
conversationMode,
|
conversationMode,
|
||||||
toggleConversationMode,
|
toggleConversationMode,
|
||||||
prepareVoice,
|
prepareVoice,
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
'settings.voice.page.field.sttLanguageHint': 'BCP-47 code (for example en, fr). Leave blank for auto-detect.',
|
'settings.voice.page.field.sttLanguageHint': 'BCP-47 code (for example en, fr). Leave blank for auto-detect.',
|
||||||
'settings.voice.page.field.silenceThreshold': 'Silence Threshold',
|
'settings.voice.page.field.silenceThreshold': 'Silence Threshold',
|
||||||
'settings.voice.page.field.silenceHold': 'Silence Hold',
|
'settings.voice.page.field.silenceHold': 'Silence Hold',
|
||||||
|
'settings.voice.page.field.transcribeOnStopAria': 'Transcribe when stopping recording',
|
||||||
|
'settings.voice.page.field.transcribeOnStop': 'Transcribe on Stop',
|
||||||
'settings.voice.page.field.millisecondsUnit': 'ms',
|
'settings.voice.page.field.millisecondsUnit': 'ms',
|
||||||
'settings.voice.page.field.messageReadAloudButtonAria': 'Message read aloud button',
|
'settings.voice.page.field.messageReadAloudButtonAria': 'Message read aloud button',
|
||||||
'settings.voice.page.field.messageReadAloudButton': 'Message Read Aloud Button',
|
'settings.voice.page.field.messageReadAloudButton': 'Message Read Aloud Button',
|
||||||
|
|||||||
@@ -2021,6 +2021,7 @@ export const dict = {
|
|||||||
'voice.status.speaking': 'Speaking...',
|
'voice.status.speaking': 'Speaking...',
|
||||||
'voice.status.error': 'Voice Error',
|
'voice.status.error': 'Voice Error',
|
||||||
'voice.status.conversationModeActiveAria': 'Conversation mode active',
|
'voice.status.conversationModeActiveAria': 'Conversation mode active',
|
||||||
|
'voice.action.finishAndTranscribe': 'Finish and transcribe voice input',
|
||||||
'onboarding.common.actions.back': 'Back',
|
'onboarding.common.actions.back': 'Back',
|
||||||
'onboarding.common.copyToClipboard': 'Copy to clipboard',
|
'onboarding.common.copyToClipboard': 'Copy to clipboard',
|
||||||
'onboarding.common.status.copiedToClipboard': 'Copied to clipboard',
|
'onboarding.common.status.copiedToClipboard': 'Copied to clipboard',
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
"settings.voice.page.field.sttLanguageHint": "Código BCP-47 (por ejemplo en, fr). Dejar en blanco para detección automática.",
|
"settings.voice.page.field.sttLanguageHint": "Código BCP-47 (por ejemplo en, fr). Dejar en blanco para detección automática.",
|
||||||
"settings.voice.page.field.silenceThreshold": "Umbral de silencio",
|
"settings.voice.page.field.silenceThreshold": "Umbral de silencio",
|
||||||
"settings.voice.page.field.silenceHold": "Retención de silencio",
|
"settings.voice.page.field.silenceHold": "Retención de silencio",
|
||||||
|
"settings.voice.page.field.transcribeOnStopAria": "Transcribir al detener la grabación",
|
||||||
|
"settings.voice.page.field.transcribeOnStop": "Transcribir al detener",
|
||||||
"settings.voice.page.field.millisecondsUnit": "ms",
|
"settings.voice.page.field.millisecondsUnit": "ms",
|
||||||
"settings.voice.page.field.messageReadAloudButtonAria": "Botón de lectura en voz alta del mensaje",
|
"settings.voice.page.field.messageReadAloudButtonAria": "Botón de lectura en voz alta del mensaje",
|
||||||
"settings.voice.page.field.messageReadAloudButton": "Botón de lectura en voz alta del mensaje",
|
"settings.voice.page.field.messageReadAloudButton": "Botón de lectura en voz alta del mensaje",
|
||||||
|
|||||||
@@ -1987,6 +1987,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"voice.status.speaking": "Hablando...",
|
"voice.status.speaking": "Hablando...",
|
||||||
"voice.status.error": "Error de voz",
|
"voice.status.error": "Error de voz",
|
||||||
"voice.status.conversationModeActiveAria": "Modo de conversación activo",
|
"voice.status.conversationModeActiveAria": "Modo de conversación activo",
|
||||||
|
"voice.action.finishAndTranscribe": "Finalizar y transcribir la entrada de voz",
|
||||||
"onboarding.common.actions.back": "Atrás",
|
"onboarding.common.actions.back": "Atrás",
|
||||||
"onboarding.common.copyToClipboard": "Copiar al portapapeles",
|
"onboarding.common.copyToClipboard": "Copiar al portapapeles",
|
||||||
"onboarding.common.status.copiedToClipboard": "Copiado al portapapeles",
|
"onboarding.common.status.copiedToClipboard": "Copiado al portapapeles",
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
'settings.voice.page.field.sttLanguageHint': 'BCP-47 코드(예: en, fr). 자동 감지하려면 비워 두세요.',
|
'settings.voice.page.field.sttLanguageHint': 'BCP-47 코드(예: en, fr). 자동 감지하려면 비워 두세요.',
|
||||||
'settings.voice.page.field.silenceThreshold': '무음 기준',
|
'settings.voice.page.field.silenceThreshold': '무음 기준',
|
||||||
'settings.voice.page.field.silenceHold': '무음 유지',
|
'settings.voice.page.field.silenceHold': '무음 유지',
|
||||||
|
'settings.voice.page.field.transcribeOnStopAria': '녹음을 중지할 때 받아쓰기',
|
||||||
|
'settings.voice.page.field.transcribeOnStop': '중지 시 받아쓰기',
|
||||||
'settings.voice.page.field.millisecondsUnit': 'ms',
|
'settings.voice.page.field.millisecondsUnit': 'ms',
|
||||||
'settings.voice.page.field.messageReadAloudButtonAria': '메시지 읽어주기 버튼',
|
'settings.voice.page.field.messageReadAloudButtonAria': '메시지 읽어주기 버튼',
|
||||||
'settings.voice.page.field.messageReadAloudButton': '메시지 읽어주기 버튼',
|
'settings.voice.page.field.messageReadAloudButton': '메시지 읽어주기 버튼',
|
||||||
|
|||||||
@@ -2021,6 +2021,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'voice.status.speaking': '말하는 중…',
|
'voice.status.speaking': '말하는 중…',
|
||||||
'voice.status.error': '음성 오류',
|
'voice.status.error': '음성 오류',
|
||||||
'voice.status.conversationModeActiveAria': '대화 모드 활성 상태',
|
'voice.status.conversationModeActiveAria': '대화 모드 활성 상태',
|
||||||
|
'voice.action.finishAndTranscribe': '음성 입력을 완료하고 받아쓰기',
|
||||||
'onboarding.common.actions.back': '뒤로',
|
'onboarding.common.actions.back': '뒤로',
|
||||||
'onboarding.common.copyToClipboard': '클립보드에 복사',
|
'onboarding.common.copyToClipboard': '클립보드에 복사',
|
||||||
'onboarding.common.status.copiedToClipboard': '클립보드에 복사했습니다',
|
'onboarding.common.status.copiedToClipboard': '클립보드에 복사했습니다',
|
||||||
|
|||||||
@@ -1502,6 +1502,8 @@ export const settingsDict = {
|
|||||||
'settings.voice.page.field.serverUrlHint': 'Bazowy URL serwera TTS zgodnego z OpenAI',
|
'settings.voice.page.field.serverUrlHint': 'Bazowy URL serwera TTS zgodnego z OpenAI',
|
||||||
'settings.voice.page.field.silenceHold': 'Podtrzymanie ciszy',
|
'settings.voice.page.field.silenceHold': 'Podtrzymanie ciszy',
|
||||||
'settings.voice.page.field.silenceThreshold': 'Próg ciszy',
|
'settings.voice.page.field.silenceThreshold': 'Próg ciszy',
|
||||||
|
'settings.voice.page.field.transcribeOnStopAria': 'Transkrybuj po zatrzymaniu nagrania',
|
||||||
|
'settings.voice.page.field.transcribeOnStop': 'Transkrybuj po zatrzymaniu',
|
||||||
'settings.voice.page.field.speechPitch': 'Wysokość głosu',
|
'settings.voice.page.field.speechPitch': 'Wysokość głosu',
|
||||||
'settings.voice.page.field.speechRate': 'Tempo mowy',
|
'settings.voice.page.field.speechRate': 'Tempo mowy',
|
||||||
'settings.voice.page.field.speechVolume': 'Głośność mowy',
|
'settings.voice.page.field.speechVolume': 'Głośność mowy',
|
||||||
|
|||||||
@@ -582,6 +582,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'voice.status.speaking': 'Mówię...',
|
'voice.status.speaking': 'Mówię...',
|
||||||
'voice.status.error': 'Błąd głosu',
|
'voice.status.error': 'Błąd głosu',
|
||||||
'voice.status.conversationModeActiveAria': 'Tryb rozmowy aktywny',
|
'voice.status.conversationModeActiveAria': 'Tryb rozmowy aktywny',
|
||||||
|
'voice.action.finishAndTranscribe': 'Zakończ i transkrybuj wejście głosowe',
|
||||||
'onboarding.common.actions.back': 'Wstecz',
|
'onboarding.common.actions.back': 'Wstecz',
|
||||||
'onboarding.common.copyToClipboard': 'Kopiuj do schowka',
|
'onboarding.common.copyToClipboard': 'Kopiuj do schowka',
|
||||||
'onboarding.common.status.copiedToClipboard': 'Skopiowano do schowka',
|
'onboarding.common.status.copiedToClipboard': 'Skopiowano do schowka',
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
"settings.voice.page.field.sttLanguageHint": "Código BCP-47 (por exemplo en, fr). Deixe em branco para detecção automática.",
|
"settings.voice.page.field.sttLanguageHint": "Código BCP-47 (por exemplo en, fr). Deixe em branco para detecção automática.",
|
||||||
"settings.voice.page.field.silenceThreshold": "Limite de silêncio",
|
"settings.voice.page.field.silenceThreshold": "Limite de silêncio",
|
||||||
"settings.voice.page.field.silenceHold": "Retenção de silêncio",
|
"settings.voice.page.field.silenceHold": "Retenção de silêncio",
|
||||||
|
"settings.voice.page.field.transcribeOnStopAria": "Transcrever ao parar a gravação",
|
||||||
|
"settings.voice.page.field.transcribeOnStop": "Transcrever ao parar",
|
||||||
"settings.voice.page.field.millisecondsUnit": "ms",
|
"settings.voice.page.field.millisecondsUnit": "ms",
|
||||||
"settings.voice.page.field.messageReadAloudButtonAria": "Botão de leitura em voz alta da mensagem",
|
"settings.voice.page.field.messageReadAloudButtonAria": "Botão de leitura em voz alta da mensagem",
|
||||||
"settings.voice.page.field.messageReadAloudButton": "Botão de leitura em voz alta da mensagem",
|
"settings.voice.page.field.messageReadAloudButton": "Botão de leitura em voz alta da mensagem",
|
||||||
|
|||||||
@@ -1987,6 +1987,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"voice.status.speaking": "Falando...",
|
"voice.status.speaking": "Falando...",
|
||||||
"voice.status.error": "Erro de voz",
|
"voice.status.error": "Erro de voz",
|
||||||
"voice.status.conversationModeActiveAria": "Modo de conversa ativo",
|
"voice.status.conversationModeActiveAria": "Modo de conversa ativo",
|
||||||
|
"voice.action.finishAndTranscribe": "Finalizar e transcrever a entrada de voz",
|
||||||
"onboarding.common.actions.back": "Voltar",
|
"onboarding.common.actions.back": "Voltar",
|
||||||
"onboarding.common.copyToClipboard": "Copiar para a área de transferência",
|
"onboarding.common.copyToClipboard": "Copiar para a área de transferência",
|
||||||
"onboarding.common.status.copiedToClipboard": "Copiado para a área de transferência",
|
"onboarding.common.status.copiedToClipboard": "Copiado para a área de transferência",
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
"settings.voice.page.field.sttLanguageHint": "BCP-47 код (наприклад en, fr). Залиште поле порожнім для автоматичного визначення.",
|
"settings.voice.page.field.sttLanguageHint": "BCP-47 код (наприклад en, fr). Залиште поле порожнім для автоматичного визначення.",
|
||||||
"settings.voice.page.field.silenceThreshold": "Поріг тиші",
|
"settings.voice.page.field.silenceThreshold": "Поріг тиші",
|
||||||
"settings.voice.page.field.silenceHold": "Утримання тиші",
|
"settings.voice.page.field.silenceHold": "Утримання тиші",
|
||||||
|
"settings.voice.page.field.transcribeOnStopAria": "Розпізнавати після зупинки запису",
|
||||||
|
"settings.voice.page.field.transcribeOnStop": "Розпізнавати при зупинці",
|
||||||
"settings.voice.page.field.millisecondsUnit": "мс",
|
"settings.voice.page.field.millisecondsUnit": "мс",
|
||||||
"settings.voice.page.field.messageReadAloudButtonAria": "Кнопка «Прочитати повідомлення вголос»",
|
"settings.voice.page.field.messageReadAloudButtonAria": "Кнопка «Прочитати повідомлення вголос»",
|
||||||
"settings.voice.page.field.messageReadAloudButton": "Кнопка «Прочитати вголос» для повідомлень",
|
"settings.voice.page.field.messageReadAloudButton": "Кнопка «Прочитати вголос» для повідомлень",
|
||||||
|
|||||||
@@ -1987,6 +1987,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"voice.status.speaking": "Говорить...",
|
"voice.status.speaking": "Говорить...",
|
||||||
"voice.status.error": "Голосова помилка",
|
"voice.status.error": "Голосова помилка",
|
||||||
"voice.status.conversationModeActiveAria": "Активний режим розмови",
|
"voice.status.conversationModeActiveAria": "Активний режим розмови",
|
||||||
|
"voice.action.finishAndTranscribe": "Завершити й розпізнати голосове введення",
|
||||||
"onboarding.common.actions.back": "Назад",
|
"onboarding.common.actions.back": "Назад",
|
||||||
"onboarding.common.copyToClipboard": "Копіювати в буфер обміну",
|
"onboarding.common.copyToClipboard": "Копіювати в буфер обміну",
|
||||||
"onboarding.common.status.copiedToClipboard": "Скопійовано в буфер обміну",
|
"onboarding.common.status.copiedToClipboard": "Скопійовано в буфер обміну",
|
||||||
|
|||||||
@@ -1330,6 +1330,8 @@ export const settingsDict = {
|
|||||||
'settings.voice.page.field.sttLanguageHint': 'BCP-47 代码(例如 en、fr)。留空为自动检测。',
|
'settings.voice.page.field.sttLanguageHint': 'BCP-47 代码(例如 en、fr)。留空为自动检测。',
|
||||||
'settings.voice.page.field.silenceThreshold': '静音阈值',
|
'settings.voice.page.field.silenceThreshold': '静音阈值',
|
||||||
'settings.voice.page.field.silenceHold': '静音保持时长',
|
'settings.voice.page.field.silenceHold': '静音保持时长',
|
||||||
|
'settings.voice.page.field.transcribeOnStopAria': '停止录音时转写',
|
||||||
|
'settings.voice.page.field.transcribeOnStop': '停止时转写',
|
||||||
'settings.voice.page.field.millisecondsUnit': '毫秒',
|
'settings.voice.page.field.millisecondsUnit': '毫秒',
|
||||||
'settings.voice.page.field.messageReadAloudButtonAria': '消息朗读按钮',
|
'settings.voice.page.field.messageReadAloudButtonAria': '消息朗读按钮',
|
||||||
'settings.voice.page.field.messageReadAloudButton': '消息朗读按钮',
|
'settings.voice.page.field.messageReadAloudButton': '消息朗读按钮',
|
||||||
|
|||||||
@@ -1987,6 +1987,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'voice.status.speaking': '播放中...',
|
'voice.status.speaking': '播放中...',
|
||||||
'voice.status.error': '语音错误',
|
'voice.status.error': '语音错误',
|
||||||
'voice.status.conversationModeActiveAria': '会话模式已启用',
|
'voice.status.conversationModeActiveAria': '会话模式已启用',
|
||||||
|
'voice.action.finishAndTranscribe': '结束并转写语音输入',
|
||||||
'onboarding.common.actions.back': '返回',
|
'onboarding.common.actions.back': '返回',
|
||||||
'onboarding.common.copyToClipboard': '复制到剪贴板',
|
'onboarding.common.copyToClipboard': '复制到剪贴板',
|
||||||
'onboarding.common.status.copiedToClipboard': '已复制到剪贴板',
|
'onboarding.common.status.copiedToClipboard': '已复制到剪贴板',
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class AudioStreamService {
|
|||||||
private silenceSince: number | null = null;
|
private silenceSince: number | null = null;
|
||||||
private onResult: SpeechResultCallback | null = null;
|
private onResult: SpeechResultCallback | null = null;
|
||||||
private onError: ErrorCallback | null = null;
|
private onError: ErrorCallback | null = null;
|
||||||
|
private finishResolver: (() => void) | null = null;
|
||||||
private lang = 'en';
|
private lang = 'en';
|
||||||
|
|
||||||
// Configurable parameters
|
// Configurable parameters
|
||||||
@@ -125,16 +126,29 @@ class AudioStreamService {
|
|||||||
|
|
||||||
/** Stop listening and clean up all resources. */
|
/** Stop listening and clean up all resources. */
|
||||||
stopListening(): void {
|
stopListening(): void {
|
||||||
this.isActive = false;
|
|
||||||
this._stopVAD();
|
this._stopVAD();
|
||||||
this._stopRecorder();
|
this._stopRecorder();
|
||||||
this._teardownAudioContext();
|
this._cleanupAfterStop(true);
|
||||||
this._releaseStream();
|
}
|
||||||
this.chunks = [];
|
|
||||||
|
async finishListening(): Promise<void> {
|
||||||
|
if (!this.isActive) return;
|
||||||
|
|
||||||
|
this._stopVAD();
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
this.silenceSince = null;
|
this.silenceSince = null;
|
||||||
this.onResult = null;
|
|
||||||
this.onError = null;
|
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
|
||||||
|
this._cleanupAfterStop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.finishResolver = resolve;
|
||||||
|
this._finaliseUtterance(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._cleanupAfterStop(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether currently listening. */
|
/** Whether currently listening. */
|
||||||
@@ -186,11 +200,18 @@ class AudioStreamService {
|
|||||||
this.mediaRecorder.onstop = () => {
|
this.mediaRecorder.onstop = () => {
|
||||||
const blobs = this.chunks.splice(0);
|
const blobs = this.chunks.splice(0);
|
||||||
const durationMs = Date.now() - this.recordingStartMs;
|
const durationMs = Date.now() - this.recordingStartMs;
|
||||||
if (blobs.length === 0 || durationMs < MIN_UTTERANCE_MS) return;
|
if (blobs.length === 0 || durationMs < MIN_UTTERANCE_MS) {
|
||||||
|
this.finishResolver?.();
|
||||||
|
this.finishResolver = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mType = blobs[0].type || mimeType || 'audio/webm';
|
const mType = blobs[0].type || mimeType || 'audio/webm';
|
||||||
const blob = new Blob(blobs, { type: mType });
|
const blob = new Blob(blobs, { type: mType });
|
||||||
void this._upload(blob, mType);
|
void this._upload(blob, mType).finally(() => {
|
||||||
|
this.finishResolver?.();
|
||||||
|
this.finishResolver = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect data every 250 ms so we don't lose the tail on stop()
|
// Collect data every 250 ms so we don't lose the tail on stop()
|
||||||
@@ -202,6 +223,8 @@ class AudioStreamService {
|
|||||||
try {
|
try {
|
||||||
this.mediaRecorder.stop();
|
this.mediaRecorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
|
this.finishResolver?.();
|
||||||
|
this.finishResolver = null;
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +267,7 @@ class AudioStreamService {
|
|||||||
// End of utterance — stop recorder (triggers onstop → upload)
|
// End of utterance — stop recorder (triggers onstop → upload)
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
this.silenceSince = null;
|
this.silenceSince = null;
|
||||||
this._finaliseUtterance();
|
this._finaliseUtterance(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,12 +281,31 @@ class AudioStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the current recorder to flush the utterance, then restart it. */
|
private _cleanupAfterStop(clearChunks: boolean): void {
|
||||||
private _finaliseUtterance(): void {
|
const pendingResolver = this.finishResolver;
|
||||||
|
this.isActive = false;
|
||||||
|
this.finishResolver = null;
|
||||||
|
this.mediaRecorder = null;
|
||||||
|
this._teardownAudioContext();
|
||||||
|
this._releaseStream();
|
||||||
|
if (clearChunks) {
|
||||||
|
this.chunks = [];
|
||||||
|
}
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.silenceSince = null;
|
||||||
|
this.onResult = null;
|
||||||
|
this.onError = null;
|
||||||
|
pendingResolver?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the current recorder to flush the utterance, optionally restarting for the next one. */
|
||||||
|
private _finaliseUtterance(restart: boolean): void {
|
||||||
if (!this.isActive) return;
|
if (!this.isActive) return;
|
||||||
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
|
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
|
||||||
this.mediaRecorder.stop();
|
this.mediaRecorder.stop();
|
||||||
}
|
}
|
||||||
|
if (!restart) return;
|
||||||
|
|
||||||
// Restart recorder for the next utterance after a short delay
|
// Restart recorder for the next utterance after a short delay
|
||||||
// (MediaRecorder.onstop fires asynchronously; we wait for it to complete)
|
// (MediaRecorder.onstop fires asynchronously; we wait for it to complete)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ interface ConfigStore {
|
|||||||
sttLanguage: string;
|
sttLanguage: string;
|
||||||
sttSilenceThresholdDb: number;
|
sttSilenceThresholdDb: number;
|
||||||
sttSilenceHoldMs: number;
|
sttSilenceHoldMs: number;
|
||||||
|
sttTranscribeOnStop: boolean;
|
||||||
showMessageTTSButtons: boolean;
|
showMessageTTSButtons: boolean;
|
||||||
voiceModeEnabled: boolean;
|
voiceModeEnabled: boolean;
|
||||||
// Summarization settings
|
// Summarization settings
|
||||||
@@ -533,6 +534,7 @@ interface ConfigStore {
|
|||||||
setSttLanguage: (lang: string) => void;
|
setSttLanguage: (lang: string) => void;
|
||||||
setSttSilenceThresholdDb: (db: number) => void;
|
setSttSilenceThresholdDb: (db: number) => void;
|
||||||
setSttSilenceHoldMs: (ms: number) => void;
|
setSttSilenceHoldMs: (ms: number) => void;
|
||||||
|
setSttTranscribeOnStop: (enabled: boolean) => void;
|
||||||
setShowMessageTTSButtons: (show: boolean) => void;
|
setShowMessageTTSButtons: (show: boolean) => void;
|
||||||
setVoiceModeEnabled: (enabled: boolean) => void;
|
setVoiceModeEnabled: (enabled: boolean) => void;
|
||||||
setSummarizeMessageTTS: (enabled: boolean) => void;
|
setSummarizeMessageTTS: (enabled: boolean) => void;
|
||||||
@@ -760,6 +762,13 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
return 1500;
|
return 1500;
|
||||||
})(),
|
})(),
|
||||||
|
sttTranscribeOnStop: (() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('sttTranscribeOnStop');
|
||||||
|
if (saved === 'true') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})(),
|
||||||
// Show TTS buttons on messages - disabled by default until user enables it
|
// Show TTS buttons on messages - disabled by default until user enables it
|
||||||
showMessageTTSButtons: (() => {
|
showMessageTTSButtons: (() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1865,6 +1874,13 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSttTranscribeOnStop: (enabled: boolean) => {
|
||||||
|
set({ sttTranscribeOnStop: enabled });
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('sttTranscribeOnStop', String(enabled));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setShowMessageTTSButtons: (show: boolean) => {
|
setShowMessageTTSButtons: (show: boolean) => {
|
||||||
set({ showMessageTTSButtons: show });
|
set({ showMessageTTSButtons: show });
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
Reference in New Issue
Block a user