d3ro-voice/apps/desktop/src/main/services/CaptionService.ts
윤찬 e55687d298 fix(desktop+supabase): 로컬 ID UUID 통일 + Realtime publication (빅뱅 Phase 5 Part 2)
SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1).

## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치
- 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..."
- 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK.
  빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서).
- 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지).
  14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체.
  nanoid 의존성 + electron.vite.config exclude 제거.
  drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능).

## Bug 5: supabase_realtime publication 누락
- 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT
- 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가.
  데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음.
- 픽스: 20260411000002_realtime_publication.sql 신규.
  pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/
  meeting_documents/history/dictionary 5개). supabase db push 적용.

## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스)
- 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token)
  명시 호출 (채널 구성 이전).
- ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요.
  블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지.

## 실증 결과
- A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈
- B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0
- C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode
- D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존
- E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건)

검증: desktop tsc --noEmit , dev 재기동 , push 최초 성공 
2026-04-11 18:46:22 +09:00

556 lines
17 KiB
TypeScript

// src/main/services/CaptionService.ts
// Phase 10.1: Live Caption — 실시간 자막 서비스
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
import { EventEmitter } from 'events'
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 '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
CaptionState,
CaptionSegment,
CaptionConfig,
CaptionSessionSummary,
} from '@d3ro/core/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 = crypto.randomUUID()
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('@d3ro/core/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: crypto.randomUUID(),
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: crypto.randomUUID(),
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 }