d3ro-voice/apps/desktop/src/main/services/VoiceConversationService.ts
윤찬 412a2e71f9 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')로
사용자 피드백(에러 사운드 + 에러 이벤트)을 노출하도록 수정. 사용자가 "⏹ 눌러도
반응 없음"으로 오해하던 증상 해소.
2026-04-11 23:16:34 +09:00

425 lines
13 KiB
TypeScript

// src/main/services/VoiceConversationService.ts
// Phase 13.1: 음성 대화 모드 — STT → LLM(chat) → TTS 루프
// 싱글톤 + EventEmitter. 대화 히스토리 최근 10턴 유지.
import { EventEmitter } from 'events'
import { getLogger } from './LoggerService'
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'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
ConversationState,
ConversationMessage,
ConversationSessionInfo,
ConversationAssistantDelta,
ConversationAssistantMessage,
ConversationError,
} from '@d3ro/core/types'
const logger = getLogger('VoiceConversationService')
/** 대화 히스토리 최대 턴 수 (user+assistant 쌍) */
const MAX_HISTORY_TURNS = 10
/** 시스템 프롬프트 */
const SYSTEM_PROMPT = `You are D3RO, a helpful local AI voice assistant. Respond concisely and naturally, as if having a spoken conversation. Keep answers brief (2-3 sentences) unless the user asks for detail. Respond in the same language the user speaks.`
class VoiceConversationService extends EventEmitter {
private _state: ConversationState = 'idle'
private _messages: ConversationMessage[] = []
private _isActive = false
private _audioBuffers: Buffer[] = []
private _audioListenerBound = false
private _audioLevelListenerBound = false
get state(): ConversationState {
return this._state
}
get isActive(): boolean {
return this._isActive
}
getSessionInfo(): ConversationSessionInfo {
return {
state: this._state,
messages: [...this._messages],
isActive: this._isActive,
}
}
getHistory(): ConversationMessage[] {
return [...this._messages]
}
/**
* 대화 세션 시작. 마이크 캡처를 시작하고 listening 상태로 진입.
*/
async startSession(): Promise<void> {
if (this._isActive) {
throw new D3ROError(ErrorCode.ConversationSessionAlreadyActive, 'Conversation session already active')
}
// 라이센스 체크
try {
const { getLicenseService } = await import('./LicenseService')
const { Feature } = await import('@d3ro/core/types')
const license = getLicenseService()
const access = license.canUse(Feature.VOICE_CONVERSATION)
if (!access.allowed) {
license.promptUpgrade(Feature.VOICE_CONVERSATION, 'tier_required')
throw new D3ROError(ErrorCode.FeatureNotAvailable, 'Pro+ required for voice conversation')
}
} catch (err) {
if (err instanceof D3ROError) throw err
}
this._isActive = true
this._setState('listening')
this._startListening()
// Whisper 모델 사전 로드 (fire-and-forget).
// finishListening → transcribe 호출 시점에 모델이 Ready 상태여야
// 전사가 바로 실행됨. 이미 로드됐거나 로딩 중이면 내부 가드로 no-op.
// (Bug 12: Voice Conversation이 Meeting/Caption 과 달리 loadModel을
// 명시적으로 호출하지 않아 첫 transcribe가 영원히 pending하던 문제)
void getLocalSTTService()
.initialize()
.catch((err) => {
logger.warn('STT 모델 사전 로드 실패 (transcribe 시 재시도):', err)
})
logger.info('Voice conversation session started')
}
/**
* 대화 세션 종료.
*/
stopSession(): void {
if (!this._isActive) return
this._stopListening()
getTTSPlaybackService().stop()
getLocalLLMService().cancelGeneration()
this._isActive = false
this._setState('idle')
logger.info('Voice conversation session stopped')
}
/**
* 텍스트 메시지를 직접 전송 (키보드 입력).
*/
async sendTextMessage(text: string): Promise<void> {
if (!this._isActive) {
throw new D3ROError(ErrorCode.ConversationNoActiveSession, 'No active conversation session')
}
await this._processUserMessage(text)
}
/**
* 현재 LLM 응답 또는 TTS 재생을 취소.
*/
cancelResponse(): void {
getLocalLLMService().cancelGeneration()
getTTSPlaybackService().stop()
if (this._isActive) {
this._setState('listening')
this._startListening()
}
}
/**
* 대화 히스토리 초기화.
*/
clearHistory(): void {
this._messages = []
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.STATE_CHANGED, this.getSessionInfo())
}
// ── 내부 로직 ──
private _setState(state: ConversationState): void {
this._state = state
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.STATE_CHANGED, this.getSessionInfo())
this.emit('state-changed', state)
}
private _startListening(): void {
this._audioBuffers = []
const audioService = getAudioCaptureService()
if (!this._audioListenerBound) {
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 {
const audioService = getAudioCaptureService()
if (this._audioListenerBound) {
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 = []
}
private _onAudioData = (payload: { buffer: Buffer }): void => {
if (this._state !== 'listening') return
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 대화 진행.
*/
async finishListening(): Promise<void> {
if (this._state !== 'listening' || this._audioBuffers.length === 0) {
return
}
// _stopListening()이 내부적으로 this._audioBuffers = []로 리셋하므로,
// concat은 반드시 stop 호출 **전**에 끝내야 한다. (Bug 11)
const audioBuffer = Buffer.concat(this._audioBuffers)
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
}
try {
// STT
const sttService = getLocalSTTService()
const language = (configGet('sttLanguage') as string | undefined) ?? 'auto'
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
}
await this._processUserMessage(result.text.trim())
} catch (err) {
logger.error('STT failed in conversation:', err)
this._emitError('stt', err instanceof Error ? err.message : 'STT failed')
this._setState('listening')
this._startListening()
}
}
private async _processUserMessage(text: string): Promise<void> {
// 사용자 메시지 추가
const userMsg: ConversationMessage = {
id: crypto.randomUUID(),
role: 'user',
content: text,
timestamp: Date.now(),
}
this._messages.push(userMsg)
this._trimHistory()
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.USER_MESSAGE, userMsg)
this._setState('thinking')
try {
// Ollama /api/chat 호출 (스트리밍)
const llmService = getLocalLLMService()
const chatMessages = this._buildChatMessages()
const assistantMsgId = crypto.randomUUID()
let accumulated = ''
const ttsSentences: string[] = []
let sentenceBuffer = ''
const generator = llmService.chatStream(chatMessages)
for await (const token of generator) {
accumulated += token
// 렌더러에 델타 전송
const delta: ConversationAssistantDelta = {
messageId: assistantMsgId,
delta: token,
accumulated,
}
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_DELTA, delta)
// 문장 단위 TTS 큐잉
sentenceBuffer += token
const sentenceEnd = sentenceBuffer.match(/[.!?。!?]\s*/g)
if (sentenceEnd) {
const lastEnd = sentenceBuffer.lastIndexOf(sentenceEnd[sentenceEnd.length - 1])
const completeSentence = sentenceBuffer.slice(
0,
lastEnd + sentenceEnd[sentenceEnd.length - 1].length,
)
sentenceBuffer = sentenceBuffer.slice(
lastEnd + sentenceEnd[sentenceEnd.length - 1].length,
)
if (completeSentence.trim()) {
ttsSentences.push(completeSentence.trim())
}
}
}
// 남은 텍스트도 TTS 큐에 추가
if (sentenceBuffer.trim()) {
ttsSentences.push(sentenceBuffer.trim())
}
// 어시스턴트 메시지 저장
const assistantMsg: ConversationMessage = {
id: assistantMsgId,
role: 'assistant',
content: accumulated,
timestamp: Date.now(),
}
this._messages.push(assistantMsg)
this._trimHistory()
const completeEvent: ConversationAssistantMessage = {
messageId: assistantMsgId,
content: accumulated,
}
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_MESSAGE, completeEvent)
// TTS 재생
if (ttsSentences.length > 0) {
this._setState('speaking')
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED, {})
const ttsService = getTTSPlaybackService()
await ttsService.speakSentences(ttsSentences)
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED, {})
// 응답 완료 chime (자동 listening 재진입 직전)
getSoundEffectService().play('chime')
}
// 재생 완료 → 다시 listening
if (this._isActive) {
this._setState('listening')
this._startListening()
}
} catch (err) {
logger.error('LLM chat failed in conversation:', err)
this._emitError('llm', err instanceof Error ? err.message : 'LLM failed')
if (this._isActive) {
this._setState('listening')
this._startListening()
}
}
}
private _buildChatMessages(): Array<{ role: string; content: string }> {
const chatMsgs: Array<{ role: string; content: string }> = [
{ role: 'system', content: SYSTEM_PROMPT },
]
for (const msg of this._messages) {
if (msg.role === 'user' || msg.role === 'assistant') {
chatMsgs.push({ role: msg.role, content: msg.content })
}
}
return chatMsgs
}
private _trimHistory(): void {
// user+assistant 쌍 기준으로 최근 MAX_HISTORY_TURNS개만 유지
const pairs: ConversationMessage[] = []
let turnCount = 0
for (let i = this._messages.length - 1; i >= 0; i--) {
pairs.unshift(this._messages[i])
if (this._messages[i].role === 'user') {
turnCount++
if (turnCount >= MAX_HISTORY_TURNS) break
}
}
this._messages = pairs
}
private _emitError(phase: ConversationError['phase'], message: string): void {
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 {
try {
const mainWindow = getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data)
}
} catch {
// 윈도우 없으면 무시
}
}
dispose(): void {
this.stopSession()
this.removeAllListeners()
}
}
// ── 싱글톤 ──
let instance: VoiceConversationService | null = null
export function getVoiceConversationService(): VoiceConversationService {
if (!instance) {
instance = new VoiceConversationService()
}
return instance
}