// 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 { if (!text.trim()) return this._queue.push(text.trim()) if (!this._speaking) { await this._processQueue() } } /** * 문장 배열을 순차 재생. * LLM 스트리밍에서 문장 단위로 호출한다. */ async speakSentences(sentences: string[]): Promise { 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 { 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 { 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 .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 }