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:
parent
9dc8b26c11
commit
983c60cda2
27 changed files with 688 additions and 76 deletions
|
|
@ -21,7 +21,8 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
autoLaunch: false,
|
||||
soundEnabled: true,
|
||||
selectedDeviceId: null,
|
||||
sttModelId: 'base',
|
||||
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드)
|
||||
sttModelId: 'large-v3-turbo',
|
||||
sttLanguage: 'auto',
|
||||
ttsVoiceId: null,
|
||||
ttsSpeed: 1.0,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue