Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템

Phase 10 킬러 피처:
- MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB)
- VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종
- ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트
- ChainService: LLM 명령어 순차 실행 파이프라인
- CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백

VoiceModeService 파이프라인 통합:
- 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입

시스템 오디오 캡처:
- setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지)
- electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현

Phase 11 수익화:
- LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API
- Feature Gate: requireFeature/checkFeature/consumeFeature
- 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage)
- LicenseModal, ProBadge, UpgradePromptModal UI

디자인 보강:
- d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템
- ScreenPanel, ButtonGroup DS 컴포넌트 신규
- PhosphorText 4→13종 변형, MetalDial conic-gradient 광택
- 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard

기타:
- 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings)
- StatusBar 자막 LED + 효과음, 자막 로딩 UI
- LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged)
- 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View file

@ -0,0 +1,557 @@
// src/main/services/CaptionService.ts
// Phase 10.1: Live Caption — 실시간 자막 서비스
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import { getLogger } from './LoggerService'
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
import { getSoundEffectService } from './SoundEffectService'
import { getLocalSTTService } from './LocalSTTService'
import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService'
import {
showCaptionOverlay,
hideCaptionOverlay,
sendToCaptionOverlay,
} from '../windows/WindowManager'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { D3ROError, ErrorCode } from '@shared/errors'
import type {
CaptionState,
CaptionSegment,
CaptionConfig,
CaptionSessionSummary,
} from '@shared/types'
import { getMainWindow } from '../windows/WindowManager'
const logger = getLogger('CaptionService')
/** 청크 수집 간격 (ms) — 6초로 충분한 컨텍스트 확보 */
const CHUNK_INTERVAL_MS = 6000
/** RMS 무음 임계값 — 이하면 무음으로 판정 (SoX 캡처 레벨이 낮으므로 0.003 사용) */
const SILENCE_RMS_THRESHOLD = 0.003
/** 유성음 프레임 비율 — 이 비율 미만이면 청크 스킵 (환각 방지) */
const VOICED_FRAME_RATIO = 0.03
/** initialPrompt 컨텍스트 윈도우 (자) */
const CONTEXT_WINDOW_SIZE = 300
/** 기본 자막 설정 */
const DEFAULT_CONFIG: CaptionConfig = {
fontSize: 18,
opacity: 0.85,
maxLines: 3,
autoClearMs: 5000,
audioSource: 'mic',
}
interface CaptionServiceEvents {
'state-changed': (state: CaptionState) => void
'segment': (segment: CaptionSegment) => void
'delta': (data: { text: string; isFinal: boolean }) => void
'session-saved': (summary: CaptionSessionSummary) => void
'error': (error: D3ROError) => void
}
class CaptionService extends EventEmitter {
private _state: CaptionState = 'inactive'
private _config: CaptionConfig = { ...DEFAULT_CONFIG }
private _sessionId: string | null = null
private _sessionStartedAt: number | null = null
private _segments: CaptionSegment[] = []
private _audioBuffers: Buffer[] = []
private _chunkTimer: ReturnType<typeof setInterval> | null = null
private _isProcessingChunk = false
private _previousContext = ''
private _disposed = false
/** 현재 청크 내 유성음 프레임 수 */
private _voicedFrameCount = 0
/** 현재 청크 내 총 프레임 수 */
private _totalFrameCount = 0
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
/** 시스템 오디오용 별도 버퍼 (audioSource='system' 또는 'both') */
private _systemAudioBuffers: Buffer[] = []
private _systemVoicedFrameCount = 0
private _systemTotalFrameCount = 0
// ── 공개 접근자 ──
getState(): CaptionState {
return this._state
}
getConfig(): CaptionConfig {
return { ...this._config }
}
setConfig(partial: Partial<CaptionConfig>): void {
this._config = { ...this._config, ...partial }
logger.debug(`Caption config updated: ${JSON.stringify(this._config)}`)
// 오버레이에 설정 변경 알림
sendToCaptionOverlay('caption:config', this._config)
}
// ── 시작 ──
async start(): Promise<void> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.CaptionStartFailed,
'CaptionService가 이미 dispose되었습니다',
)
}
if (this._state !== 'inactive') {
throw new D3ROError(
ErrorCode.CaptionAlreadyActive,
`캡션이 이미 활성 상태입니다: ${this._state}`,
)
}
this._setState('starting')
// 오버레이를 즉시 표시 (로딩 상태)
showCaptionOverlay()
sendToCaptionOverlay('caption:config', this._config)
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
try {
// STT 초기화 (모델 로딩 — 시간 소요)
const sttService = getLocalSTTService()
const modelId = configGet('sttModelId') as string | undefined
await sttService.initialize(modelId)
// 세션 초기화
this._sessionId = nanoid()
this._sessionStartedAt = Date.now()
this._segments = []
this._audioBuffers = []
// 초기 컨텍스트 힌트 — Whisper 첫 청크 환각 방지
const lang = configGet('sttLanguage') as string | undefined
this._previousContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
this._isProcessingChunk = false
this._voicedFrameCount = 0
this._totalFrameCount = 0
// ConfigService에서 저장된 오디오 소스 읽기
const savedSource = configGet('captionAudioSource' as keyof import('@shared/types').AppConfig) as unknown as string
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
}
const { audioSource } = this._config
logger.info(`Caption audioSource: ${audioSource}`)
// 마이크 캡처 (mic 또는 both)
if (audioSource === 'mic' || audioSource === 'both') {
const audioCaptureService = getAudioCaptureService()
this._audioDataHandler = (payload) => {
this._onAudioData(payload.buffer)
}
audioCaptureService.on('audio-data', this._audioDataHandler)
await audioCaptureService.start()
}
// 시스템 오디오 캡처 요청 (system 또는 both) — 렌더러에 시작 요청
if (audioSource === 'system' || audioSource === 'both') {
this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {})
this._systemAudioBuffers = []
this._systemVoicedFrameCount = 0
this._systemTotalFrameCount = 0
}
// 청크 타이머 시작
this._chunkTimer = setInterval(() => {
const promises: Promise<void>[] = []
// 마이크 청크 처리
if (audioSource === 'mic' || audioSource === 'both') {
promises.push(
this._processChunk().catch((err: unknown) => {
logger.error(`마이크 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`)
})
)
}
// 시스템 오디오 청크 처리
if (audioSource === 'system' || audioSource === 'both') {
promises.push(
this._processSystemChunk().catch((err: unknown) => {
logger.error(`시스템 오디오 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`)
})
)
}
Promise.all(promises).catch(() => { /* 개별 에러는 이미 로깅됨 */ })
}, CHUNK_INTERVAL_MS)
this._setState('active')
getSoundEffectService().play('recording-start')
logger.info(`Live Caption 시작: sessionId=${this._sessionId}`)
} catch (err) {
this._setState('inactive')
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.CaptionStartFailed,
`캡션 시작 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this.emit('error', d3roErr)
throw d3roErr
}
}
// ── 중지 ──
async stop(): Promise<void> {
if (this._state !== 'active' && this._state !== 'starting') {
logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`)
return
}
this._setState('stopping')
// 타이머 정리
if (this._chunkTimer) {
clearInterval(this._chunkTimer)
this._chunkTimer = null
}
// 잔여 오디오 처리
try {
await this._processChunk()
} catch (err) {
logger.warn(
`잔여 오디오 처리 실패: ${err instanceof Error ? err.message : String(err)}`,
)
}
// 마이크 캡처 정리
if (this._audioDataHandler) {
const audioCaptureService = getAudioCaptureService()
audioCaptureService.off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
await audioCaptureService.stop()
}
// 시스템 오디오 캡처 중지 요청
this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {})
this._systemAudioBuffers = []
// 오버레이 숨김
hideCaptionOverlay()
// 세션을 히스토리에 저장
const summary = this._saveSession()
this._sessionId = null
this._sessionStartedAt = null
this._audioBuffers = []
this._previousContext = ''
this._setState('inactive')
getSoundEffectService().play('recording-stop')
logger.info('Live Caption 중지')
if (summary) {
this.emit('session-saved', summary)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary)
}
}
// ── dispose ──
async dispose(): Promise<void> {
if (this._disposed) return
this._disposed = true
if (this._state === 'active' || this._state === 'starting') {
await this.stop()
}
this.removeAllListeners()
logger.info('CaptionService disposed')
}
// ── 내부: 오디오 데이터 수집 ──
private _onAudioData(buffer: Buffer): void {
if (this._state !== 'active') return
this._audioBuffers.push(buffer)
// RMS 기반 유성음 감지 (환각 방지)
this._totalFrameCount++
const rms = calculateRMS(buffer)
if (rms >= SILENCE_RMS_THRESHOLD) {
this._voicedFrameCount++
}
}
/**
* PCM16 ( IPC ).
* systemAudioCapture.ts가 16kHz mono PCM16으로 .
*/
onSystemAudioData(buffer: Buffer): void {
if (this._state !== 'active') return
this._systemAudioBuffers.push(buffer)
this._systemTotalFrameCount++
const rms = calculateRMS(buffer)
if (rms >= SILENCE_RMS_THRESHOLD) {
this._systemVoicedFrameCount++
}
}
// ── 내부: 3초 청크 처리 ──
private async _processChunk(): Promise<void> {
if (this._audioBuffers.length === 0) return
if (this._isProcessingChunk) return
this._isProcessingChunk = true
// VAD 카운터 리셋 (로깅용)
const voicedRatio = this._totalFrameCount > 0
? this._voicedFrameCount / this._totalFrameCount
: 0
this._voicedFrameCount = 0
this._totalFrameCount = 0
logger.debug(`청크 처리: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`)
// VAD 필터링은 faster-whisper 사이드카에서 처리 (vadFilter: true)
// SoX의 마이크 캡처 레벨이 매우 낮아 로컬 RMS 게이트는 신뢰 불가
try {
const merged = Buffer.concat(this._audioBuffers)
this._audioBuffers = []
// 최소 오디오 크기 확인 (100ms 분량 이상)
const minBytes = 16000 * 2 * 0.1 // 100ms @ 16kHz 16bit mono
if (merged.length < minBytes) {
return
}
const sttService = getLocalSTTService()
const language = configGet('sttLanguage') as string | undefined
const result = await sttService.transcribe(merged, {
language: language ?? 'auto',
initialPrompt: this._previousContext,
vadFilter: true,
})
if (!result.text || result.text.trim().length === 0) {
return
}
const segment: CaptionSegment = {
id: nanoid(),
text: result.text.trim(),
timestamp: Date.now(),
isFinal: true,
}
this._segments.push(segment)
// 다음 청크를 위한 컨텍스트 업데이트 (연속성 유지)
this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE)
// 이벤트 emit
this.emit('segment', segment)
// 오버레이 윈도우에 전송
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment)
// 메인 윈도우에 전송 (Dashboard 등에서 사용)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment)
logger.debug(`자막 세그먼트: "${segment.text.substring(0, 50)}"`)
} catch (err) {
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.CaptionSTTFailed,
`자막 전사 실패: ${err instanceof Error ? err.message : String(err)}`,
)
logger.error(`자막 전사 에러: ${d3roErr.message}`)
this.emit('error', d3roErr)
} finally {
this._isProcessingChunk = false
}
}
// ── 내부: 시스템 오디오 청크 처리 ──
private async _processSystemChunk(): Promise<void> {
if (this._systemAudioBuffers.length === 0) return
// VAD 게이트
const voicedRatio = this._systemTotalFrameCount > 0
? this._systemVoicedFrameCount / this._systemTotalFrameCount
: 0
this._systemVoicedFrameCount = 0
this._systemTotalFrameCount = 0
if (voicedRatio < VOICED_FRAME_RATIO) {
this._systemAudioBuffers = []
logger.debug(`시스템 오디오 청크 스킵: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`)
return
}
const merged = Buffer.concat(this._systemAudioBuffers)
this._systemAudioBuffers = []
const minBytes = 16000 * 2 * 0.1
if (merged.length < minBytes) return
try {
const sttService = getLocalSTTService()
const language = configGet('sttLanguage') as string | undefined
const result = await sttService.transcribe(merged, {
language: language ?? 'auto',
initialPrompt: this._previousContext,
vadFilter: true,
})
if (!result.text || result.text.trim().length === 0) return
const segment: CaptionSegment = {
id: nanoid(),
text: result.text.trim(),
timestamp: Date.now(),
isFinal: true,
}
this._segments.push(segment)
this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE)
this.emit('segment', segment)
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment)
logger.debug(`시스템 자막: "${segment.text.substring(0, 50)}"`)
} catch (err) {
logger.error(`시스템 오디오 전사 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ── 내부: 세션 저장 ──
private _saveSession(): CaptionSessionSummary | null {
if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) {
return null
}
const endedAt = Date.now()
const totalDurationMs = endedAt - this._sessionStartedAt
const fullText = this._segments.map((s) => s.text).join(' ')
const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length
// 히스토리에 저장 (mode: 'caption')
try {
const historyService = getHistoryService()
historyService.create({
originalText: fullText,
polishedText: null,
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: 'caption',
status: 'completed',
errorCode: null,
audioLocalPath: null,
duration: totalDurationMs / 1000,
detectedLanguage: null,
micDevice: null,
wordCount,
sttModel: configGet('sttModelId') as string | null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
appVersion: '1.0.0',
})
logger.info(
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}`,
)
} catch (err) {
logger.error(
`캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`,
)
}
const summary: CaptionSessionSummary = {
sessionId: this._sessionId,
segments: [...this._segments],
startedAt: this._sessionStartedAt,
endedAt,
totalDurationMs,
}
this._segments = []
return summary
}
// ── 내부: 상태 전이 ──
private _setState(newState: CaptionState): void {
if (this._state === newState) return
const prev = this._state
this._state = newState
logger.debug(`CaptionState: ${prev} -> ${newState}`)
this.emit('state-changed', newState)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: newState })
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: newState })
}
// ── 내부: 메인 윈도우에 IPC 전송 ──
private _sendToMainWindow(channel: string, data: unknown): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data)
}
}
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
override emit<K extends keyof CaptionServiceEvents>(
event: K,
...args: Parameters<CaptionServiceEvents[K]>
): boolean {
return super.emit(event, ...args)
}
override on<K extends keyof CaptionServiceEvents>(
event: K,
listener: CaptionServiceEvents[K],
): this {
return super.on(event, listener)
}
override off<K extends keyof CaptionServiceEvents>(
event: K,
listener: CaptionServiceEvents[K],
): this {
return super.off(event, listener)
}
}
// ── 싱글톤 ──
let instance: CaptionService | null = null
export function getCaptionService(): CaptionService {
if (!instance) {
instance = new CaptionService()
}
return instance
}
export { CaptionService }