# 07. VoiceModeService 핵심 패턴 분석 > Speakly 실제 소스 기반 분석 (2026-04-04) > 대상: `dist/src/services/VoiceModeService.js`, `VoiceRecognitionService.js`, `AudioService.js`, `main/index.js` --- ## 1. Accidental Press (실수 누름) 감지 ### Speakly 원본 위치 - **상수 정의**: `VoiceModeService.js:116` — `MIN_AUDIO_DURATION_MS = 700` - **검사 함수**: `VoiceModeService.js:3251-3280` — `isAccidentalKeyPress(session)` - **마킹 함수**: `VoiceModeService.js:3282-3293` — `checkAndMarkAccidentalPress(session)` - **호출 지점 (early)**: `VoiceModeService.js:604` — dictation released에서 delay 전 조기 검사 - **호출 지점 (stop)**: `VoiceModeService.js:1971` — `stopAndProcess()`에서 최종 검사 ### 동작 원리 키 릴리스 시점에서 두 가지 조건을 OR로 판단: ``` isAccidental = (keyPressDuration > 0 && keyPressDuration < 700ms) || (sessionLifetime < 700ms) ``` - `keyPressDuration`: native 타임스탬프 기반 (pressed → released) - `sessionLifetime`: 세션 생성 시점부터 현재까지의 시간 ### 감지 시점과 처리 흐름 1. **DICTATION released** (`handleDictationHotkeyReleased`, 줄 604): - `checkAndMarkAccidentalPress(session)` 호출 - accidental이면 세션에 마킹 (`session.markAsAccidental()`) - 이후 `stopAndProcess()`에서 200ms delay 후 처리 2. **stopAndProcess** (줄 1971): - `session.isAccidentalPress() || this.isAccidentalKeyPress(session)` 확인 - accidental이면: - `session.markErrorEmitted()` — 에러 팁 표시 방지 - `createCancelledBySystemError()` 에러 생성 (코드: `VoiceCancelledBySystem`) - `recognition-error` 이벤트 emit (UI는 이 에러 코드를 보고 에러 팁을 표시하지 않음) - `session.userCancel()` → 서버에 cancel 전송 - 세션 정리 후 **조용히 종료** (사용자에게 에러 표시 없음) ### D3RO-VOICE 적용 의사코드 ```typescript // VoicePipelineService private readonly MIN_AUDIO_DURATION_MS = 700; private isAccidentalPress(session: VoiceSession): boolean { const keyPressDuration = session.getKeyPressDuration(); const sessionLifetime = session.getSessionLifetime(); const hasKeyTiming = keyPressDuration > 0; const keyTooShort = hasKeyTiming && keyPressDuration < this.MIN_AUDIO_DURATION_MS; const sessionTooShort = sessionLifetime < this.MIN_AUDIO_DURATION_MS; return keyTooShort || sessionTooShort; } // stopAndProcess() 내부 if (this.isAccidentalPress(session)) { session.cancel(); // 서버에 cancel 전송 this.emit('session:cancelled-silent'); // UI에 에러 표시 없이 조용히 닫기 return; } ``` --- ## 2. Audio Mute 연동 ### Speakly 원본 위치 - **상수**: `VoiceModeService.js:118-119` — `MUTE_DELAY_MS = 500`, `UNMUTE_SOUND_DELAY_MS = 100` - **상태**: `VoiceModeService.js:120` — `wasMutedBeforeRecording = false` - **Mute 로직**: `VoiceModeService.js:2726-2773` — `onBeforeRecording()` - **Unmute 로직**: `VoiceModeService.js:2776-2820` — `onAfterRecording()` - **Loopback 검사**: `VoiceModeService.js:2707` — `_isLoopbackMic()` ### 동작 원리 #### 녹음 시작 시 (onBeforeRecording) 1. `UserConfigService.getMuteAudioWhenDictating()` 설정 확인 2. `_isLoopbackMic()` — 가상 오디오 장치(BlackHole 등)면 mute 스킵 (loopback 입력이 끊김 방지) 3. 사운드 이펙트가 활성화된 경우: - 녹음 시작음 먼저 재생 - **500ms 딜레이** 후 `NativeService.muteSystemAudio()` (시작음이 끝나도록) - fire-and-forget (비동기, 녹음 시작을 차단하지 않음) 4. 사운드 이펙트 비활성화 시: 즉시 mute 5. `wasMutedBeforeRecording` 저장 — 이미 음소거였으면 unmute 스킵 #### 녹음 종료 시 (onAfterRecording) 1. `muteAudioWhenDictating && !_isLoopbackMic() && !wasMutedBeforeRecording` 일 때만 unmute 2. `NativeService.unmuteSystemAudio()` 호출 3. unmute 완료 후 **100ms 딜레이** → 종료음 재생 4. unmute 실패 시에도 종료음은 재생 (`.catch()` 안에서) #### 핵심 설계: fire-and-forget 패턴 - mute/unmute는 녹음 흐름을 **절대 블로킹하지 않음** - `onBeforeRecording()`과 `onAfterRecording()` 모두 즉시 리턴 - mute 실패는 warn 로그만 남기고 녹음은 계속 진행 ### D3RO-VOICE 적용 의사코드 ```typescript // AudioMuteService (별도 서비스로 분리) class AudioMuteService { private wasMutedBefore = false; private readonly MUTE_DELAY_MS = 500; private readonly UNMUTE_SOUND_DELAY_MS = 100; async muteForRecording(sessionId: string, playSoundFirst: boolean): Promise { if (!this.config.muteAudioWhenDictating) return; if (this.isLoopbackDevice()) return; const doMute = async () => { try { const { wasMuted } = await nativeAudio.muteSystem(); this.wasMutedBefore = wasMuted; } catch (e) { log.warn('Mute failed, continuing recording', e); } }; if (playSoundFirst) { setTimeout(doMute, this.MUTE_DELAY_MS); // fire-and-forget } else { doMute(); // fire-and-forget (no await) } } async unmuteAfterRecording(onComplete?: () => void): Promise { if (!this.config.muteAudioWhenDictating || this.wasMutedBefore) { onComplete?.(); return; } nativeAudio.unmuteSystem() .then(() => setTimeout(onComplete, this.UNMUTE_SOUND_DELAY_MS)) .catch(() => onComplete?.()); // 실패해도 종료음 재생 } } ``` --- ## 3. Retry 로직 ### Speakly 원본 위치 - **IPC 핸들러**: `main/index.js:1298-1400` — `history:retry` - **sessionRetryCount**: `main/index.js:126-128` — `Map` - **MAX_RETRY_ATTEMPTS**: `config/constants.js:18` — `3` - **retryRecognition()**: `VoiceModeService.js:3319-3605` - **OggOpusReader**: `VoiceModeService.js:3361` — 파일 포맷 감지 및 프레임 추출 ### 전체 흐름 ``` renderer → IPC 'history:retry' → main/index.js → canRetrySession(id) 확인 → sessionRetryCount 증가 → VoiceModeService.retryRecognition(id) → HistoryService.getById(id) // DB에서 기록 조회 → fs.readFileSync(audioLocalPath) // 디스크에서 오디오 파일 읽기 → 파일 포맷 감지 (OGG/WAV) → 새 VoiceRecognitionSession 생성 → session.start() → 서버 연결 → audioFrames 순차 전송 (sendAudio) → session.stop() → commit + wait → HistoryService.upsert() → DB 업데이트 → ResultPopupWindow.show() → 결과 표시 ``` ### 오디오 파일 처리 (줄 3361-3393) ```javascript // OGG 파일인 경우 if (isOggFile(audioBuffer)) { retryCodec = 'opus'; const parsed = parseOggOpus(audioBuffer); // 프레임 + 메타데이터 추출 audioFrames = parsed.frames; } // WAV 파일인 경우 else if (isWavFile(audioBuffer)) { retryCodec = 'pcm'; const pcmData = audioBuffer.subarray(44); // WAV 헤더(44바이트) 제거 // 8192 바이트씩 분할 for (let i = 0; i < pcmData.length; i += PCM_CHUNK_SIZE) { audioFrames.push(Buffer.from(pcmData.subarray(i, ...))); } } ``` ### Retry Count 관리 (main/index.js) ```javascript const sessionRetryCount = new Map(); // 전역 // 매 retry 시 sessionRetryCount.set(id, (sessionRetryCount.get(id) || 0) + 1); // retry 횟수 >= MAX_RETRY_ATTEMPTS(3) 이면 // → 'error.retry.maxRetriesReached' 메시지, retry 버튼 없음 // → sessionRetryCount.delete(id) // retryable 에러 (네트워크/InternalError)이고 횟수 남으면 // → 'error-with-retry' UI 표시 // 성공 시 // → sessionRetryCount.delete(id) // 카운트 초기화 ``` ### ESC 키 취소 (VoiceModeService.js:813-818) ```javascript // ESC 핸들러에서 if (this.retryingSession) { await this.retryingSession.userCancel(); this.retryingSession = null; this.retryingHistoryId = null; } ``` ### D3RO-VOICE 적용 의사코드 ```typescript // RetryService class RetryService { private retryCount = new Map(); private readonly MAX_RETRY = 3; async retrySession(historyId: string): Promise { // 1. Retry count 확인 const count = (this.retryCount.get(historyId) || 0) + 1; if (count > this.MAX_RETRY) { return { success: false, error: 'max_retries_reached' }; } this.retryCount.set(historyId, count); // 2. 히스토리에서 오디오 경로 조회 const record = await historyDB.getById(historyId); if (!record?.audioLocalPath) throw new Error('no_audio'); // 3. 오디오 파일 읽기 + 포맷 감지 const buffer = fs.readFileSync(record.audioLocalPath); const frames = this.parseAudioFile(buffer); // OGG or WAV // 4. 새 세션 생성 → 연결 → 오디오 전송 const session = new RecognitionSession({ source: 'file', ... }); await session.start(); for (const frame of frames) { if (session.isCancelled()) break; await session.sendAudio(frame); } // 5. 결과 대기 const result = await session.stop(); // 6. 성공 시 retry count 초기화 this.retryCount.delete(historyId); return { success: true, data: result }; } } ``` --- ## 4. Action Queue 직렬화 ### Speakly 원본 위치 - **NXAction 클래스**: `VoiceModeService.js:78-87` - **큐 선언**: `VoiceModeService.js:186-189` - **enqueueAction()**: `VoiceModeService.js:213-219` - **processActionQueue()**: `VoiceModeService.js:222-242` - **handleAction()**: `VoiceModeService.js:245-274` - **clearActionQueue()**: `VoiceModeService.js:277-285` (ESC 시 호출) ### 패턴: async 직렬화 큐 (Mutex 아님) ```javascript class NXAction { constructor(type, options) { this.type = type; // 'dictation:pressed', 'hands-free:released', ... this.timestamp = Date.now(); this.hotkeyId = options?.hotkeyId; this.hotkeyTimestamp = options?.hotkeyTimestamp; // native 타임스탬프 } } // 큐 상태 actionQueue = []; // NXAction[] isProcessingActionQueue = false; // boolean flag (lock 역할) enqueueAction(action) { this.actionQueue.push(action); this.processActionQueue(); // 처리 시작 시도 } async processActionQueue() { if (this.isProcessingActionQueue) return; // 이미 처리 중이면 스킵 this.isProcessingActionQueue = true; while (this.actionQueue.length > 0) { const action = this.actionQueue.shift(); await this.handleAction(action); // await로 직렬 처리 } this.isProcessingActionQueue = false; } ``` ### 핵심 메커니즘 - **Lock 패턴이 아닌 async loop**: `isProcessingActionQueue` 플래그로 재진입 방지 - 새 이벤트가 들어오면 큐에 push하고 `processActionQueue()` 호출 → 이미 처리 중이면 즉시 리턴 - 현재 action의 `await handleAction()` 완료 후 다음 action 처리 - **ESC 키**: `clearActionQueue()`로 대기 중인 모든 action 즉시 삭제 (줄 808-809) ### 녹음 중 다른 핫키 입력 시 - 새 핫키 이벤트가 큐에 들어감 - 현재 처리 중인 action이 완료될 때까지 대기 - 각 핸들러 내부에서 `this.isRecording` 상태를 검사하여 모드 전환/거부 결정: - 같은 모드 핫키 → 녹음 중지 (토글) - 다른 모드 핫키 → 모드 전환 (오디오 보존) - processing 중 → `showProcessingInfoTip()` (줄 481-484) ### D3RO-VOICE 적용 의사코드 ```typescript // ActionQueue (VoicePipelineService 내장) interface PipelineAction { type: 'push-to-talk:start' | 'push-to-talk:stop' | 'toggle:start' | 'toggle:stop'; timestamp: number; } class ActionQueue { private queue: PipelineAction[] = []; private processing = false; enqueue(action: PipelineAction): void { this.queue.push(action); this.process(); } clear(): void { this.queue = []; } private async process(): Promise { if (this.processing) return; this.processing = true; while (this.queue.length > 0) { const action = this.queue.shift()!; await this.handleAction(action); } this.processing = false; } private async handleAction(action: PipelineAction): Promise { // dispatch to VoicePipelineService handlers } } ``` --- ## 5. Hands-Free No-Wake 모드 ### Speakly 원본 위치 - **모드 정의**: `VoiceModeService.js:94` — `HANDS_FREE_NO_WAKE = "hands-free-no-wake"` - **핫키 등록**: `VoiceModeService.js:415-426` - **pressed 핸들러**: `VoiceModeService.js:653-687` - **cancelNoWakeRecording()**: `VoiceModeService.js:2481-2550` (X 버튼) - **confirmNoWakeRecording()**: `VoiceModeService.js:2551-2558` (V 버튼) - **undoNoWakeCancel()**: `VoiceModeService.js:2562-2610` (Undo 버튼) ### 일반 Hands-Free와의 차이 | 기능 | Hands-Free | Hands-Free No-Wake | |------|-----------|-------------------| | 시작 | 핫키 토글 | 핫키 토글 | | 종료 | 핫키 토글 | 핫키 토글 **또는** UI 버튼 | | 웨이크워드 | 해당 없음 (둘 다 없음) | 해당 없음 | | Cancel (X) | ESC만 가능 | **UI X 버튼** → 오디오 저장 후 취소 | | Confirm (V) | 핫키로만 | **UI V 버튼** = 핫키 재누름과 동일 | | Undo | 없음 | **Undo 버튼** → retry로 복원 | | 오디오 보존 | 없음 | 취소 시 디스크 저장 (undo 용) | ### 동작 흐름 #### 시작 (핫키 누름) ``` handleHandsFreeNoWakeHotkeyPressed(timestamp) → 다른 모드 녹음 중이면: 오디오 보존 + 모드 전환 → 같은 모드 녹음 중이면: 녹음 중지 (confirm) → 녹음 안 하고 있으면: 녹음 시작 ``` #### Cancel — X 버튼 (줄 2481-2550) ``` cancelNoWakeRecording() → 마이크 중지 → finalizeSession(status='error') → 오디오 OGG/WAV 파일 디스크 저장 → session.userCancel() → 서버에 cancel → cancelledSessionId = sessionId ← Undo용 저장 → emit('recording-tip:show-cancelled') → UI에 Undo 버튼 표시 ``` #### Confirm — V 버튼 (줄 2551-2558) ``` confirmNoWakeRecording() → stopAndProcess(false) // 일반 핫키 재누름과 동일 ``` #### Undo — Undo 버튼 (줄 2562-2610) ``` undoNoWakeCancel() → cancelledSessionId 가져오기 → isProcessing = true → UI thinking 상태 → retryRecognition(sessionId, { showResultPopup: false }) → 디스크의 오디오 파일 읽기 → 서버 재전송 → 결과 수신 → insertTextCallback(result.text) → 텍스트 삽입 → isProcessing = false ``` ### D3RO-VOICE 적용 판단 **No-Wake 모드는 D3RO-VOICE에 불필요** — 근거: 1. "No-Wake"라는 이름과 달리 웨이크워드와 무관. 실제로는 **UI 버튼이 있는 hands-free 변형** 2. Speakly의 3가지 버튼(X, V, Undo)은 RecordingTipWindow(플로팅 캡슐)에 의존 3. D3RO-VOICE는 시스템 트레이 기반이므로 플로팅 캡슐 UI가 없음 4. Cancel+Undo 패턴은 retry 인프라 위에 구축 — retry만 있으면 같은 효과 **대신 구현할 것**: Toggle 모드(= Hands-Free)만 지원 + 히스토리에서 retry 가능 ```typescript // D3RO-VOICE에서는 hands-free-no-wake를 별도 모드로 구현하지 않음. // Toggle 모드가 이미 동일한 기본 기능을 제공. // Undo 기능은 히스토리 패널의 retry 버튼으로 대체. ``` --- ## 6. VoiceMode 4종 완전 분석 ### 모드 정의 (줄 89-94) ```typescript enum VoiceMode { DICTATION = "dictation", // Hold-to-talk HANDS_FREE = "hands-free", // Toggle (press once → speak → press again) CUSTOM_INSTRUCTION = "custom-instruction", // Hold-to-talk + AI 지시 HANDS_FREE_NO_WAKE = "hands-free-no-wake", // Toggle + UI 버튼 } ``` ### Mode 1: DICTATION (Hold-to-talk) **시작**: 트리거 키 길게 누름 (>10ms, `PRESS_HOLD_THRESHOLD`) ``` handleDictationHotkeyPressed(timestamp) → lastPressTimestamp = timestamp → waitingForSecondPress = true → secondPressTimer 시작 (300ms) → pressHoldTimer 시작 (10ms) → onPressHoldTimeout() → recordingSource = 'fn' → setMode(DICTATION) → dictationEnteredByLongPress = true → startRecording(undefined, pressedTimestamp) ``` **종료**: 트리거 키 놓기 ``` handleDictationHotkeyReleased(timestamp) → checkAndMarkAccidentalPress(session) // 700ms 미만이면 마킹 → stopDelayTimer = setTimeout(200ms) → stopAndProcess(false) ``` **더블 프레스**: 300ms 이내 두 번 누름 → Ask Genspark (agent mode 활성화 시) ### Mode 2: HANDS_FREE (Toggle) **시작**: 핫키 누름 (녹음 중 아닐 때) ``` handleHandsFreeHotkeyPressed(timestamp) → setMode(HANDS_FREE) → startRecording(undefined, timestamp) ``` **종료**: 핫키 다시 누름 (녹음 중일 때) ``` handleHandsFreeHotkeyPressed(timestamp) → [녹음 중이고 같은 모드] → stopAndProcess(false) ``` **모드 전환**: 다른 모드 녹음 중 핫키 → 오디오 보존 후 모드 전환 ``` → savedAudioChunks = [...this.audioChunks] → cancelRecording({ isSwitching: true }) → setMode(HANDS_FREE) → startRecording(savedAudioChunks, undefined, 'mode-switch') ``` **Released**: 무시됨 (토글 모드, 줄 258-260) ### Mode 3: CUSTOM_INSTRUCTION (Hold-to-talk + AI 지시) **시작**: Custom Instruction 핫키 누름 ``` handleCustomInstructionHotkeyPressed(commandId) → instructionId 변환 → customInstructionPressed = true → activeCustomInstructionId = instructionId → executeCustomInstructionPressed(commandId) // fire-and-forget → Context capture (100ms timeout race) → show-mode-tip 이벤트 (지시 이름 표시) → setMode(CUSTOM_INSTRUCTION) → startRecordingWithCustomInstruction(instruction, id) ``` **종료**: 핫키 놓기 ``` handleCustomInstructionHotkeyReleased(commandId, timestamp) → customInstructionPressed = false → [녹음 중이면] stopAndProcess(false) ``` **특징**: - Hold-to-talk 방식 (DICTATION과 동일하게 눌러서 말하고 놓으면 종료) - 세션에 `pendingCustomInstruction` 첨부 → 서버가 AI 지시에 따라 텍스트 처리 - 녹음 중 모드 전환 대상이 아님 (독립적 핫키) ### Mode 4: HANDS_FREE_NO_WAKE (Toggle + UI 버튼) 상세 분석은 위 섹션 5 참조. **시작/종료**: HANDS_FREE와 동일한 토글 패턴 **추가 기능**: X/V/Undo UI 버튼, 취소 시 오디오 디스크 보존 ### 모드 전환 매트릭스 | 현재 모드 → 입력 | DICTATION | HANDS_FREE | NO_WAKE | CUSTOM | |---|---|---|---|---| | **DICTATION pressed** | - | 오디오보존→전환 | 오디오보존→전환 | 독립 | | **HANDS_FREE pressed** | 오디오보존→전환 | 토글 중지 | 오디오보존→전환 | 독립 | | **NO_WAKE pressed** | 오디오보존→전환 | 오디오보존→전환 | 토글 중지 | 독립 | | **ESC** | 취소 | 취소→DICTATION | 취소→DICTATION | 취소→DICTATION | 녹음 종료 후: HANDS_FREE, CUSTOM_INSTRUCTION, HANDS_FREE_NO_WAKE 모두 → DICTATION으로 리셋 (줄 2147-2151) --- ## 7. D3RO-VOICE에 필요한 모드 (결론) D3RO-VOICE는 다음 2개 모드만 지원: | D3RO-VOICE 모드 | Speakly 대응 | 설명 | |---|---|---| | **Push-to-Talk** | DICTATION | 키 누르고 있는 동안 녹음 | | **Toggle** | HANDS_FREE | 키 한번 → 녹음 시작, 다시 한번 → 종료 | **제거 대상**: - `HANDS_FREE_NO_WAKE` — 플로팅 캡슐 UI 전제. 히스토리 retry로 대체 - `CUSTOM_INSTRUCTION` — Phase 2 이후 검토 - `Ask Genspark` (더블 프레스) — Genspark 특화 기능, 불필요 **반드시 포함할 패턴**: 1. Action Queue 직렬화 — race condition 방지 (핫키 이벤트 직렬 처리) 2. Accidental Press 감지 — 700ms 미만 자동 취소 3. Audio Mute 연동 — 설정 기반 시스템 음소거 4. Retry 로직 — 히스토리에서 오디오 파일 재전송 5. 모드 전환 시 오디오 보존 — Push-to-Talk ↔ Toggle 전환 시 기존 오디오 유지