d3ro-voice/apps/desktop/src/main/services/TTSPlaybackService.ts
yunchan8804 45a580878a feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
2026-04-08 14:04:41 +09:00

152 lines
3.9 KiB
TypeScript

// 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<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')
}
}
/**
* PowerShell SAPI로 단일 텍스트 재생.
*/
private _speakOne(text: string): Promise<void> {
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
}