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:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View 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
}