feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- 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 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
557
apps/desktop/src/main/services/CaptionService.ts
Normal file
557
apps/desktop/src/main/services/CaptionService.ts
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue