From ee6ac3dbfcfd03a5aaeeef308208224a26e8fa1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 22:59:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20Voice=20Conversation=20Mac=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=E2=80=94=20TTS=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20finishListening=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EC=88=9C=EC=84=9C=20+=20Whisper=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=20=EB=A1=9C=EB=93=9C=20(=EB=B9=85=EB=B1=85=20Phase=20?= =?UTF-8?q?5=20Part=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice Conversation이 Mac에서 한 번도 end-to-end 동작한 적 없었음을 발견. 3개 독립 버그가 중첩돼 있었고, 진단 로그를 추가해 단계적으로 분해하여 모두 해결. Bug 10.5 — TTSPlaybackService 'spawn powershell ENOENT' (Mac) - 원인: _speakOne이 spawn('powershell', ...) 하드코딩. 파일 주석부터 "Windows SAPI (PowerShell) 기반". V2-5 플랫폼 감사 때 이 파일 누락. - Fix: _speakOne을 platform dispatcher로 변경, _speakOneMac(/usr/bin/say -r --) + _speakOneWindows(기존 PowerShell) 분리. 공통 close/error 핸들러는 _bindProcessHandlers로 추출. rate 매핑은 _getMacRate(180*speed, WPM 기준) / _getWindowsRate(-10~10, SAPI) 분리. - 파급: 음성 대화뿐 아니라 TTS를 쓰는 모든 경로가 Mac에서 벙어리였음. Bug 11 — VoiceConversationService.finishListening 오디오 버퍼 스냅샷 순서 (진짜 root cause) - 증상: finishListening 호출 시 audioBuffer가 항상 0 bytes → "audio too short"로 조용히 listening 복귀. STT가 한 번도 안 탐. Windows에서도 동일 버그였을 가능성 매우 높음. - 원인: _stopListening() 내부에 this._audioBuffers = []로 리셋하는 라인이 있는데, finishListening이 concat을 stop 호출 **이후**에 수행 → 이미 빈 배열에서 concat. 19초짜리 1.7MB 오디오가 매번 증발. - Fix: _stopListening() 호출 **전**에 const audioBuffer = Buffer.concat(this._audioBuffers) 스냅샷 저장. finishListening 내 redundant한 this._audioBuffers = [] 재설정도 제거(이미 _stopListening이 수행). Bug 12 — Whisper 모델 사전 로드 누락 - 증상: Bug 11 fix 후 STT 경로로는 진입하지만 LocalSTTService가 "모델 로딩 중, 오디오 버퍼에 적재"로 pending 큐에 쌓기만 하고 아무도 로드를 트리거하지 않아 영원히 대기. - 원인: MeetingMode/CaptionService는 startRecording 시 LocalSTTService.initialize()를 명시적으로 호출하지만 VoiceConversationService는 이 호출 누락. - Fix: startSession에서 void getLocalSTTService().initialize().catch(...) fire-and-forget. initialize는 같은 모델이 Ready면 즉시 return하므로 idempotent. 검증: - desktop tsc --noEmit EXIT=0 - /usr/bin/say -r 180 "TTS 분기 테스트 성공" 직접 호출 → 스피커 소리 확인 - 사용자 재시연: Mic → 말함 → ⏹ Stop → 전사 → LLM 스트리밍 → TTS 소리 → 자동 listening 복귀 연속 대화까지 end-to-end 통과 남은 과제 (다음 세션 U6/U8): - Voice Conversation UX 몰입 패널 (9바 waveform + REC LED + 타이머) — 설계 완료 - Bug 13: 빈 STT 결과 시 사용자 피드백 부재 (현재는 조용히 listening 복귀 → 버튼 먹통처럼 보임) --- .../src/main/services/TTSPlaybackService.ts | 97 +++++++++++++++---- .../main/services/VoiceConversationService.ts | 23 ++++- 2 files changed, 98 insertions(+), 22 deletions(-) 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) {