feat(bootstrap): Whisper large-v3-turbo 기본 전환 + 온보딩 2단계 다운로드 진행률

- 기본 STT 모델 base → large-v3-turbo (6배 빠름, 1.6GB)
- 사이드카: /download, /download/status, /download/cancel + --models-dir
- LocalSTTService: downloadModel/cancelDownload + download-progress 이벤트
- IPC: 설계서 02의 stt:downloadModel/cancelDownload/downloadProgress 구현
- OnboardingModal: LLM(gemma4:e4b) → STT(turbo) 2단계 순차 다운로드 UI
- SettingsModal turbo 선택지 + settings.model.largeTurbo 12 locale
- 테스트: 모노레포 잔재 import 수정 (src/shared → @d3ro/core), 41/41 통과
This commit is contained in:
Yun Chan 2026-07-21 11:59:49 +09:00
parent 9dc8b26c11
commit 983c60cda2
27 changed files with 688 additions and 76 deletions

View file

@ -6,11 +6,18 @@
import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import { createServer } from 'net'
import { existsSync } from 'fs'
import { join } from 'path'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getSidecarCommand } from '../utils/paths'
import { getSidecarCommand, getWhisperModelsDir } from '../utils/paths'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { STTModel, STTStatus, STTEngineState } from '@d3ro/core/types'
import type {
STTModel,
STTStatus,
STTEngineState,
DownloadProgressEvent,
} from '@d3ro/core/types'
// ── 내부 타입 정의 ────────────────────────────────────────
@ -61,6 +68,17 @@ interface LoadResponse {
load_time_ms: number
}
/** sidecar /download/status 응답 */
interface DownloadStatusResponse {
status: 'idle' | 'downloading' | 'done' | 'cancelled' | 'error'
model_id: string | null
percent: number
downloaded_bytes: number
total_bytes: number
bytes_per_second: number
message: string | null
}
/** sidecar /transcribe 응답 */
interface TranscribeResponse {
text: string
@ -80,6 +98,7 @@ export interface LocalSTTEvents {
'transcription-delta': { text: string; isFinal: boolean }
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'download-progress': DownloadProgressEvent
'error': { error: D3ROError }
}
@ -138,6 +157,15 @@ const MODEL_CATALOG: STTModel[] = [
accuracy: 5,
speed: 1,
},
{
id: 'large-v3-turbo',
name: 'Large V3 Turbo',
sizeBytes: 1_600_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 5,
speed: 3,
},
]
// ── 서비스 구현 ───────────────────────────────────────────
@ -201,10 +229,7 @@ class LocalSTTService extends EventEmitter {
try {
// sidecar가 아직 실행 중이 아니면 시작
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
await this._ensureSidecarRunning()
// 모델 로딩
await this._loadModel(targetModel)
@ -269,16 +294,116 @@ class LocalSTTService extends EventEmitter {
/**
* .
* sidecar에 (faster-whisper가 ).
* models-dir + downloaded를 .
*/
getModels(): STTModel[] {
const modelsDir = getWhisperModelsDir()
return MODEL_CATALOG.map((m) => ({
...m,
// 현재 로딩된 모델은 downloaded=true로 표시
downloaded: m.id === this._currentModelId ? true : m.downloaded,
downloaded:
m.id === this._currentModelId ||
existsSync(join(modelsDir, m.id, 'model.bin')),
}))
}
/**
* (sidecar /download + ).
* 'download-progress' emit하며, resolve.
*/
async downloadModel(modelId: string): Promise<void> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
await this._ensureSidecarRunning()
const startRes = await fetch(`http://localhost:${this._port}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
signal: AbortSignal.timeout(10000),
})
if (!startRes.ok) {
const text = await startRes.text()
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`다운로드 시작 실패 (HTTP ${startRes.status}): ${text}`,
)
}
const started = (await startRes.json()) as { status: string }
if (started.status === 'done') {
this._emitDownloadProgress(modelId, 100, 0, 0, 0)
return
}
// 진행률 폴링 — sidecar 통신 실패가 연속되면 중단
let consecutiveFailures = 0
for (;;) {
await this._sleep(500)
let status: DownloadStatusResponse
try {
const res = await fetch(`http://localhost:${this._port}/download/status`, {
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
status = (await res.json()) as DownloadStatusResponse
consecutiveFailures = 0
} catch (err) {
consecutiveFailures++
if (consecutiveFailures >= 5) {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`다운로드 상태 조회 실패: ${err instanceof Error ? err.message : String(err)}`,
)
}
continue
}
this._emitDownloadProgress(
modelId,
status.percent,
status.downloaded_bytes,
status.total_bytes,
status.bytes_per_second,
)
if (status.status === 'done') {
logger.info(`모델 다운로드 완료: ${modelId}`)
return
}
if (status.status === 'cancelled') {
throw new D3ROError(
ErrorCode.STTModelDownloadCancelled,
`모델 다운로드 취소: ${modelId}`,
)
}
if (status.status === 'error') {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`모델 다운로드 실패: ${status.message ?? 'unknown'}`,
)
}
}
}
/** 진행 중인 모델 다운로드 취소 요청 */
async cancelDownload(): Promise<void> {
try {
await fetch(`http://localhost:${this._port}/download/cancel`, {
method: 'POST',
signal: AbortSignal.timeout(3000),
})
} catch {
// sidecar 미기동/통신 불가 — 취소할 다운로드가 없음
}
}
/** 현재 상태 조회 */
getStatus(): STTStatus {
const stateMap: Record<STTState, STTEngineState> = {
@ -359,6 +484,30 @@ class LocalSTTService extends EventEmitter {
// ── Sidecar 관리 ──
/** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */
private async _ensureSidecarRunning(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
}
private _emitDownloadProgress(
modelId: string,
percent: number,
downloadedBytes: number,
totalBytes: number,
bytesPerSecond: number,
): void {
this.emit('download-progress', {
modelId,
percent,
downloadedBytes,
totalBytes,
bytesPerSecond,
})
}
/**
* maxAttempts개 free .
* dev mode sidecar가 orphan으로
@ -409,7 +558,13 @@ class LocalSTTService extends EventEmitter {
this._port = await this._findFreePort(SIDECAR_PORT, 20)
const { command, args } = getSidecarCommand()
const fullArgs = [...args, '--port', String(this._port)]
const fullArgs = [
...args,
'--port',
String(this._port),
'--models-dir',
getWhisperModelsDir(),
]
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
return new Promise<void>((resolve, reject) => {