- 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개 문서
577 lines
20 KiB
Markdown
577 lines
20 KiB
Markdown
# 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 전환 시 기존 오디오 유지
|