d3ro-voice/docs/design/07-voicemode-patterns.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- 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개 문서
2026-04-05 01:03:03 +09:00

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:116MIN_AUDIO_DURATION_MS = 700
  • 검사 함수: VoiceModeService.js:3251-3280isAccidentalKeyPress(session)
  • 마킹 함수: VoiceModeService.js:3282-3293checkAndMarkAccidentalPress(session)
  • 호출 지점 (early): VoiceModeService.js:604 — dictation released에서 delay 전 조기 검사
  • 호출 지점 (stop): VoiceModeService.js:1971stopAndProcess()에서 최종 검사

동작 원리

키 릴리스 시점에서 두 가지 조건을 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 적용 의사코드

// 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-119MUTE_DELAY_MS = 500, UNMUTE_SOUND_DELAY_MS = 100
  • 상태: VoiceModeService.js:120wasMutedBeforeRecording = false
  • Mute 로직: VoiceModeService.js:2726-2773onBeforeRecording()
  • Unmute 로직: VoiceModeService.js:2776-2820onAfterRecording()
  • 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 적용 의사코드

// 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-1400history:retry
  • sessionRetryCount: main/index.js:126-128Map<sessionId, retryCount>
  • MAX_RETRY_ATTEMPTS: config/constants.js:183
  • 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:94HANDS_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 가능

// 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 특화 기능, 불필요

반드시 포함할 패턴:

  1. Action Queue 직렬화 — race condition 방지 (핫키 이벤트 직렬 처리)
  2. Accidental Press 감지 — 700ms 미만 자동 취소
  3. Audio Mute 연동 — 설정 기반 시스템 음소거
  4. Retry 로직 — 히스토리에서 오디오 파일 재전송
  5. 모드 전환 시 오디오 보존 — Push-to-Talk ↔ Toggle 전환 시 기존 오디오 유지