// 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 | 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): void { this._config = { ...this._config, ...partial } logger.debug(`Caption config updated: ${JSON.stringify(this._config)}`) // 오버레이에 설정 변경 알림 sendToCaptionOverlay('caption:config', this._config) } // ── 시작 ── async start(): Promise { 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[] = [] // 마이크 청크 처리 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 { 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 { 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 { 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 { 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( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } override on( event: K, listener: CaptionServiceEvents[K], ): this { return super.on(event, listener) } override off( 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 }