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:
parent
e0a1864e60
commit
fd46ac7b14
13 changed files with 280 additions and 16 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export function AppLayout(): React.ReactElement {
|
|||
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
||||
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
|
||||
const [fallbackMsg, setFallbackMsg] = useState<string | null>(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 (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||
|
|
@ -300,6 +319,23 @@ export function AppLayout(): React.ReactElement {
|
|||
{fallbackMsg}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{/* 음성 세션 에러/경고 배너 — 모델 미설치·엔진 실패·LLM 스킵 알림 */}
|
||||
<Snackbar
|
||||
open={voiceAlert !== null}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setVoiceAlert(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={voiceAlert?.severity ?? 'error'}
|
||||
variant="filled"
|
||||
onClose={() => setVoiceAlert(null)}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{voiceAlert?.message ?? ''}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@
|
|||
<div id="root">
|
||||
<div id="container" class="recording-tip">
|
||||
<!-- recording 상태 -->
|
||||
<div id="recording-view" class="view">
|
||||
<div id="wave-bars" class="wave-bars"></div>
|
||||
<span id="duration-text" class="duration">0:00</span>
|
||||
<div id="recording-view" class="view view-recording">
|
||||
<div class="recording-row">
|
||||
<div id="wave-bars" class="wave-bars"></div>
|
||||
<span id="duration-text" class="duration">0:00</span>
|
||||
</div>
|
||||
<!-- 실시간 부분 전사 미리보기 -->
|
||||
<div id="partial-text" class="partial-text hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- thinking 상태 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ export interface VoiceErrorEvent {
|
|||
sessionId: string | null
|
||||
errorCode: number
|
||||
message: string
|
||||
/** 'warning'은 세션을 막지 않는 알림 (예: Ollama 미가용으로 LLM 후처리 스킵) */
|
||||
severity?: 'error' | 'warning'
|
||||
}
|
||||
|
||||
export interface AudioLevelEvent {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue