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 최초 성공 ✅
556 lines
17 KiB
TypeScript
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 }
|