diff --git a/apps/desktop/src/main/ipc/voice-handlers.ts b/apps/desktop/src/main/ipc/voice-handlers.ts index bbf5473..e588d56 100644 --- a/apps/desktop/src/main/ipc/voice-handlers.ts +++ b/apps/desktop/src/main/ipc/voice-handlers.ts @@ -4,9 +4,30 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' import { getVoiceModeService } from '../services/VoiceModeService' -import type { StartRecordingParams, StopRecordingParams, CancelRecordingParams, SetVoiceModeParams } from '@d3ro/core/types' +import { getMainWindow } from '../windows/WindowManager' +import type { StartRecordingParams, StopRecordingParams, CancelRecordingParams, SetVoiceModeParams, VoiceErrorEvent } from '@d3ro/core/types' + +function safeSendToRenderer(channel: string, data: unknown): void { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(channel, data) + } +} export function registerVoiceHandlers(): void { + // 음성 세션 에러/경고를 메인 윈도우로 브로드캐스트 — + // 모델 미설치/엔진 실패 등의 경고가 recording tip(3초) 외에도 + // 메인 UI에서 명확히 보이도록 (설계서 02 voice:error 이벤트) + getVoiceModeService().on('error', (payload) => { + const event: VoiceErrorEvent = { + sessionId: payload.session?.id ?? null, + errorCode: payload.error.code, + message: payload.error.message, + severity: payload.severity ?? 'error', + } + safeSendToRenderer(IPC_CHANNELS.VOICE.ERROR, event) + }) + ipcMain.handle(IPC_CHANNELS.VOICE.START_RECORDING, async (_event, params: StartRecordingParams) => { try { const voice = getVoiceModeService() diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index e699412..c08dfbb 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -173,6 +173,14 @@ const MODEL_CATALOG: STTModel[] = [ const logger = getLogger('LocalSTTService') class LocalSTTService extends EventEmitter { + constructor() { + super() + // EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다 + // (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 — + // 실제 로깅은 _emitError에서 수행. + this.on('error', () => { /* default sink */ }) + } + private _state: STTState = STTState.Uninitialized private _sidecarProcess: ChildProcess | null = null private _port: number = SIDECAR_PORT @@ -637,6 +645,15 @@ class LocalSTTService extends EventEmitter { const startTime = Date.now() while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) { + // 프로세스가 이미 죽었으면 30초 타임아웃을 기다리지 않고 즉시 실패 + // (의존성 미설치 등 즉사 크래시가 30초×큐잉으로 증폭되는 문제 방지) + if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + 'Sidecar process exited during startup — check Python environment/dependencies', + ) + } + try { const response = await fetch(`http://localhost:${this._port}/health`, { signal: AbortSignal.timeout(2000), @@ -645,6 +662,7 @@ class LocalSTTService extends EventEmitter { if (response.ok) { const data = (await response.json()) as HealthResponse this._gpuAccelerated = data.gpu + this._restartCount = 0 logger.info( `Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`, ) diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index aa4b56f..e2f3f34 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -26,6 +26,7 @@ import { hideRecordingTip, updateRecordingTipState, sendAudioLevelToTip, + sendPartialTranscriptToTip, showResultPopup, } from '../windows/WindowManager' import type { ScreenContext } from '@d3ro/core/types' @@ -73,7 +74,12 @@ interface VoiceModeEvents { reason: 'user' | 'timeout' | 'too-short' }) => void 'audio-level': (payload: { level: number }) => void - error: (payload: { error: D3ROError; session: VoiceSession | null }) => void + error: (payload: { + error: D3ROError + session: VoiceSession | null + /** 'warning'은 세션을 막지 않는 알림 (기본 'error') */ + severity?: 'error' | 'warning' + }) => void /** * Phase 3.2: Premium LLM 호출이 실패해 Local로 자동 fallback된 경우 emit. * renderer에서 이 이벤트를 받아 Snackbar 경고 배너를 띄운다. @@ -114,6 +120,9 @@ class VoiceModeService extends EventEmitter { private _errorEmitted = false // 에러 popup 3초 hide 예약 타이머 (다음 세션 시작 시 취소해야 현재 recording tip이 살아남음) private _errorHideTimer: NodeJS.Timeout | null = null + /** 실시간 부분 전사 루프 */ + private _interimTimer: NodeJS.Timeout | null = null + private _interimBusy = false // Action Queue (이벤트 직렬화) private _actionQueue: VoiceAction[] = [] @@ -267,15 +276,33 @@ class VoiceModeService extends EventEmitter { this.emit('session-started', { session: this._session }) logger.info(`Session started: ${this._session.id} (mode: ${mode})`) + // 프리플라이트: STT 모델 미설치면 게이지만 돌며 "되는 척"하지 않고 즉시 에러. + // (온보딩 미완료/모델 삭제 상태에서 단축키를 눌렀을 때의 경고 경로) + const stt = getLocalSTTService() + const sttModelId = configGet('sttModelId') + const sttModel = stt.getModels().find((m) => m.id === sttModelId) + if (sttModel && !sttModel.downloaded) { + this._handleError( + new D3ROError( + ErrorCode.STTModelNotFound, + `STT model not installed: ${sttModelId}`, + ), + ) + return + } + // Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시 showRecordingTip('recording') - // 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작 - const sttPromise = this._initSTT() - const audioPromise = this._startAudio() + // 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작. + // 주의: STT 초기화는 await하지 않는다 — sidecar 기동이 느리거나 실패할 때 + // press 액션이 action queue를 점유해 release가 수십 초 지연되던 버그의 원인. + // (_initSTT는 내부에서 에러를 _handleError로 처리하므로 fire-and-forget 안전) + void this._initSTT() + await this._startAudio() - // 둘 다 에러여도 개별 처리하므로 allSettled - await Promise.allSettled([sttPromise, audioPromise]) + // 실시간 부분 전사 루프 시작 (STT 준비 + 스트리밍 중일 때만 동작) + this._startInterimLoop() } async stopSession(): Promise { @@ -436,6 +463,7 @@ class VoiceModeService extends EventEmitter { } private async _stopAudio(): Promise { + this._stopInterimLoop() this._setAudioState(AudioState.STOPPED) const audio = getAudioCaptureService() @@ -455,6 +483,63 @@ class VoiceModeService extends EventEmitter { } } + // ── 실시간 부분 전사 (interim) ────────────────────────── + // 녹음 중 1.5초 간격으로 현재 버퍼(최근 12초 윈도우)를 전사해 + // RecordingTip에 "말하는 대로 적히는" 미리보기를 제공한다. + // 최종 전사는 release 후 전체 버퍼로 다시 수행 (기존 경로 그대로). + + private _startInterimLoop(): void { + this._stopInterimLoop() + this._interimTimer = setInterval(() => { + void this._runInterimTranscribe() + }, 1500) + } + + private _stopInterimLoop(): void { + if (this._interimTimer) { + clearInterval(this._interimTimer) + this._interimTimer = null + } + this._interimBusy = false + } + + private async _runInterimTranscribe(): Promise { + if (!this._session || this._isInTerminalState()) return + if (this._audioState !== AudioState.STREAMING) return + if (!this._sttReady || this._interimBusy) return + // 0.8초 미만 오디오는 스킵 (16kHz 16bit mono) + if (this._audioBufferBytes < 16000 * 2 * 0.8) return + + this._interimBusy = true + const sessionId = this._session.id + try { + // 버퍼는 비우지 않고 복사만 (최종 전사가 전체 버퍼 사용) + const merged = Buffer.concat(this._audioBuffer) + const windowBytes = 16000 * 2 * 12 + const windowBuf = + merged.length > windowBytes ? merged.subarray(merged.length - windowBytes) : merged + + const language = configGet('sttLanguage') + const result = await getLocalSTTService().transcribe(windowBuf, { + language: language === 'auto' ? undefined : language, + }) + + // 세션이 바뀌었거나 이미 녹음이 끝났으면 폐기 + if (this._session?.id !== sessionId) return + if (this._audioState !== AudioState.STREAMING || this._isInTerminalState()) return + + const text = result.text.trim() + if (text.length > 0) { + sendPartialTranscriptToTip(text) + this.emit('transcription-update', { text, isFinal: false }) + } + } catch { + // interim 실패는 조용히 무시 — 최종 전사가 진실 + } finally { + this._interimBusy = false + } + } + // ── 이중 조건 플러시 ─────────────────────────────────── private _tryFlushAll(): void { @@ -556,10 +641,20 @@ class VoiceModeService extends EventEmitter { // premium backend는 내부에서 local fallback을 시도하므로 스킵 안 함. const llmAction = overrideAction ?? configGet('defaultLLMAction') const backend = configGet('llmBackend') - const skipLLM = - llmAction === 'none' || - (backend === 'local' && !getLocalLLMService().isAvailable()) + const ollamaUnavailable = backend === 'local' && !getLocalLLMService().isAvailable() + const skipLLM = llmAction === 'none' || ollamaUnavailable if (skipLLM) { + // Ollama 미가용으로 후처리를 건너뛰는 경우 사용자에게 경고 (원문 그대로 삽입됨을 알림) + if (llmAction !== 'none' && ollamaUnavailable) { + this.emit('error', { + error: new D3ROError( + ErrorCode.LLMServerUnreachable, + 'Ollama unavailable — inserting raw transcription without LLM post-processing', + ), + session: this._session ? { ...this._session } : null, + severity: 'warning', + }) + } this._completeSession(effectiveText) } else { await this._processWithLLM(effectiveText, overrideInstructionId) @@ -799,6 +894,7 @@ class VoiceModeService extends EventEmitter { } private _resetToIdle(): void { + this._stopInterimLoop() this._session = null this._audioBuffer = [] this._audioBufferBytes = 0 diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 3d36472..ed64a99 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -154,8 +154,9 @@ export function getRecordingTipWindow(): BrowserWindow { * Phase 2: resize → show */ // RecordingTip 기본 크기 (고정 — 2-phase 복잡도 제거) -const TIP_WIDTH = 280 -const TIP_HEIGHT = 80 +// v2: 실시간 부분 전사 라인 표시를 위해 상향 (280x80 → 320x110) +const TIP_WIDTH = 320 +const TIP_HEIGHT = 110 export function showRecordingTip( state: string, @@ -206,6 +207,13 @@ export function sendAudioLevelToTip(level: number): void { } } +/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */ +export function sendPartialTranscriptToTip(text: string): void { + if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { + recordingTipWindow.webContents.send('voice:partialTranscript', { text }) + } +} + // ── ResultPopup 팝업 ────────────────────────────────── function createResultPopupWindow(): BrowserWindow { diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index c045faa..af09a13 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -70,6 +70,8 @@ export function AppLayout(): React.ReactElement { const [currentTier, setCurrentTier] = useState('free') // Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled) const [fallbackMsg, setFallbackMsg] = useState(null) + // 음성 세션 에러/경고 배너 (모델 미설치, 엔진 실패, LLM 스킵 등) + const [voiceAlert, setVoiceAlert] = useState<{ message: string; severity: 'error' | 'warning' } | null>(null) // 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시 useEffect(() => { @@ -101,6 +103,22 @@ export function AppLayout(): React.ReactElement { const handleOpenSettings = () => setSettingsOpen(true) window.addEventListener('d3ro:open-settings', handleOpenSettings) + // 음성 세션 에러/경고 — 단축키 녹음 실패를 메인 UI에서도 명확히 알림. + // 모델 미설치(101)는 온보딩 모달을 함께 연다. + const unsubVoiceError = window.electronAPI.voice.onError((e) => { + const severity = e.severity ?? 'error' + let message = e.message + if (e.errorCode === 101) { + message = t('voice.error.modelMissing') + setOnboardingOpen(true) + } else if (e.errorCode === 103 || (e.errorCode >= 130 && e.errorCode <= 132)) { + message = t('voice.error.engine') + } else if (severity === 'warning' && e.errorCode === 300) { + message = t('voice.warning.llmSkipped') + } + setVoiceAlert({ message, severity }) + }) + // Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독 const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => { setFallbackMsg(e.reason) @@ -114,10 +132,11 @@ export function AppLayout(): React.ReactElement { unsubUpgrade() unsubFallback() unsubUpgradeReq() + unsubVoiceError() window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) window.removeEventListener('d3ro:open-settings', handleOpenSettings) } - }, []) + }, [t]) return ( @@ -300,6 +319,23 @@ export function AppLayout(): React.ReactElement { {fallbackMsg} + + {/* 음성 세션 에러/경고 배너 — 모델 미설치·엔진 실패·LLM 스킵 알림 */} + setVoiceAlert(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setVoiceAlert(null)} + sx={{ width: '100%' }} + > + {voiceAlert?.message ?? ''} + + ) } diff --git a/apps/desktop/src/renderer/popups/recording-tip/index.html b/apps/desktop/src/renderer/popups/recording-tip/index.html index dae416d..58ea4ba 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/index.html +++ b/apps/desktop/src/renderer/popups/recording-tip/index.html @@ -10,9 +10,13 @@
-
-
- 0:00 +
+
+
+ 0:00 +
+ +
diff --git a/apps/desktop/src/renderer/popups/recording-tip/script.js b/apps/desktop/src/renderer/popups/recording-tip/script.js index 2e4fe81..1141f03 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/script.js +++ b/apps/desktop/src/renderer/popups/recording-tip/script.js @@ -30,6 +30,7 @@ var durationText = document.getElementById('duration-text') var progressBar = document.getElementById('progress-bar') var errorText = document.getElementById('error-text') + var partialText = document.getElementById('partial-text') // ── 상태 ───────────────────────────────────────────── var bars = [] @@ -110,6 +111,10 @@ durationText.textContent = '0:00' currentHeights.fill(MIN_HEIGHT) + // 이전 세션의 부분 전사 리셋 + partialText.textContent = '' + partialText.classList.add('hidden') + animInterval = setInterval(updateBars, UPDATE_INTERVAL) durationInterval = setInterval(updateDuration, 1000) } @@ -166,6 +171,17 @@ audioLevel = data.level || 0 }) + // 실시간 부분 전사 — 녹음 중 말하는 내용 미리보기 + window.popupAPI.on('voice:partialTranscript', function (data) { + if (currentState !== 'recording') return + var text = (data && data.text) || '' + if (text.length === 0) return + partialText.textContent = text + partialText.classList.remove('hidden') + // 항상 끝부분(최근 발화)이 보이도록 스크롤 + partialText.scrollTop = partialText.scrollHeight + }) + // 상태 변경 (showRecordingTip + updateRecordingTipState 양쪽에서 사용) window.popupAPI.on('window:tipStateChanged', function (data) { var state = data.state diff --git a/apps/desktop/src/renderer/popups/recording-tip/style.css b/apps/desktop/src/renderer/popups/recording-tip/style.css index 6bfd71a..5fda14f 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/style.css +++ b/apps/desktop/src/renderer/popups/recording-tip/style.css @@ -133,3 +133,37 @@ body { .view.hidden { display: none; } + +/* ── 실시간 부분 전사 (recording 상태 하단) ─────────────── */ +.view-recording { + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 288px; +} + +.recording-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.partial-text { + color: var(--d3-text-primary); + font-size: 12px; + line-height: 1.45; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-align: left; + max-height: 35px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + border-top: 1px solid var(--d3-border-default); + padding-top: 5px; +} + +.partial-text.hidden { + display: none; +} diff --git a/apps/desktop/tests/main/services/VoiceModeService.test.ts b/apps/desktop/tests/main/services/VoiceModeService.test.ts index 1116fa8..58e2ea0 100644 --- a/apps/desktop/tests/main/services/VoiceModeService.test.ts +++ b/apps/desktop/tests/main/services/VoiceModeService.test.ts @@ -21,6 +21,10 @@ const mockSTT = { Promise.resolve({ text: '테스트 전사', segments: [], language: 'ko', duration: 2, processingTime: 500 }) ), getStatus: vi.fn(() => ({ state: 'ready', modelId: 'base', uptime: 0 })), + // 프리플라이트 검증(모델 설치 여부)용 — 기본은 설치된 상태로 목킹 + getModels: vi.fn(() => [ + { id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 }, + ]), on: vi.fn(), off: vi.fn() } diff --git a/memory/project_status.md b/memory/project_status.md index 04d37f5..0641398 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -2,6 +2,25 @@ > 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩) +## 받아쓰기 파이프라인 4버그 수정 + 실시간 부분 전사 (2026-07-21 /goal) ✅ + +### 증상 → 근본 원인 (로그 실측) +1. "전사가 안 되는데 되는 것처럼 보임" + 4. "키를 떼도 안 끝남": **press 액션이 STT 초기화(사이드카 죽으면 30초 헬스 타임아웃)를 await하며 action queue를 점유** → release가 수십 초 지연, 연타한 press들이 큐에 쌓여 30초 간격 유령 세션 반복(마이크 게이지는 동작해 "되는 척"). 트리거는 dev 사이드카 의존성 미설치(venv 부재)였지만 구조 결함이 본질. +2. 사이드카 crash 루프: 재시작 카운트가 MAX(3) 초과해도 세션마다 재spawn + `emit('error')` 리스너 부재로 ERR_UNHANDLED_ERROR 미처리 예외. + +### 수정 +- **VoiceModeService**: `_initSTT()`를 fire-and-forget으로(await 제거 — release 즉시 처리), **프리플라이트**(STT 모델 미설치 시 즉시 에러+경고), **실시간 부분 전사 루프**(1.5s 간격, 최근 12초 윈도우, interim 실패 무시·최종 전사가 진실), Ollama 미가용 후처리 스킵 시 warning 이벤트 +- **LocalSTTService**: 헬스체크 중 프로세스 사망 감지 → 30초 대기 없이 즉시 실패, 헬스 성공 시 restartCount 리셋, 기본 error sink로 미처리 예외 방지 +- **voice-handlers**: VoiceMode 'error' → `voice:error` 브로드캐스트(severity 포함, 설계서 02 채널) +- **AppLayout**: voice.onError 구독 → 에러/경고 스낵바 + 모델 미설치(101)면 온보딩 자동 오픈. i18n 3키(ko/en) +- **RecordingTip 팝업**: 부분 전사 표시 영역 추가(2줄 클램프, 팝업 320x110), `voice:partialTranscript` 채널 +- dev 환경: sidecar venv 구축 (재발 방지) + +### E2E 실측 (dev, GPU cuda float16 + large-v3-turbo 로컬 디렉토리 로드) +- 콜드: 모델 로드 3.4s, 전사 5.6s→2.3s / 웜: 전사 0.5~1.0s +- 10초 홀드: interim 2회 발동(부분 전사 팁 전송) → 릴리스 1ms 내 캡처 중지 → 최종 전사 → 텍스트 삽입 43자 → WAV 저장 +- vitest 41/41 (VoiceModeService mock에 getModels 추가) + ## UI 전면 리디자인 "Midnight Glass" v2 (2026-07-21) — 기반 완성 ✅ ### 방향 (사용자 레퍼런스 이미지 기반, /goal) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1a71ca8..985ef34 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -96,6 +96,8 @@ export interface VoiceErrorEvent { sessionId: string | null errorCode: number message: string + /** 'warning'은 세션을 막지 않는 알림 (예: Ollama 미가용으로 LLM 후처리 스킵) */ + severity?: 'error' | 'warning' } export interface AudioLevelEvent { diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 4cb2e62..22b9d68 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -173,6 +173,9 @@ "settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)", "settings.about.description": "A fully local AI voice assistant built on Speakly reverse-engineering insights. Runs entirely offline with no cloud dependencies.", "settings.about.restartOnboarding": "Restart Setup Wizard", + "voice.error.modelMissing": "Speech model is not installed. Please download it first.", + "voice.error.engine": "Failed to start the speech engine. Please try again.", + "voice.warning.llmSkipped": "Ollama is unavailable — inserted the raw transcription.", "status.localTime": "Local Time", "status.ollama": "OLLAMA", "status.offline": "OFFLINE", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index c3dce06..6c49d6a 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -174,6 +174,9 @@ "settings.about.voiceEngineValue": "STT: faster-whisper (로컬) / LLM: Ollama (로컬)", "settings.about.description": "Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다.", "settings.about.restartOnboarding": "초기 설정 안내 다시 보기", + "voice.error.modelMissing": "음성 인식 모델이 설치되지 않았습니다. 모델을 다운로드해 주세요.", + "voice.error.engine": "음성 인식 엔진을 시작하지 못했습니다. 잠시 후 다시 시도해 주세요.", + "voice.warning.llmSkipped": "Ollama에 연결할 수 없어 원문 그대로 삽입했습니다.", "status.localTime": "로컬 시간", "status.ollama": "OLLAMA", "status.offline": "OFFLINE",