feat(desktop): Voice Conversation 몰입 UX 패널 + 사운드 피드백 + Bug 13 빈 STT (빅뱅 Phase 5 Part 6)

listening 상태에서 풀 몰입 계측기 모드로 전환되는 VoiceRecordingPanel 추가.
recording-tip 팝업의 9바 cos-분포 waveform(BAR_COUNT=9, SMOOTHING=0.5,
RANDOM_FACTOR=0.35, 100ms)을 React로 포팅해 REC LED + elapsed 타이머 +
"SPEAK NOW" 힌트까지 구성. thinking/speaking 상태에서는 메시지 리스트로
복귀해 대화 맥락 유지 + 점 3개 typing indicator 버블 추가.

VoiceConversationService에 AudioCaptureService audio-level forwarding과
사운드 훅 4개(recording-start / recording-stop / chime / error)를 삽입.
chime은 recording-stop.wav 재사용(SoundEffectService SoundName 확장).
VOICE_CONVERSATION.AUDIO_LEVEL 채널 신설 + preload onAudioLevel API.

U8 Bug 13 동반 해소: finishListening에서 minBytes 미달 또는 VAD 무음 판정으로
빈 텍스트가 나오는 경우 조용히 listening으로 복귀하던 것을 _emitError('stt')로
사용자 피드백(에러 사운드 + 에러 이벤트)을 노출하도록 수정. 사용자가 "⏹ 눌러도
반응 없음"으로 오해하던 증상 해소.
This commit is contained in:
윤찬 2026-04-11 23:16:34 +09:00
parent e420e35ade
commit 412a2e71f9
9 changed files with 385 additions and 94 deletions

View file

@ -8,6 +8,7 @@ import { getLocalLLMService } from './LocalLLMService'
import { getLocalSTTService } from './LocalSTTService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getTTSPlaybackService } from './TTSPlaybackService'
import { getSoundEffectService } from './SoundEffectService'
import { configGet } from './ConfigService'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
@ -34,6 +35,7 @@ class VoiceConversationService extends EventEmitter {
private _isActive = false
private _audioBuffers: Buffer[] = []
private _audioListenerBound = false
private _audioLevelListenerBound = false
get state(): ConversationState {
return this._state
@ -157,11 +159,18 @@ class VoiceConversationService extends EventEmitter {
audioService.on('audio-data', this._onAudioData)
this._audioListenerBound = true
}
if (!this._audioLevelListenerBound) {
audioService.on('audio-level', this._onAudioLevel)
this._audioLevelListenerBound = true
}
audioService.start().catch((err) => {
logger.error('Failed to start audio capture for conversation:', err)
this._emitError('stt', 'Failed to start microphone')
})
// 녹음 시작 사운드 (fire-and-forget)
getSoundEffectService().play('recording-start')
}
private _stopListening(): void {
@ -170,6 +179,10 @@ class VoiceConversationService extends EventEmitter {
audioService.off('audio-data', this._onAudioData)
this._audioListenerBound = false
}
if (this._audioLevelListenerBound) {
audioService.off('audio-level', this._onAudioLevel)
this._audioLevelListenerBound = false
}
audioService.stop().catch(() => { /* ignore */ })
this._audioBuffers = []
}
@ -179,6 +192,16 @@ class VoiceConversationService extends EventEmitter {
this._audioBuffers.push(payload.buffer)
}
/**
* AudioCaptureService가 100ms emit하는 audio-level을
* listening forwarding. VoiceRecordingPanel에서
* 9 waveform .
*/
private _onAudioLevel = (payload: { level: number; timestamp: number }): void => {
if (this._state !== 'listening') return
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.AUDIO_LEVEL, { level: payload.level })
}
/**
* (UI에서 stop ).
* STT로 LLM .
@ -195,9 +218,14 @@ class VoiceConversationService extends EventEmitter {
this._stopListening()
this._setState('thinking')
// 녹음 종료 사운드 (fire-and-forget)
getSoundEffectService().play('recording-stop')
// 최소 오디오 길이 체크 (500ms @ 16kHz 16bit mono)
// Bug 13: 너무 짧으면 조용히 listening 복귀 대신 에러 피드백.
const minBytes = 16000 * 2 * 0.5
if (audioBuffer.length < minBytes) {
this._emitError('stt', 'No speech detected. Please speak and try again.')
this._setState('listening')
this._startListening()
return
@ -210,6 +238,8 @@ class VoiceConversationService extends EventEmitter {
const result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
if (!result.text || result.text.trim().length === 0) {
// Bug 13: VAD가 전체 오디오를 무음 판정한 경우에도 사용자 피드백.
this._emitError('stt', 'No speech detected. Check microphone and try again.')
this._setState('listening')
this._startListening()
return
@ -309,6 +339,9 @@ class VoiceConversationService extends EventEmitter {
await ttsService.speakSentences(ttsSentences)
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED, {})
// 응답 완료 chime (자동 listening 재진입 직전)
getSoundEffectService().play('chime')
}
// 재생 완료 → 다시 listening
@ -360,6 +393,8 @@ class VoiceConversationService extends EventEmitter {
const error: ConversationError = { message, phase }
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.ERROR, error)
this.emit('error', error)
// 에러 사운드 (fire-and-forget)
getSoundEffectService().play('error')
}
private _sendToRenderer(channel: string, data: unknown): void {