fix(voice): 받아쓰기 파이프라인 4버그 수정 + 실시간 부분 전사

- press가 STT 초기화를 await하며 action queue 점유 → release 수십 초 지연·
  유령 세션 반복 버그 수정 (initSTT fire-and-forget)
- 프리플라이트: STT 모델 미설치 시 즉시 에러 + 메인 UI 경고 + 온보딩 오픈
- 사이드카: 기동 중 프로세스 사망 시 30초 대기 없이 즉시 실패,
  restartCount 리셋, error 리스너 부재 미처리 예외 방지
- 실시간 부분 전사: 1.5s 간격 interim → RecordingTip에 말하는 내용 미리보기
- Ollama 미가용 후처리 스킵 시 warning 배너, voice:error 브로드캐스트 신설
This commit is contained in:
Yun Chan 2026-07-21 20:56:40 +09:00
parent e0a1864e60
commit fd46ac7b14
13 changed files with 280 additions and 16 deletions

View file

@ -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}`,
)

View file

@ -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<void> {
@ -436,6 +463,7 @@ class VoiceModeService extends EventEmitter {
}
private async _stopAudio(): Promise<void> {
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<void> {
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