fix(desktop): Bug 13 전면 해소 — Conversation 에러 배너 + Dictation 빈 STT 가드 + recording tip 타이머 leak
- VoiceConversationPage: Snackbar + Alert(severity=warning filled) 하단 중앙
배너 추가, onError 콜백에서 setErrorBanner. formatErrorMessage 헬퍼 —
phase=stt + 'no speech' → i18n conversation.error.noSpeech, 그 외 →
phaseLabel: rawMessage.
- VoiceModeService._transcribe: minBytes 가드(0.5s @ 16kHz 16bit mono =
16000B) + 빈 result.text 가드. 양쪽 모두 _handleError(STTAudioTooShort /
STTNoAudioData, 'No speech detected...')로 recording-tip popup error
상태 3초 표시 재사용. 기존에는 빈 전사가 조용히 session completed +
빈 history entry 생성되던 경로 차단.
- VoiceModeService._errorHideTimer 필드: _handleError의 setTimeout(hide, 3000)
핸들 보관, _startSession 초기화 블록과 dispose()에서 clearTimeout. 다음
세션 시작 후에도 이전 에러 타이머가 살아 진행 중인 recording tip을 ~2초
지점에 숨기던 잠재 버그 수정 (실측 재현 및 해소 확인).
- i18n ko/en: conversation.error.phase.{stt,llm,tts} + conversation.error.noSpeech
4개 키 추가.
This commit is contained in:
parent
16aaf37956
commit
a744551442
4 changed files with 87 additions and 5 deletions
|
|
@ -107,6 +107,8 @@ class VoiceModeService extends EventEmitter {
|
||||||
|
|
||||||
// 에러 가드
|
// 에러 가드
|
||||||
private _errorEmitted = false
|
private _errorEmitted = false
|
||||||
|
// 에러 popup 3초 hide 예약 타이머 (다음 세션 시작 시 취소해야 현재 recording tip이 살아남음)
|
||||||
|
private _errorHideTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
// Action Queue (이벤트 직렬화)
|
// Action Queue (이벤트 직렬화)
|
||||||
private _actionQueue: VoiceAction[] = []
|
private _actionQueue: VoiceAction[] = []
|
||||||
|
|
@ -249,6 +251,13 @@ class VoiceModeService extends EventEmitter {
|
||||||
this._audioBuffer = []
|
this._audioBuffer = []
|
||||||
this._audioBufferBytes = 0
|
this._audioBufferBytes = 0
|
||||||
|
|
||||||
|
// 이전 에러의 3초 hide 타이머가 살아있으면 취소 (그대로 두면 새 recording tip을
|
||||||
|
// ~2초 지점에서 숨겨버리는 버그 발생). _errorEmitted 리셋과 동일 타이밍.
|
||||||
|
if (this._errorHideTimer) {
|
||||||
|
clearTimeout(this._errorHideTimer)
|
||||||
|
this._errorHideTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
this._setRecognitionState(RecognitionState.PREPARING)
|
this._setRecognitionState(RecognitionState.PREPARING)
|
||||||
this.emit('session-started', { session: this._session })
|
this.emit('session-started', { session: this._session })
|
||||||
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
|
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
|
||||||
|
|
@ -467,6 +476,19 @@ class VoiceModeService extends EventEmitter {
|
||||||
this._audioBuffer = []
|
this._audioBuffer = []
|
||||||
this._audioBufferBytes = 0
|
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 {
|
try {
|
||||||
const stt = getLocalSTTService()
|
const stt = getLocalSTTService()
|
||||||
const language = configGet('sttLanguage')
|
const language = configGet('sttLanguage')
|
||||||
|
|
@ -487,6 +509,18 @@ class VoiceModeService extends EventEmitter {
|
||||||
|
|
||||||
if (this._isInTerminalState()) return
|
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) {
|
if (this._session) {
|
||||||
this._session.transcription = result.text
|
this._session.transcription = result.text
|
||||||
}
|
}
|
||||||
|
|
@ -720,9 +754,14 @@ class VoiceModeService extends EventEmitter {
|
||||||
|
|
||||||
this._stopAudio()
|
this._stopAudio()
|
||||||
|
|
||||||
// Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김
|
// Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김.
|
||||||
|
// 핸들 보관 → 다음 세션 시작 시 clearTimeout으로 취소(없으면 새 recording tip 숨김).
|
||||||
updateRecordingTipState('error', { errorMessage: error.message })
|
updateRecordingTipState('error', { errorMessage: error.message })
|
||||||
setTimeout(() => {
|
if (this._errorHideTimer) {
|
||||||
|
clearTimeout(this._errorHideTimer)
|
||||||
|
}
|
||||||
|
this._errorHideTimer = setTimeout(() => {
|
||||||
|
this._errorHideTimer = null
|
||||||
hideRecordingTip()
|
hideRecordingTip()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
|
|
@ -861,6 +900,12 @@ class VoiceModeService extends EventEmitter {
|
||||||
// 오디오 리스너 해제
|
// 오디오 리스너 해제
|
||||||
this._stopAudio()
|
this._stopAudio()
|
||||||
|
|
||||||
|
// 에러 hide 타이머 정리 (pending 시 메모리 leak 방지)
|
||||||
|
if (this._errorHideTimer) {
|
||||||
|
clearTimeout(this._errorHideTimer)
|
||||||
|
this._errorHideTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
this._setRecognitionState(RecognitionState.DESTROYED)
|
this._setRecognitionState(RecognitionState.DESTROYED)
|
||||||
this.removeAllListeners()
|
this.removeAllListeners()
|
||||||
logger.info('VoiceModeService disposed')
|
logger.info('VoiceModeService disposed')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼.
|
// STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼.
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 MicIcon from '@mui/icons-material/Mic'
|
||||||
import StopIcon from '@mui/icons-material/Stop'
|
import StopIcon from '@mui/icons-material/Stop'
|
||||||
import SendIcon from '@mui/icons-material/Send'
|
import SendIcon from '@mui/icons-material/Send'
|
||||||
|
|
@ -19,6 +19,7 @@ import type {
|
||||||
ConversationState,
|
ConversationState,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ConversationAssistantDelta,
|
ConversationAssistantDelta,
|
||||||
|
ConversationError,
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
|
|
||||||
export function VoiceConversationPage(): React.ReactElement {
|
export function VoiceConversationPage(): React.ReactElement {
|
||||||
|
|
@ -29,6 +30,7 @@ export function VoiceConversationPage(): React.ReactElement {
|
||||||
const [streamingText, setStreamingText] = useState('')
|
const [streamingText, setStreamingText] = useState('')
|
||||||
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
|
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
|
||||||
const [textInput, setTextInput] = useState('')
|
const [textInput, setTextInput] = useState('')
|
||||||
|
const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
|
|
@ -59,8 +61,9 @@ export function VoiceConversationPage(): React.ReactElement {
|
||||||
setStreamingMsgId(null)
|
setStreamingMsgId(null)
|
||||||
setTimeout(scrollToBottom, 50)
|
setTimeout(scrollToBottom, 50)
|
||||||
})
|
})
|
||||||
const unsubError = window.electronAPI.voiceConversation.onError(() => {
|
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
|
||||||
// 에러 시 자동 복구 (서비스에서 listening으로 전환)
|
// 서비스는 자동 복구(listening 재진입)까지 처리. 여기선 배너만 띄운다.
|
||||||
|
setErrorBanner(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 초기 상태 로드
|
// 초기 상태 로드
|
||||||
|
|
@ -127,6 +130,15 @@ export function VoiceConversationPage(): React.ReactElement {
|
||||||
speaking: t('conversation.speaking'),
|
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 = {
|
const stateLedColor = {
|
||||||
idle: 'amber' as const,
|
idle: 'amber' as const,
|
||||||
listening: 'red' as const,
|
listening: 'red' as const,
|
||||||
|
|
@ -346,6 +358,23 @@ export function VoiceConversationPage(): React.ReactElement {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
|
|
||||||
|
{/* 에러 배너 — Bug 13 "No speech detected" 등 사용자 피드백 */}
|
||||||
|
<Snackbar
|
||||||
|
open={errorBanner !== null}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setErrorBanner(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
variant="filled"
|
||||||
|
onClose={() => setErrorBanner(null)}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{errorBanner ? formatErrorMessage(errorBanner) : ''}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,10 @@
|
||||||
"conversation.recording.hint": "SPEAK NOW · PRESS ⏹ TO SEND",
|
"conversation.recording.hint": "SPEAK NOW · PRESS ⏹ TO SEND",
|
||||||
"conversation.thinking.placeholder": "Waiting for response…",
|
"conversation.thinking.placeholder": "Waiting for response…",
|
||||||
"conversation.state.recording": "RECORDING",
|
"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",
|
"nav.knowledge": "Knowledge",
|
||||||
"rag.title": "Knowledge Base",
|
"rag.title": "Knowledge Base",
|
||||||
"rag.addDocument": "Add Document",
|
"rag.addDocument": "Add Document",
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,10 @@
|
||||||
"conversation.recording.hint": "말씀하세요 · ⏹로 전송",
|
"conversation.recording.hint": "말씀하세요 · ⏹로 전송",
|
||||||
"conversation.thinking.placeholder": "응답 대기 중…",
|
"conversation.thinking.placeholder": "응답 대기 중…",
|
||||||
"conversation.state.recording": "녹음 중",
|
"conversation.state.recording": "녹음 중",
|
||||||
|
"conversation.error.phase.stt": "음성 인식",
|
||||||
|
"conversation.error.phase.llm": "응답 생성",
|
||||||
|
"conversation.error.phase.tts": "음성 재생",
|
||||||
|
"conversation.error.noSpeech": "음성이 감지되지 않았습니다. 마이크를 확인하고 다시 말씀해 주세요.",
|
||||||
"rag.title": "지식 베이스",
|
"rag.title": "지식 베이스",
|
||||||
"rag.addDocument": "문서 추가",
|
"rag.addDocument": "문서 추가",
|
||||||
"rag.documents": "문서",
|
"rag.documents": "문서",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue