Phase 12~13 전체 구현: Pro+ 피처 6종 + 음성 대화 + RAG + OS 자동화
Phase 12: - FileTranscriptionService: ffmpeg PCM 변환 + 30초 청크 순차 STT - MeetingSummaryService: 자막 세션 → LLM 자동 요약 + DB summaryText - DictationTemplateService: 필드별 음성 입력 상태 머신 + 프리셋 3개 Phase 13.1: - VoiceConversationService: STT→Ollama /api/chat→TTS 대화 루프 (10턴) - TTSPlaybackService: Windows SAPI 문장 단위 큐 재생 - LocalLLMService.chatStream: Ollama /api/chat 스트리밍 Phase 13.2: - RAGService: Ollama 임베딩 + SQLite 벡터 + 코사인 유사도 검색 - KnowledgeBasePage: 문서 관리 + 질문/답변 UI - PDF 파서: zlib FlateDecode 해제 + BT/ET 텍스트 추출 Phase 13.3: - VoiceActionService: LLM JSON 액션 플랜 생성 + 실행 - 프리셋 6개 (크롬/메모장/탐색기/볼륨), 위험 명령 차단 공통: IPC ~70채널, 에러코드 780-878, i18n 100+키 버그픽스: 라이선스 로컬 키 우선, i18n featureLabel, DOM 중첩
This commit is contained in:
parent
a31f96bbb8
commit
eb83682269
38 changed files with 5678 additions and 19 deletions
152
src/main/services/TTSPlaybackService.ts
Normal file
152
src/main/services/TTSPlaybackService.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue