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:
parent
e420e35ade
commit
412a2e71f9
9 changed files with 385 additions and 94 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue