diff --git a/apps/desktop/src/main/services/TTSPlaybackService.ts b/apps/desktop/src/main/services/TTSPlaybackService.ts index 7eda1a8..a345def 100644 --- a/apps/desktop/src/main/services/TTSPlaybackService.ts +++ b/apps/desktop/src/main/services/TTSPlaybackService.ts @@ -1,6 +1,6 @@ // src/main/services/TTSPlaybackService.ts // Phase 13.1: TTS 재생 서비스 -// Windows SAPI (PowerShell) 기반 로컬 TTS. +// 플랫폼별 로컬 TTS — macOS `say`, Windows SAPI(PowerShell). // 온라인 불필요, 완전 로컬. 문장 단위 큐 재생. import { EventEmitter } from 'events' @@ -82,9 +82,46 @@ class TTSPlaybackService extends EventEmitter { } /** - * PowerShell SAPI로 단일 텍스트 재생. + * 플랫폼별 TTS 재생 dispatcher. + * macOS는 내장 `say`, Windows는 PowerShell SAPI. */ private _speakOne(text: string): Promise { + if (process.platform === 'darwin') { + return this._speakOneMac(text) + } + if (process.platform === 'win32') { + return this._speakOneWindows(text) + } + return Promise.reject( + new D3ROError( + ErrorCode.ConversationTTSFailed, + `TTS not supported on platform: ${process.platform}`, + ), + ) + } + + /** + * macOS 내장 `say` 명령어로 단일 텍스트 재생. + * rate 단위: words per minute (기본 180). + * 텍스트는 argv로 직접 넘기므로 shell escape 불필요. `--` 구분자로 + * `-`로 시작하는 텍스트가 플래그로 해석되는 것을 방지. + */ + private _speakOneMac(text: string): Promise { + return new Promise((resolve, reject) => { + const rate = this._getMacRate() + this._currentProcess = spawn( + 'say', + ['-r', String(rate), '--', text], + { stdio: 'pipe' }, + ) + this._bindProcessHandlers(resolve, reject) + }) + } + + /** + * Windows PowerShell SAPI로 단일 텍스트 재생. + */ + private _speakOneWindows(text: string): Promise { return new Promise((resolve, reject) => { // 텍스트를 PowerShell 안전 문자열로 이스케이프 const escaped = text @@ -92,7 +129,7 @@ class TTSPlaybackService extends EventEmitter { .replace(/\n/g, ' ') .replace(/\r/g, '') - const rate = this._getRate() + const rate = this._getWindowsRate() const script = ` Add-Type -AssemblyName System.Speech @@ -109,26 +146,50 @@ class TTSPlaybackService extends EventEmitter { script, ], { stdio: 'pipe' }) - this._currentProcess.on('close', (code) => { - this._currentProcess = null - if (code === 0 || this._cancelled) { - resolve() - } else { - reject(new D3ROError(ErrorCode.ConversationTTSFailed, `TTS exited with code ${code}`)) - } - }) - - this._currentProcess.on('error', (err) => { - this._currentProcess = null - reject(new D3ROError(ErrorCode.ConversationTTSFailed, `TTS error: ${err.message}`)) - }) + this._bindProcessHandlers(resolve, reject) }) } /** - * SAPI Rate: -10(매우 느림) ~ 10(매우 빠름), 기본 0 + * spawn된 `_currentProcess`에 close/error 핸들러를 바인딩. + * mac/win 공통 처리 — cancelled 상태나 exit 0은 resolve, 그 외 reject. */ - private _getRate(): number { + private _bindProcessHandlers( + resolve: () => void, + reject: (err: Error) => void, + ): void { + if (!this._currentProcess) { + reject(new D3ROError(ErrorCode.ConversationTTSFailed, 'TTS process not spawned')) + return + } + this._currentProcess.on('close', (code) => { + this._currentProcess = null + if (code === 0 || this._cancelled) { + resolve() + } else { + reject(new D3ROError(ErrorCode.ConversationTTSFailed, `TTS exited with code ${code}`)) + } + }) + this._currentProcess.on('error', (err) => { + this._currentProcess = null + reject(new D3ROError(ErrorCode.ConversationTTSFailed, `TTS error: ${err.message}`)) + }) + } + + /** + * macOS `say` rate: words per minute. 기본 180. + * ttsSpeed 0.5→90, 1.0→180, 2.0→360 + */ + private _getMacRate(): number { + const speed = configGet('ttsSpeed') as number | undefined + if (!speed) return 180 + return Math.round(180 * speed) + } + + /** + * Windows SAPI Rate: -10(매우 느림) ~ 10(매우 빠름), 기본 0 + */ + private _getWindowsRate(): number { const speed = configGet('ttsSpeed') as number | undefined if (!speed || speed === 1.0) return 0 // 0.5 → -5, 1.0 → 0, 2.0 → 5 diff --git a/apps/desktop/src/main/services/VoiceConversationService.ts b/apps/desktop/src/main/services/VoiceConversationService.ts index 77c6ecd..059ee02 100644 --- a/apps/desktop/src/main/services/VoiceConversationService.ts +++ b/apps/desktop/src/main/services/VoiceConversationService.ts @@ -80,6 +80,18 @@ class VoiceConversationService extends EventEmitter { 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') } @@ -172,14 +184,17 @@ class VoiceConversationService extends EventEmitter { * 수집된 오디오를 STT로 전사 후 LLM 대화 진행. */ async finishListening(): Promise { - if (this._state !== 'listening' || this._audioBuffers.length === 0) return + 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') - const audioBuffer = Buffer.concat(this._audioBuffers) - this._audioBuffers = [] - // 최소 오디오 길이 체크 (500ms @ 16kHz 16bit mono) const minBytes = 16000 * 2 * 0.5 if (audioBuffer.length < minBytes) {