// src/main/services/TTSPlaybackService.ts // Phase 13.1: TTS 재생 서비스 // Windows SAPI (PowerShell) 기반 로컬 TTS. // 온라인 불필요, 완전 로컬. 문장 단위 큐 재생. import { EventEmitter } from 'events' import { spawn, type ChildProcess } from 'child_process' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@shared/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') } } /** * PowerShell SAPI로 단일 텍스트 재생. */ private _speakOne(text: string): Promise { return new Promise((resolve, reject) => { // 텍스트를 PowerShell 안전 문자열로 이스케이프 const escaped = text .replace(/'/g, "''") .replace(/\n/g, ' ') .replace(/\r/g, '') const rate = this._getRate() 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._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}`)) }) }) } /** * SAPI Rate: -10(매우 느림) ~ 10(매우 빠름), 기본 0 */ private _getRate(): 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 }