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:
윤찬 2026-04-12 09:46:22 +09:00
parent 16aaf37956
commit a744551442
4 changed files with 87 additions and 5 deletions

View file

@ -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')