- 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 자동 실행 모두 정상
557 lines
17 KiB
TypeScript
557 lines
17 KiB
TypeScript
// 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 }
|