Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
220 lines
5.9 KiB
TypeScript
220 lines
5.9 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()) {
|
|
throw new D3ROError(ErrorCode.TTSTextEmpty, 'TTS text is empty')
|
|
}
|
|
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', windowsHide: true })
|
|
|
|
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 resetTTSPlaybackServiceForTests(): void {
|
|
if (instance) instance.removeAllListeners()
|
|
instance = null
|
|
}
|
|
|
|
export function getTTSPlaybackService(): TTSPlaybackService {
|
|
if (!instance) {
|
|
instance = new TTSPlaybackService()
|
|
}
|
|
return instance
|
|
}
|