VoiceModeService ↔ 팝업 연동 구현 (Speakly RE 패턴)

- startSession: RecordingTip 표시 (recording 상태)
- 오디오 레벨: sendAudioLevelToTip → 웨이브 바 애니메이션
- stopSession: RecordingTip → thinking 상태 전환
- completeSession: RecordingTip 숨김 + 삽입 실패 시 ResultPopup 표시
- cancelSession: RecordingTip 즉시 숨김
- handleError: RecordingTip에 에러 표시 → 3초 후 자동 숨김 → idle 복귀
- CLAUDE.md: 핵심 병목 + Speakly 파이프라인 패턴 문서화
This commit is contained in:
Yun Chan 2026-04-05 10:23:54 +09:00
parent 9d5594e848
commit 18d5fef594
2 changed files with 73 additions and 2 deletions

View file

@ -78,10 +78,47 @@ npm run typecheck # tsc --noEmit
## 현재 상태 ## 현재 상태
Phase: 8 진행 중 (Phase 1~7.5 + 3.5 완료, Phase 8 UI+기능 보강 진행) Phase: 8 진행 중 (Phase 1~7.5 + 3.5 완료, Phase 8 UI+기능 보강 진행)
마지막 작업: Phase 8 — SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성 마지막 작업: SoX `-t waveaudio` 수정 → 마이크 캡처 동작 확인, VAD 에러 방어
다음 작업: **녹음→전사 파이프라인 E2E 테스트** (핫키 변경 후 실제 사이클 확인) 다음 작업: **VoiceModeService ↔ 팝업 연동** (RE 패턴 기반 — 핵심 병목)
차단 이슈: @nut-tree-fork/nut-js 포크 사용 차단 이슈: @nut-tree-fork/nut-js 포크 사용
### ⚠️ 핵심 병목: VoiceModeService ↔ 팝업 연동 미구현
Speakly RE 재조사로 발견된 치명적 갭:
1. **VoiceModeService가 RecordingTip/ResultPopup과 전혀 연동 안 됨**
- 녹음 시작 시 팝업 표시 안 함, 에러 시 팝업 숨김 안 함
- Speakly: VoiceModeService가 이벤트 발행 → main/index.js에서 RecordingTipWindow 메서드 호출
2. **RecordingTipUIState 상태 머신 없음**
- Speakly: OPENING→RECORDING→THINKING→COMPLETED/ERROR/CANCELLED
- D3RO: 상태 없이 수동 show/hide만
3. **session-aware 윈도우 관리 없음**
- Speakly: showForSession(sessionId) / hideForSession(sessionId) — race condition 방지
4. **에러 후 복구 안 됨**
- 에러 시 RecordingTip이 "실패" 상태로 남아있고, 핫키가 안 먹힘
- Speakly: 에러 표시 → 3초 후 자동 숨김 → idle 복귀
### Speakly 음성 파이프라인 패턴 (RE 결과)
```
[핫키 press] → Action Queue에 enqueue → processActionQueue()
→ startRecording():
1. 시스템 오디오 뮤트 (500ms 후)
2. emit('recording-tip:show-opening') → RecordingTip 표시
3. MicNativeService.start() → 오디오 스트리밍
4. emit('recording-tip:mic-ready') → 웨이브 바 활성
5. RecognitionSession 생성 → STT 연결
→ 녹음 중: 오디오 레벨 → sendAudioLevel() → 웨이브 바
[핫키 release] → stopAndProcess():
1. MicNativeService.stop()
2. accidentalPress 체크 (< 700ms 취소)
3. 언뮤트 + 종료 효과음
4. RecordingTip → thinking 상태
5. session.commitAndWait() → 전사 결과 대기
6. 전사 완료 → 텍스트 삽입 시도
7. 삽입 성공 → RecordingTip 숨김
8. 삽입 실패 → ResultPopup 표시
9. 에러 → RecordingTip에 에러 표시 → 3초 후 숨김
```
### DS 컴포넌트 현황 (src/renderer/components/ds/) ### DS 컴포넌트 현황 (src/renderer/components/ds/)
- CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅ - CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅
- InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅ - InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅

View file

@ -18,6 +18,13 @@ import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants' import { TIMING } from '@shared/constants'
import { RecognitionState, AudioState } from '@shared/types' import { RecognitionState, AudioState } from '@shared/types'
import type { VoiceMode, VoiceState } from '@shared/types' import type { VoiceMode, VoiceState } from '@shared/types'
import {
showRecordingTip,
hideRecordingTip,
updateRecordingTipState,
sendAudioLevelToTip,
showResultPopup,
} from '../windows/WindowManager'
const logger = getLogger('VoiceModeService') const logger = getLogger('VoiceModeService')
@ -187,6 +194,9 @@ class VoiceModeService extends EventEmitter {
this.emit('session-started', { session: this._session }) this.emit('session-started', { session: this._session })
logger.info(`Session started: ${this._session.id} (mode: ${mode})`) logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
// Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시
showRecordingTip('recording')
// 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작 // 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작
const sttPromise = this._initSTT() const sttPromise = this._initSTT()
const audioPromise = this._startAudio() const audioPromise = this._startAudio()
@ -212,6 +222,9 @@ class VoiceModeService extends EventEmitter {
// 오디오 캡처 중지 // 오디오 캡처 중지
await this._stopAudio() await this._stopAudio()
// Speakly 패턴: 녹음 종료 후 thinking 상태로 전환
updateRecordingTipState('thinking')
// 버퍼가 있으면 STT에 전달 // 버퍼가 있으면 STT에 전달
if (this._audioBuffer.length > 0 && this._sttReady) { if (this._audioBuffer.length > 0 && this._sttReady) {
await this._transcribe() await this._transcribe()
@ -321,6 +334,8 @@ class VoiceModeService extends EventEmitter {
this._audioLevelHandler = (payload) => { this._audioLevelHandler = (payload) => {
this.emit('audio-level', { level: payload.level }) this.emit('audio-level', { level: payload.level })
// Speakly 패턴: 오디오 레벨을 RecordingTip 웨이브 바에 전달
sendAudioLevelToTip(payload.level)
} }
audio.on('audio-data', this._audioDataHandler) audio.on('audio-data', this._audioDataHandler)
@ -467,16 +482,26 @@ class VoiceModeService extends EventEmitter {
const session = { ...this._session } const session = { ...this._session }
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`) logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
// Speakly 패턴: RecordingTip 숨김
hideRecordingTip()
// 텍스트 삽입 (autoInsert 설정 확인) // 텍스트 삽입 (autoInsert 설정 확인)
let insertSuccess = false
if (configGet('autoInsert') && finalText.length > 0) { if (configGet('autoInsert') && finalText.length > 0) {
try { try {
const insertMethod = configGet('insertMethod') const insertMethod = configGet('insertMethod')
await getTextInsertService().insertText(finalText, insertMethod) await getTextInsertService().insertText(finalText, insertMethod)
insertSuccess = true
} catch (error) { } catch (error) {
logger.warn(`Text insert failed: ${error instanceof Error ? error.message : String(error)}`) logger.warn(`Text insert failed: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
// Speakly 패턴: 삽입 실패 시 ResultPopup에 텍스트 표시
if (!insertSuccess && finalText.length > 0) {
showResultPopup(finalText, 10000)
}
this.emit('session-completed', { session, finalText }) this.emit('session-completed', { session, finalText })
// IDLE로 복귀 // IDLE로 복귀
@ -492,6 +517,9 @@ class VoiceModeService extends EventEmitter {
const session = { ...this._session } const session = { ...this._session }
logger.info(`Session cancelled: ${reason}`) logger.info(`Session cancelled: ${reason}`)
// Speakly 패턴: RecordingTip 즉시 숨김
hideRecordingTip()
// 오디오 정리 // 오디오 정리
this._stopAudio() this._stopAudio()
@ -528,6 +556,12 @@ class VoiceModeService extends EventEmitter {
this._stopAudio() this._stopAudio()
// Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김
updateRecordingTipState('error', { errorMessage: error.message })
setTimeout(() => {
hideRecordingTip()
}, 3000)
this.emit('error', { error, session: this._session ? { ...this._session } : null }) this.emit('error', { error, session: this._session ? { ...this._session } : null })
this._resetToIdle() this._resetToIdle()
} }