- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵 - .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입) - .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug - .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist - docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널), DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석, VoiceMode패턴, 디자인시스템, 히스토리팝업 - docs/phases/1-7+3.5: 전체 구현 페이즈 문서 - docs/re-findings/: Speakly RE 노하우 5개 문서
20 KiB
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: 세션 생성 시점부터 현재까지의 시간
감지 시점과 처리 흐름
-
DICTATION released (
handleDictationHotkeyReleased, 줄 604):checkAndMarkAccidentalPress(session)호출- accidental이면 세션에 마킹 (
session.markAsAccidental()) - 이후
stopAndProcess()에서 200ms delay 후 처리
-
stopAndProcess (줄 1971):
session.isAccidentalPress() || this.isAccidentalKeyPress(session)확인- accidental이면:
session.markErrorEmitted()— 에러 팁 표시 방지createCancelledBySystemError()에러 생성 (코드:VoiceCancelledBySystem)recognition-error이벤트 emit (UI는 이 에러 코드를 보고 에러 팁을 표시하지 않음)session.userCancel()→ 서버에 cancel 전송- 세션 정리 후 조용히 종료 (사용자에게 에러 표시 없음)
D3RO-VOICE 적용 의사코드
// 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)
UserConfigService.getMuteAudioWhenDictating()설정 확인_isLoopbackMic()— 가상 오디오 장치(BlackHole 등)면 mute 스킵 (loopback 입력이 끊김 방지)- 사운드 이펙트가 활성화된 경우:
- 녹음 시작음 먼저 재생
- 500ms 딜레이 후
NativeService.muteSystemAudio()(시작음이 끝나도록) - fire-and-forget (비동기, 녹음 시작을 차단하지 않음)
- 사운드 이펙트 비활성화 시: 즉시 mute
wasMutedBeforeRecording저장 — 이미 음소거였으면 unmute 스킵
녹음 종료 시 (onAfterRecording)
muteAudioWhenDictating && !_isLoopbackMic() && !wasMutedBeforeRecording일 때만 unmuteNativeService.unmuteSystemAudio()호출- unmute 완료 후 100ms 딜레이 → 종료음 재생
- unmute 실패 시에도 종료음은 재생 (
.catch()안에서)
핵심 설계: fire-and-forget 패턴
- mute/unmute는 녹음 흐름을 절대 블로킹하지 않음
onBeforeRecording()과onAfterRecording()모두 즉시 리턴- mute 실패는 warn 로그만 남기고 녹음은 계속 진행
D3RO-VOICE 적용 의사코드
// 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<void> {
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<void> {
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<sessionId, retryCount> - 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)
// 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)
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)
// ESC 핸들러에서
if (this.retryingSession) {
await this.retryingSession.userCancel();
this.retryingSession = null;
this.retryingHistoryId = null;
}
D3RO-VOICE 적용 의사코드
// RetryService
class RetryService {
private retryCount = new Map<string, number>();
private readonly MAX_RETRY = 3;
async retrySession(historyId: string): Promise<RetryResult> {
// 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 아님)
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 적용 의사코드
// 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<void> {
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<void> {
// 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에 불필요 — 근거:
- "No-Wake"라는 이름과 달리 웨이크워드와 무관. 실제로는 UI 버튼이 있는 hands-free 변형
- Speakly의 3가지 버튼(X, V, Undo)은 RecordingTipWindow(플로팅 캡슐)에 의존
- D3RO-VOICE는 시스템 트레이 기반이므로 플로팅 캡슐 UI가 없음
- Cancel+Undo 패턴은 retry 인프라 위에 구축 — retry만 있으면 같은 효과
대신 구현할 것: Toggle 모드(= Hands-Free)만 지원 + 히스토리에서 retry 가능
// D3RO-VOICE에서는 hands-free-no-wake를 별도 모드로 구현하지 않음.
// Toggle 모드가 이미 동일한 기본 기능을 제공.
// Undo 기능은 히스토리 패널의 retry 버튼으로 대체.
6. VoiceMode 4종 완전 분석
모드 정의 (줄 89-94)
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 특화 기능, 불필요
반드시 포함할 패턴:
- Action Queue 직렬화 — race condition 방지 (핫키 이벤트 직렬 처리)
- Accidental Press 감지 — 700ms 미만 자동 취소
- Audio Mute 연동 — 설정 기반 시스템 음소거
- Retry 로직 — 히스토리에서 오디오 파일 재전송
- 모드 전환 시 오디오 보존 — Push-to-Talk ↔ Toggle 전환 시 기존 오디오 유지