feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
376
apps/desktop/src/main/services/VoiceConversationService.ts
Normal file
376
apps/desktop/src/main/services/VoiceConversationService.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
// src/main/services/VoiceConversationService.ts
|
||||
// Phase 13.1: 음성 대화 모드 — STT → LLM(chat) → TTS 루프
|
||||
// 싱글톤 + EventEmitter. 대화 히스토리 최근 10턴 유지.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalLLMService } from './LocalLLMService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getAudioCaptureService } from './AudioCaptureService'
|
||||
import { getTTSPlaybackService } from './TTSPlaybackService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type {
|
||||
ConversationState,
|
||||
ConversationMessage,
|
||||
ConversationSessionInfo,
|
||||
ConversationAssistantDelta,
|
||||
ConversationAssistantMessage,
|
||||
ConversationError,
|
||||
} from '@shared/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
|
||||
|
||||
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('@shared/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()
|
||||
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
|
||||
}
|
||||
|
||||
audioService.start().catch((err) => {
|
||||
logger.error('Failed to start audio capture for conversation:', err)
|
||||
this._emitError('stt', 'Failed to start microphone')
|
||||
})
|
||||
}
|
||||
|
||||
private _stopListening(): void {
|
||||
const audioService = getAudioCaptureService()
|
||||
if (this._audioListenerBound) {
|
||||
audioService.off('audio-data', this._onAudioData)
|
||||
this._audioListenerBound = false
|
||||
}
|
||||
audioService.stop().catch(() => { /* ignore */ })
|
||||
this._audioBuffers = []
|
||||
}
|
||||
|
||||
private _onAudioData = (payload: { buffer: Buffer }): void => {
|
||||
if (this._state !== 'listening') return
|
||||
this._audioBuffers.push(payload.buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 완료 (UI에서 stop 버튼 클릭 시 호출).
|
||||
* 수집된 오디오를 STT로 전사 후 LLM 대화 진행.
|
||||
*/
|
||||
async finishListening(): Promise<void> {
|
||||
if (this._state !== 'listening' || this._audioBuffers.length === 0) return
|
||||
|
||||
this._stopListening()
|
||||
this._setState('thinking')
|
||||
|
||||
const audioBuffer = Buffer.concat(this._audioBuffers)
|
||||
this._audioBuffers = []
|
||||
|
||||
// 최소 오디오 길이 체크 (500ms @ 16kHz 16bit mono)
|
||||
const minBytes = 16000 * 2 * 0.5
|
||||
if (audioBuffer.length < minBytes) {
|
||||
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) {
|
||||
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: nanoid(),
|
||||
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 = nanoid()
|
||||
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, {})
|
||||
}
|
||||
|
||||
// 재생 완료 → 다시 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue