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 <wpm> --) +
_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 복귀 → 버튼 먹통처럼 보임)
213 lines
5.7 KiB
TypeScript
213 lines
5.7 KiB
TypeScript
// src/main/services/TTSPlaybackService.ts
|
|
// Phase 13.1: TTS 재생 서비스
|
|
// 플랫폼별 로컬 TTS — macOS `say`, Windows SAPI(PowerShell).
|
|
// 온라인 불필요, 완전 로컬. 문장 단위 큐 재생.
|
|
|
|
import { EventEmitter } from 'events'
|
|
import { spawn, type ChildProcess } from 'child_process'
|
|
import { getLogger } from './LoggerService'
|
|
import { configGet } from './ConfigService'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
|
|
const logger = getLogger('TTSPlaybackService')
|
|
|
|
class TTSPlaybackService extends EventEmitter {
|
|
private _speaking = false
|
|
private _queue: string[] = []
|
|
private _currentProcess: ChildProcess | null = null
|
|
private _cancelled = false
|
|
|
|
get isSpeaking(): boolean {
|
|
return this._speaking
|
|
}
|
|
|
|
/**
|
|
* 텍스트를 음성으로 재생 (Windows SAPI).
|
|
* 큐에 추가되어 순차 재생된다.
|
|
*/
|
|
async speak(text: string): Promise<void> {
|
|
if (!text.trim()) return
|
|
this._queue.push(text.trim())
|
|
if (!this._speaking) {
|
|
await this._processQueue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 문장 배열을 순차 재생.
|
|
* LLM 스트리밍에서 문장 단위로 호출한다.
|
|
*/
|
|
async speakSentences(sentences: string[]): Promise<void> {
|
|
for (const sentence of sentences) {
|
|
if (this._cancelled) break
|
|
this._queue.push(sentence.trim())
|
|
}
|
|
if (!this._speaking) {
|
|
await this._processQueue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 재생 중단 + 큐 비우기.
|
|
*/
|
|
stop(): void {
|
|
this._cancelled = true
|
|
this._queue = []
|
|
if (this._currentProcess) {
|
|
this._currentProcess.kill()
|
|
this._currentProcess = null
|
|
}
|
|
this._speaking = false
|
|
this.emit('stopped')
|
|
}
|
|
|
|
private async _processQueue(): Promise<void> {
|
|
this._speaking = true
|
|
this._cancelled = false
|
|
this.emit('started')
|
|
|
|
while (this._queue.length > 0 && !this._cancelled) {
|
|
const text = this._queue.shift()!
|
|
try {
|
|
await this._speakOne(text)
|
|
} catch (err) {
|
|
logger.warn('TTS playback failed for segment:', err)
|
|
}
|
|
}
|
|
|
|
this._speaking = false
|
|
if (!this._cancelled) {
|
|
this.emit('finished')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플랫폼별 TTS 재생 dispatcher.
|
|
* macOS는 내장 `say`, Windows는 PowerShell SAPI.
|
|
*/
|
|
private _speakOne(text: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
return new Promise((resolve, reject) => {
|
|
// 텍스트를 PowerShell 안전 문자열로 이스케이프
|
|
const escaped = text
|
|
.replace(/'/g, "''")
|
|
.replace(/\n/g, ' ')
|
|
.replace(/\r/g, '')
|
|
|
|
const rate = this._getWindowsRate()
|
|
|
|
const script = `
|
|
Add-Type -AssemblyName System.Speech
|
|
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
$synth.Rate = ${rate}
|
|
$synth.Speak('${escaped}')
|
|
$synth.Dispose()
|
|
`
|
|
|
|
this._currentProcess = spawn('powershell', [
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
script,
|
|
], { stdio: 'pipe' })
|
|
|
|
this._bindProcessHandlers(resolve, reject)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* spawn된 `_currentProcess`에 close/error 핸들러를 바인딩.
|
|
* mac/win 공통 처리 — cancelled 상태나 exit 0은 resolve, 그 외 reject.
|
|
*/
|
|
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
|
|
return Math.round((speed - 1.0) * 5)
|
|
}
|
|
|
|
dispose(): void {
|
|
this.stop()
|
|
this.removeAllListeners()
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ──
|
|
let instance: TTSPlaybackService | null = null
|
|
|
|
export function getTTSPlaybackService(): TTSPlaybackService {
|
|
if (!instance) {
|
|
instance = new TTSPlaybackService()
|
|
}
|
|
return instance
|
|
}
|