초기 프로젝트 설정: 하네스 시스템 + 설계서 + 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개 문서
This commit is contained in:
Yun Chan 2026-04-05 01:03:03 +09:00
commit e24bb8378c
35 changed files with 11452 additions and 0 deletions

View file

@ -0,0 +1,577 @@
# 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<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)
```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<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 아님)
```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<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에 불필요** — 근거:
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 전환 시 기존 오디오 유지