diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index f9e0c57..e393825 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -107,6 +107,8 @@ class VoiceModeService extends EventEmitter { // 에러 가드 private _errorEmitted = false + // 에러 popup 3초 hide 예약 타이머 (다음 세션 시작 시 취소해야 현재 recording tip이 살아남음) + private _errorHideTimer: NodeJS.Timeout | null = null // Action Queue (이벤트 직렬화) private _actionQueue: VoiceAction[] = [] @@ -249,6 +251,13 @@ class VoiceModeService extends EventEmitter { this._audioBuffer = [] this._audioBufferBytes = 0 + // 이전 에러의 3초 hide 타이머가 살아있으면 취소 (그대로 두면 새 recording tip을 + // ~2초 지점에서 숨겨버리는 버그 발생). _errorEmitted 리셋과 동일 타이밍. + if (this._errorHideTimer) { + clearTimeout(this._errorHideTimer) + this._errorHideTimer = null + } + this._setRecognitionState(RecognitionState.PREPARING) this.emit('session-started', { session: this._session }) logger.info(`Session started: ${this._session.id} (mode: ${mode})`) @@ -467,6 +476,19 @@ class VoiceModeService extends EventEmitter { this._audioBuffer = [] this._audioBufferBytes = 0 + // Bug 13: 너무 짧은 오디오는 STT 호출 전에 에러 피드백. + // 16kHz 16bit mono 기준 0.5초 = 16000 bytes. + const minBytes = 16000 * 2 * 0.5 + if (merged.length < minBytes) { + this._handleError( + new D3ROError( + ErrorCode.STTAudioTooShort, + 'No speech detected. Please speak and try again.' + ) + ) + return + } + try { const stt = getLocalSTTService() const language = configGet('sttLanguage') @@ -487,6 +509,18 @@ class VoiceModeService extends EventEmitter { if (this._isInTerminalState()) return + // Bug 13: VAD가 전체 오디오를 무음 판정했거나 결과가 빈 텍스트일 때 + // 조용히 빈 문자열로 complete 하지 않고 사용자에게 피드백. + if (!result.text || result.text.trim().length === 0) { + this._handleError( + new D3ROError( + ErrorCode.STTNoAudioData, + 'No speech detected. Check your microphone and try again.' + ) + ) + return + } + if (this._session) { this._session.transcription = result.text } @@ -720,9 +754,14 @@ class VoiceModeService extends EventEmitter { this._stopAudio() - // Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김 + // Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김. + // 핸들 보관 → 다음 세션 시작 시 clearTimeout으로 취소(없으면 새 recording tip 숨김). updateRecordingTipState('error', { errorMessage: error.message }) - setTimeout(() => { + if (this._errorHideTimer) { + clearTimeout(this._errorHideTimer) + } + this._errorHideTimer = setTimeout(() => { + this._errorHideTimer = null hideRecordingTip() }, 3000) @@ -861,6 +900,12 @@ class VoiceModeService extends EventEmitter { // 오디오 리스너 해제 this._stopAudio() + // 에러 hide 타이머 정리 (pending 시 메모리 leak 방지) + if (this._errorHideTimer) { + clearTimeout(this._errorHideTimer) + this._errorHideTimer = null + } + this._setRecognitionState(RecognitionState.DESTROYED) this.removeAllListeners() logger.info('VoiceModeService disposed') diff --git a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx index f96c611..b85fca1 100644 --- a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx +++ b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx @@ -3,7 +3,7 @@ // STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼. import { useState, useEffect, useCallback, useRef } from 'react' -import { Box, IconButton, TextField, Tooltip } from '@mui/material' +import { Alert, Box, IconButton, Snackbar, TextField, Tooltip } from '@mui/material' import MicIcon from '@mui/icons-material/Mic' import StopIcon from '@mui/icons-material/Stop' import SendIcon from '@mui/icons-material/Send' @@ -19,6 +19,7 @@ import type { ConversationState, ConversationMessage, ConversationAssistantDelta, + ConversationError, } from '@d3ro/core/types' export function VoiceConversationPage(): React.ReactElement { @@ -29,6 +30,7 @@ export function VoiceConversationPage(): React.ReactElement { const [streamingText, setStreamingText] = useState('') const [streamingMsgId, setStreamingMsgId] = useState(null) const [textInput, setTextInput] = useState('') + const [errorBanner, setErrorBanner] = useState(null) const messagesEndRef = useRef(null) const scrollToBottom = useCallback(() => { @@ -59,8 +61,9 @@ export function VoiceConversationPage(): React.ReactElement { setStreamingMsgId(null) setTimeout(scrollToBottom, 50) }) - const unsubError = window.electronAPI.voiceConversation.onError(() => { - // 에러 시 자동 복구 (서비스에서 listening으로 전환) + const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => { + // 서비스는 자동 복구(listening 재진입)까지 처리. 여기선 배너만 띄운다. + setErrorBanner(err) }) // 초기 상태 로드 @@ -127,6 +130,15 @@ export function VoiceConversationPage(): React.ReactElement { speaking: t('conversation.speaking'), } + const formatErrorMessage = useCallback((err: ConversationError): string => { + // Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label. + if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) { + return t('conversation.error.noSpeech') + } + const phaseLabel = t(`conversation.error.phase.${err.phase}`) + return `${phaseLabel}: ${err.message}` + }, [t]) + const stateLedColor = { idle: 'amber' as const, listening: 'red' as const, @@ -346,6 +358,23 @@ export function VoiceConversationPage(): React.ReactElement { )} + + {/* 에러 배너 — Bug 13 "No speech detected" 등 사용자 피드백 */} + setErrorBanner(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setErrorBanner(null)} + sx={{ width: '100%' }} + > + {errorBanner ? formatErrorMessage(errorBanner) : ''} + + ) } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 8d4bd9d..46d87e3 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -387,6 +387,10 @@ "conversation.recording.hint": "SPEAK NOW · PRESS ⏹ TO SEND", "conversation.thinking.placeholder": "Waiting for response…", "conversation.state.recording": "RECORDING", + "conversation.error.phase.stt": "Speech Recognition", + "conversation.error.phase.llm": "Response", + "conversation.error.phase.tts": "Voice Playback", + "conversation.error.noSpeech": "No speech detected. Check your microphone and try again.", "nav.knowledge": "Knowledge", "rag.title": "Knowledge Base", "rag.addDocument": "Add Document", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 5bd30fb..1fa0444 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -388,6 +388,10 @@ "conversation.recording.hint": "말씀하세요 · ⏹로 전송", "conversation.thinking.placeholder": "응답 대기 중…", "conversation.state.recording": "녹음 중", + "conversation.error.phase.stt": "음성 인식", + "conversation.error.phase.llm": "응답 생성", + "conversation.error.phase.tts": "음성 재생", + "conversation.error.noSpeech": "음성이 감지되지 않았습니다. 마이크를 확인하고 다시 말씀해 주세요.", "rag.title": "지식 베이스", "rag.addDocument": "문서 추가", "rag.documents": "문서",