// src/main/services/VoiceModeService.ts // 전체 음성 파이프라인 오케스트레이터. // 설계서 01의 IVoiceModeService 구현. Speakly VoiceModeService의 // 상태 머신, 이중 조건 플러시, Action Queue 패턴 적용. import { EventEmitter } from 'events' import { randomUUID } from 'crypto' import { writeFile, mkdir } from 'fs/promises' import { join } from 'path' import { app } from 'electron' import { getLogger } from './LoggerService' import { getAudioCaptureService } from './AudioCaptureService' import { getLocalSTTService } from './LocalSTTService' import type { TranscriptionResult } from './LocalSTTService' import { getHotkeyService } from './HotkeyService' import type { HotkeyConfig } from './HotkeyService' import { configGet } from './ConfigService' import { getTextInsertService } from './TextInsertService' import { getLocalLLMService } from './LocalLLMService' import { D3ROError, ErrorCode } from '@shared/errors' import { TIMING } from '@shared/constants' import { RecognitionState, AudioState } from '@shared/types' import type { VoiceMode, VoiceState } from '@shared/types' import { showRecordingTip, hideRecordingTip, updateRecordingTipState, sendAudioLevelToTip, showResultPopup, } from '../windows/WindowManager' import type { ScreenContext } from '@shared/types' const logger = getLogger('VoiceModeService') // ============================================================ // 내부 타입 // ============================================================ interface VoiceSession { id: string mode: VoiceMode startedAt: number recognitionState: RecognitionState audioState: AudioState audioBufferDurationMs: number transcription: string processedText: string | null accidentalPress: boolean screenContext: ScreenContext | null } interface VoiceAction { type: 'press' | 'release' | 'escape' timestamp: number mode: VoiceMode hotkeyId: string } interface VoiceModeEvents { 'session-started': (payload: { session: VoiceSession }) => void 'recognition-state-changed': (payload: { previous: RecognitionState current: RecognitionState }) => void 'audio-state-changed': (payload: { previous: AudioState current: AudioState }) => void 'transcription-update': (payload: { text: string; isFinal: boolean }) => void 'session-completed': (payload: { session: VoiceSession; finalText: string }) => void 'session-cancelled': (payload: { session: VoiceSession reason: 'user' | 'timeout' | 'too-short' }) => void 'audio-level': (payload: { level: number }) => void error: (payload: { error: D3ROError; session: VoiceSession | null }) => void } // ============================================================ // 터미널 상태 헬퍼 // ============================================================ const TERMINAL_STATES = new Set([ RecognitionState.COMPLETED, RecognitionState.CANCELLED, RecognitionState.ERROR, RecognitionState.DESTROYED ]) // ============================================================ // VoiceModeService // ============================================================ class VoiceModeService extends EventEmitter { private _session: VoiceSession | null = null private _recognitionState = RecognitionState.IDLE private _audioState = AudioState.IDLE // 이중 조건 플러시 private _sttReady = false private _audioStarted = false private _audioBuffer: Buffer[] = [] private _audioBufferBytes = 0 // 녹음 오디오 저장용 private _lastAudioBuffer: Buffer | null = null // 에러 가드 private _errorEmitted = false // Action Queue (이벤트 직렬화) private _actionQueue: VoiceAction[] = [] private _isProcessingQueue = false // 리스너 해제용 참조 private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null private _audioLevelHandler: ((payload: { level: number }) => void) | null = null private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null private _disposed = false get currentSession(): VoiceSession | null { return this._session } get isActive(): boolean { return this._session !== null && !this._isInTerminalState() } getState(): VoiceState { return { recognitionState: this._recognitionState, audioState: this._audioState, mode: configGet('defaultLLMAction') === 'translate' ? 'hands-free' : 'dictation', sessionId: this._session?.id ?? null, recordingStartedAt: this._session?.startedAt ?? null } } // ── 초기화 ────────────────────────────────────────────── /** * HotkeyService 이벤트를 구독하여 핫키 → 세션 제어를 연결한다. * bootstrap에서 호출한다. */ connectHotkey(): void { const hotkey = getHotkeyService() this._hotkeyPressHandler = (payload) => { // Phase 10.1: caption 핫키는 VoiceModeService가 아닌 CaptionService로 라우팅 if (payload.config.id === 'voice-caption') { this._toggleCaption() return } const mode = this._resolveMode(payload.config) this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) } this._hotkeyReleaseHandler = (payload) => { // caption 핫키의 release는 무시 (토글 방식) if (payload.config.id === 'voice-caption') return const mode = this._resolveMode(payload.config) this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) } this._doublePressHandler = (payload) => { // 더블프레스 → hands-free 모드 토글 this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id }) } hotkey.on('hotkey-pressed', this._hotkeyPressHandler) hotkey.on('hotkey-released', this._hotkeyReleaseHandler) hotkey.on('double-press', this._doublePressHandler) logger.info('Hotkey events connected') } // ── 세션 제어 ────────────────────────────────────────── async startSession(mode: VoiceMode): Promise { if (this._disposed) return if (this._session && !this._isInTerminalState()) { logger.warn('Session already active, ignoring startSession') return } // Phase 11: 라이센스 쿼터 체크 try { const { getLicenseService } = await import('./LicenseService') const { Feature } = await import('@shared/types') const license = getLicenseService() const access = license.canUse(Feature.DICTATION) if (!access.allowed) { license.promptUpgrade(Feature.DICTATION, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required') logger.warn(`Dictation blocked: ${access.reason}`) return } license.consumeQuota(Feature.DICTATION) } catch { // LicenseService 미초기화 시 허용 (graceful) } // Phase 10.1: 자막 모드 활성 중이면 dictation 세션 시작 불가 (AudioCaptureService 공유) try { const { getCaptionService } = await import('./CaptionService') const captionState = getCaptionService().getState() if (captionState === 'active' || captionState === 'starting') { logger.warn('Cannot start dictation: caption mode active') return } } catch { // CaptionService 미초기화 시 무시 } // Phase 10.2: 스크린 컨텍스트 캡처 (녹음 시작 전, 활성 앱 정보 보존) let screenContext: ScreenContext | null = null try { const { getScreenContextService } = await import('./ScreenContextService') const ctx = getScreenContextService() if (ctx.isEnabled()) { const captureSelected = configGet('screenContextEnabled' as keyof import('@shared/types').AppConfig) as unknown as boolean const result = await ctx.captureContext(captureSelected) screenContext = result.context logger.info(`Screen context captured: ${screenContext.appName ?? 'unknown'}`) } } catch { // 컨텍스트 캡처 실패 시 무시 — 핵심 기능 아님 } // 세션 생성 this._session = { id: randomUUID(), mode, startedAt: Date.now(), recognitionState: RecognitionState.PREPARING, audioState: AudioState.IDLE, audioBufferDurationMs: 0, transcription: '', processedText: null, accidentalPress: false, screenContext, } this._errorEmitted = false this._sttReady = false this._audioStarted = false this._audioBuffer = [] this._audioBufferBytes = 0 this._setRecognitionState(RecognitionState.PREPARING) this.emit('session-started', { session: this._session }) logger.info(`Session started: ${this._session.id} (mode: ${mode})`) // Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시 showRecordingTip('recording') // 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작 const sttPromise = this._initSTT() const audioPromise = this._startAudio() // 둘 다 에러여도 개별 처리하므로 allSettled await Promise.allSettled([sttPromise, audioPromise]) } async stopSession(): Promise { if (!this._session || this._isInTerminalState()) return const session = this._session const duration = Date.now() - session.startedAt // accidentalPress 체크 (700ms 미만) if (duration < TIMING.MIN_AUDIO_DURATION) { session.accidentalPress = true logger.info(`Accidental press detected (${duration}ms < ${TIMING.MIN_AUDIO_DURATION}ms)`) this._cancelSession('too-short') return } // 오디오 캡처 중지 await this._stopAudio() // Speakly 패턴: 녹음 종료 후 thinking 상태로 전환 updateRecordingTipState('thinking') // 버퍼가 있으면 STT에 전달 if (this._audioBuffer.length > 0 && this._sttReady) { await this._transcribe() } else if (this._audioBuffer.length > 0 && !this._sttReady) { // STT 아직 준비 안 됨 → tryFlushAll이 처리 logger.info('Waiting for STT to be ready before transcribing') // 타임아웃 설정 setTimeout(() => { if (this._session?.id === session.id && !this._isInTerminalState()) { logger.warn('STT readiness timeout, cancelling session') this._cancelSession('timeout') } }, TIMING.POST_RECORDING_WAIT_BUFFERED) } else { // 오디오 없음 logger.warn('No audio buffer, cancelling session') this._cancelSession('too-short') } } cancelSession(): void { this._cancelSession('user') } // ── 상태 머신 ────────────────────────────────────────── private _isInTerminalState(): boolean { return TERMINAL_STATES.has(this._recognitionState) } private _setRecognitionState(state: RecognitionState): void { if (this._recognitionState === state) return if (this._isInTerminalState() && state !== RecognitionState.IDLE) return const previous = this._recognitionState this._recognitionState = state if (this._session) { this._session.recognitionState = state } this.emit('recognition-state-changed', { previous, current: state }) logger.debug(`RecognitionState: ${previous} → ${state}`) } private _setAudioState(state: AudioState): void { if (this._audioState === state) return const previous = this._audioState this._audioState = state if (this._session) { this._session.audioState = state } this.emit('audio-state-changed', { previous, current: state }) logger.debug(`AudioState: ${previous} → ${state}`) } // ── STT 초기화 ───────────────────────────────────────── private async _initSTT(): Promise { try { this._setRecognitionState(RecognitionState.CONNECTING) const stt = getLocalSTTService() const modelId = configGet('sttModelId') await stt.initialize(modelId) if (this._isInTerminalState()) return this._sttReady = true this._setRecognitionState(RecognitionState.READY) logger.info('STT ready') this._tryFlushAll() } catch (error) { if (this._isInTerminalState()) return this._handleError( new D3ROError( ErrorCode.STTModelLoadFailed, `STT initialization failed: ${error instanceof Error ? error.message : String(error)}` ) ) } } // ── 오디오 캡처 ──────────────────────────────────────── private async _startAudio(): Promise { try { this._setAudioState(AudioState.INITIALIZING) const audio = getAudioCaptureService() // 오디오 데이터 수신 this._audioDataHandler = (payload) => { if (this._isInTerminalState()) return this._audioBuffer.push(payload.buffer) this._audioBufferBytes += payload.buffer.length // 버퍼 duration 업데이트 (16kHz, 16bit, mono) const durationMs = (this._audioBufferBytes / 2 / 16000) * 1000 if (this._session) { this._session.audioBufferDurationMs = durationMs } this._tryFlushAll() } this._audioLevelHandler = (payload) => { this.emit('audio-level', { level: payload.level }) // Speakly 패턴: 오디오 레벨을 RecordingTip 웨이브 바에 전달 sendAudioLevelToTip(payload.level) } audio.on('audio-data', this._audioDataHandler) audio.on('audio-level', this._audioLevelHandler) await audio.start() if (this._isInTerminalState()) return this._audioStarted = true this._setAudioState(AudioState.STREAMING) logger.info('Audio capture started') this._tryFlushAll() } catch (error) { if (this._isInTerminalState()) return this._setAudioState(AudioState.STOPPED) this._handleError( new D3ROError( ErrorCode.AudioCaptureStartFailed, `Audio capture failed: ${error instanceof Error ? error.message : String(error)}` ) ) } } private async _stopAudio(): Promise { this._setAudioState(AudioState.STOPPED) const audio = getAudioCaptureService() if (this._audioDataHandler) { audio.off('audio-data', this._audioDataHandler) this._audioDataHandler = null } if (this._audioLevelHandler) { audio.off('audio-level', this._audioLevelHandler) this._audioLevelHandler = null } try { await audio.stop() } catch (error) { logger.warn(`Audio stop error: ${error instanceof Error ? error.message : String(error)}`) } } // ── 이중 조건 플러시 ─────────────────────────────────── private _tryFlushAll(): void { if (!this._sttReady || !this._audioStarted) return if (this._audioBuffer.length === 0) return if (this._isInTerminalState()) return // 아직 녹음 중이면 flush 하지 않음 (stopSession에서 처리) if (this._audioState === AudioState.STREAMING) return this._transcribe() } // ── 전사 ─────────────────────────────────────────────── private async _transcribe(): Promise { if (this._audioBuffer.length === 0) return if (this._isInTerminalState()) return this._setRecognitionState(RecognitionState.RECOGNIZING) const merged = Buffer.concat(this._audioBuffer) this._lastAudioBuffer = merged // WAV 저장용 복사본 this._audioBuffer = [] this._audioBufferBytes = 0 try { const stt = getLocalSTTService() const language = configGet('sttLanguage') // Dictionary → STT initialPrompt 주입 (Speakly 패턴) let initialPrompt: string | undefined try { const { getDictionaryService } = await import('./DictionaryService') initialPrompt = getDictionaryService().getPromptHints() || undefined } catch { // DictionaryService 미초기화 시 무시 } const result: TranscriptionResult = await stt.transcribe(merged, { language: language === 'auto' ? undefined : language, initialPrompt, }) if (this._isInTerminalState()) return if (this._session) { this._session.transcription = result.text } this.emit('transcription-update', { text: result.text, isFinal: true }) // Phase 10.5: 음성 단축키 — 키워드 매칭으로 LLM 명령어 자동 선택 let effectiveText = result.text let overrideAction: string | null = null let overrideInstructionId: string | null = null try { const { getVoiceCommandService } = await import('./VoiceCommandService') const vcSvc = getVoiceCommandService() if (vcSvc.isEnabled()) { const match = vcSvc.match(result.text) if (match.matched && match.instructionId) { effectiveText = match.cleanedText overrideAction = 'custom' overrideInstructionId = match.instructionId logger.info(`Voice command matched: keyword="${match.matchedKeyword}", instruction=${match.instructionId}`) } } } catch { // VoiceCommandService 미초기화 시 무시 } // LLM 후처리: none이면 스킵, 그 외에는 LLM 처리 const llmAction = overrideAction ?? configGet('defaultLLMAction') if (llmAction === 'none' || !getLocalLLMService().isAvailable()) { this._completeSession(effectiveText) } else { await this._processWithLLM(effectiveText, overrideInstructionId) } } catch (error) { if (this._isInTerminalState()) return this._handleError( new D3ROError( ErrorCode.STTTranscriptionFailed, `Transcription failed: ${error instanceof Error ? error.message : String(error)}` ) ) } } // ── LLM 후처리 ───────────────────────────────────────── private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise { if (this._isInTerminalState()) return try { const llm = getLocalLLMService() const action = configGet('defaultLLMAction') // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 let contextPrefix = '' if (this._session?.screenContext) { try { const { getScreenContextService } = await import('./ScreenContextService') contextPrefix = getScreenContextService().buildContextPrompt(this._session.screenContext) } catch { // 무시 } } // Phase 10.4: 체인 모드 처리 if (action === 'chain') { try { const { getChainService } = await import('./ChainService') const activeChainId = configGet('activeChainId' as keyof import('@shared/types').AppConfig) as unknown as string if (activeChainId) { const chainResult = await getChainService().execute(activeChainId, contextPrefix + transcribedText) if (this._isInTerminalState()) return if (this._session) this._session.processedText = chainResult.finalText this._completeSession(chainResult.finalText) return } } catch (error) { logger.warn(`Chain execution failed, falling back: ${error instanceof Error ? error.message : String(error)}`) } } let processedText: string // 음성 단축키 오버라이드 또는 활성 명령어 const effectiveInstructionId = overrideInstructionId ?? (configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string) if (action === 'custom' || overrideInstructionId) { let customPrompt = contextPrefix + transcribedText if (effectiveInstructionId) { const { getCustomInstructionService } = await import('./CustomInstructionService') const instruction = getCustomInstructionService().getById(effectiveInstructionId) if (instruction) { customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, contextPrefix + transcribedText) logger.info(`Using custom instruction: "${instruction.name}"`) } } processedText = await llm.processText(customPrompt, 'custom') } else { logger.info(`Processing with LLM (action: ${action})`) processedText = await llm.processText(contextPrefix + transcribedText, action) } if (this._isInTerminalState()) return if (this._session) { this._session.processedText = processedText } this._completeSession(processedText) } catch (error) { if (this._isInTerminalState()) return logger.warn(`LLM processing failed, using original text: ${error instanceof Error ? error.message : String(error)}`) this._completeSession(transcribedText) } } // ── 세션 완료/취소 ───────────────────────────────────── private async _completeSession(finalText: string): Promise { if (!this._session) return const session = { ...this._session } logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`) // Speakly 패턴: 먼저 RecordingTip 숨기고 IDLE 복귀 (다음 핫키 즉시 사용 가능) hideRecordingTip() this._setRecognitionState(RecognitionState.COMPLETED) this._setAudioState(AudioState.STOPPED) this.emit('session-completed', { session, finalText }) this._resetToIdle() // 텍스트 삽입은 세션과 무관하게 비동기 실행 (세션 블로킹 방지) if (configGet('autoInsert') && finalText.length > 0) { try { const insertMethod = configGet('insertMethod') await getTextInsertService().insertText(finalText, insertMethod) } catch (error) { logger.warn(`Text insert failed: ${error instanceof Error ? error.message : String(error)}`) // 삽입 실패 시 ResultPopup에 텍스트 표시 showResultPopup(finalText, 10000) } } // 녹음 오디오 WAV 파일 저장 (비동기, 실패해도 무시) this._saveAudioFile(session.id) } /** PCM 버퍼를 WAV 파일로 저장 */ private async _saveAudioFile(sessionId: string): Promise { if (!this._lastAudioBuffer || this._lastAudioBuffer.length === 0) return try { const recordingsDir = join(app.getPath('userData'), 'recordings') await mkdir(recordingsDir, { recursive: true }) const wavPath = join(recordingsDir, `${sessionId}.wav`) const pcmData = this._lastAudioBuffer // WAV 헤더 생성 (16kHz, 16bit, mono) const header = Buffer.alloc(44) const dataSize = pcmData.length const fileSize = dataSize + 36 header.write('RIFF', 0) header.writeUInt32LE(fileSize, 4) header.write('WAVE', 8) header.write('fmt ', 12) header.writeUInt32LE(16, 16) // fmt chunk size header.writeUInt16LE(1, 20) // PCM format header.writeUInt16LE(1, 22) // mono header.writeUInt32LE(16000, 24) // sample rate header.writeUInt32LE(32000, 28) // byte rate (16000 * 2) header.writeUInt16LE(2, 32) // block align header.writeUInt16LE(16, 34) // bits per sample header.write('data', 36) header.writeUInt32LE(dataSize, 40) await writeFile(wavPath, Buffer.concat([header, pcmData])) logger.info(`Audio saved: ${wavPath} (${Math.round(dataSize / 1024)}KB)`) } catch (error) { logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`) } finally { this._lastAudioBuffer = null } } private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void { if (!this._session) return this._setRecognitionState(RecognitionState.CANCELLED) this._setAudioState(AudioState.STOPPED) const session = { ...this._session } logger.info(`Session cancelled: ${reason}`) // Speakly 패턴: RecordingTip 즉시 숨김 hideRecordingTip() // 오디오 정리 this._stopAudio() this.emit('session-cancelled', { session, reason }) this._resetToIdle() } private _resetToIdle(): void { this._session = null this._audioBuffer = [] this._audioBufferBytes = 0 this._sttReady = false this._audioStarted = false this._errorEmitted = false // 즉시 IDLE로 전이 (딜레이는 race condition 유발) this._setRecognitionState(RecognitionState.IDLE) this._setAudioState(AudioState.IDLE) } // ── 에러 처리 ────────────────────────────────────────── private _handleError(error: D3ROError): void { if (this._errorEmitted) return this._errorEmitted = true logger.error(`VoiceMode error [${error.code}]: ${error.message}`) this._setRecognitionState(RecognitionState.ERROR) this._stopAudio() // Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김 updateRecordingTipState('error', { errorMessage: error.message }) setTimeout(() => { hideRecordingTip() }, 3000) this.emit('error', { error, session: this._session ? { ...this._session } : null }) this._resetToIdle() } // ── Action Queue (이벤트 직렬화) ─────────────────────── private _enqueueAction(action: VoiceAction): void { this._actionQueue.push(action) this._processQueue() } private async _processQueue(): Promise { if (this._isProcessingQueue) return this._isProcessingQueue = true try { while (this._actionQueue.length > 0) { const action = this._actionQueue.shift()! await this._processAction(action) } } finally { this._isProcessingQueue = false } } private async _processAction(action: VoiceAction): Promise { try { switch (action.type) { case 'press': await this._handlePress(action) break case 'release': await this._handleRelease(action) break case 'escape': this.cancelSession() break } } catch (error) { logger.error(`Action processing error: ${error instanceof Error ? error.message : String(error)}`) } } private async _handlePress(action: VoiceAction): Promise { if (action.mode === 'dictation') { // Dictation: hold-to-talk — press로 시작 if (!this.isActive) { await this.startSession('dictation') } } else if (action.mode === 'hands-free') { // HandsFree: toggle if (this.isActive) { await this.stopSession() } else { await this.startSession('hands-free') } } } private async _handleRelease(_action: VoiceAction): Promise { if (_action.mode === 'dictation' && this.isActive) { // Dictation: hold-to-talk — release로 즉시 종료 // Speakly 패턴: 딜레이 없이 즉시 stop (딜레이가 race condition 유발) await this.stopSession() } // HandsFree: release 무시 } // ── 유틸리티 ─────────────────────────────────────────── private _resolveMode(config: HotkeyConfig): VoiceMode { if (config.id === 'voice-handsfree' || config.doublePressEnabled) { return 'hands-free' } return 'dictation' } // ── 자막 모드 토글 (Phase 10.1) ───────────────────────── private async _toggleCaption(): Promise { try { const { getCaptionService } = await import('./CaptionService') const caption = getCaptionService() const state = caption.getState() if (state === 'active' || state === 'starting') { // 자막 활성 중 → 정지 await caption.stop() logger.info('Caption stopped via hotkey') } else { // dictation 세션이 활성이면 자막 시작 불가 (상호 배제) if (this._session && !this._isInTerminalState()) { logger.warn('Cannot start caption: dictation session active') return } // 회의 모드 녹음 중이면 자막 시작 불가 (상호 배제) const { getMeetingModeService } = await import('./MeetingModeService') if (getMeetingModeService().getState() === 'recording') { logger.warn('Cannot start caption: meeting mode recording active') return } await caption.start() logger.info('Caption started via hotkey') } } catch (error) { logger.error(`Caption toggle failed: ${error instanceof Error ? error.message : String(error)}`) } } // ── 종료 ─────────────────────────────────────────────── dispose(): void { this._disposed = true this._actionQueue = [] // 진행 중 세션 취소 if (this._session && !this._isInTerminalState()) { this._cancelSession('user') } // 핫키 리스너 해제 const hotkey = getHotkeyService() if (this._hotkeyPressHandler) { hotkey.off('hotkey-pressed', this._hotkeyPressHandler) } if (this._hotkeyReleaseHandler) { hotkey.off('hotkey-released', this._hotkeyReleaseHandler) } if (this._doublePressHandler) { hotkey.off('double-press', this._doublePressHandler) } // 오디오 리스너 해제 this._stopAudio() this._setRecognitionState(RecognitionState.DESTROYED) this.removeAllListeners() logger.info('VoiceModeService disposed') } // ── EventEmitter 타입 오버라이드 ─────────────────────── override on( event: K, listener: VoiceModeEvents[K] ): this { return super.on(event, listener) } override off( event: K, listener: VoiceModeEvents[K] ): this { return super.off(event, listener) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } } // ── 싱글톤 ───────────────────────────────────────────── let instance: VoiceModeService | null = null export function getVoiceModeService(): VoiceModeService { if (!instance) { instance = new VoiceModeService() } return instance }