diff --git a/apps/desktop/sidecar/main.py b/apps/desktop/sidecar/main.py index 688492f..3c3a3ab 100644 --- a/apps/desktop/sidecar/main.py +++ b/apps/desktop/sidecar/main.py @@ -3,13 +3,16 @@ D3RO-VOICE STT Sidecar (FastAPI HTTP 서버) faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포. 사용법: - python main.py --port 18765 + python main.py --port 18765 --models-dir 엔드포인트: - GET /health - 헬스체크 - POST /load - Whisper 모델 로딩 - POST /transcribe - 오디오 전사 (multipart) - POST /shutdown - 서버 종료 + GET /health - 헬스체크 + POST /load - Whisper 모델 로딩 + POST /transcribe - 오디오 전사 (multipart) + POST /download - 모델 다운로드 시작 (백그라운드) + GET /download/status - 다운로드 진행률 조회 + POST /download/cancel - 다운로드 취소 + POST /shutdown - 서버 종료 주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며, pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정. @@ -18,12 +21,15 @@ pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공 from __future__ import annotations import argparse +import fnmatch import logging import os import signal import sys +import threading import time from contextlib import asynccontextmanager +from pathlib import Path from typing import AsyncGenerator import numpy as np @@ -47,6 +53,37 @@ _model: "WhisperModel | None" = None _model_id: str | None = None _gpu_available: bool = False _server: uvicorn.Server | None = None +_models_dir: Path | None = None + +# ── 다운로드 상태 (스레드 공유) ──────────────────────────── + +_download_lock = threading.Lock() +_download_thread: threading.Thread | None = None +_download_cancel = threading.Event() +_download_state: dict = { + "status": "idle", # idle | downloading | done | cancelled | error + "model_id": None, + "percent": 0, + "downloaded_bytes": 0, + "total_bytes": 0, + "bytes_per_second": 0, + "message": None, +} + +# faster-whisper가 다운로드하는 파일과 동일한 화이트리스트 +_DOWNLOAD_PATTERNS = [ + "config.json", + "preprocessor_config.json", + "model.bin", + "tokenizer.json", + "vocabulary.*", +] + +# faster_whisper.utils._MODELS 매핑 실패 시 폴백 +_FALLBACK_REPOS = { + "large-v3-turbo": "mobiuslabsgmbh/faster-whisper-large-v3-turbo", + "turbo": "mobiuslabsgmbh/faster-whisper-large-v3-turbo", +} # ── FastAPI 앱 ───────────────────────────────────────────── @@ -84,6 +121,135 @@ def _detect_gpu() -> None: logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc) +# ── 모델 다운로드 헬퍼 ───────────────────────────────────── + + +def _resolve_repo(model_id: str) -> str: + """모델 ID를 HuggingFace repo ID로 변환한다.""" + if "/" in model_id: + return model_id + try: + from faster_whisper.utils import _MODELS + + if model_id in _MODELS: + return _MODELS[model_id] + except Exception: + pass + if model_id in _FALLBACK_REPOS: + return _FALLBACK_REPOS[model_id] + return f"Systran/faster-whisper-{model_id}" + + +def _local_model_dir(model_id: str) -> Path | None: + """models_dir 내 다운로드 완료된 모델 디렉토리를 반환한다 (없으면 None).""" + if _models_dir is None: + return None + model_dir = _models_dir / model_id + if (model_dir / "model.bin").exists(): + return model_dir + return None + + +def _set_download_state(**kwargs: object) -> None: + with _download_lock: + _download_state.update(kwargs) + + +def _download_worker(model_id: str) -> None: + """백그라운드 스레드: HF repo 파일들을 스트리밍 다운로드한다.""" + import requests + from huggingface_hub import HfApi, hf_hub_url + + try: + repo_id = _resolve_repo(model_id) + logger.info("모델 다운로드 시작: %s (repo=%s)", model_id, repo_id) + + api = HfApi() + info = api.model_info(repo_id, files_metadata=True) + files = [ + s + for s in (info.siblings or []) + if any(fnmatch.fnmatch(s.rfilename, p) for p in _DOWNLOAD_PATTERNS) + ] + if not files: + raise RuntimeError(f"다운로드할 파일이 없습니다: {repo_id}") + + assert _models_dir is not None + target_dir = _models_dir / model_id + target_dir.mkdir(parents=True, exist_ok=True) + + total_bytes = sum(s.size or 0 for s in files) + downloaded = 0 + start_time = time.monotonic() + _set_download_state(total_bytes=total_bytes, downloaded_bytes=0, percent=0) + + for sibling in files: + fname = sibling.rfilename + fsize = sibling.size or 0 + dest = target_dir / fname + + # 멱등: 이미 크기 일치하는 파일은 스킵 + if dest.exists() and fsize > 0 and dest.stat().st_size == fsize: + downloaded += fsize + _set_download_state( + downloaded_bytes=downloaded, + percent=int(downloaded * 100 / total_bytes) if total_bytes else 0, + ) + logger.info("이미 존재, 스킵: %s", fname) + continue + + if _download_cancel.is_set(): + raise InterruptedError() + + url = hf_hub_url(repo_id, fname) + part = dest.with_suffix(dest.suffix + ".part") + logger.info("다운로드: %s (%.1f MB)", fname, fsize / 1e6) + + with requests.get(url, stream=True, timeout=30) as resp: + resp.raise_for_status() + with open(part, "wb") as fh: + for chunk in resp.iter_content(chunk_size=1024 * 1024): + if _download_cancel.is_set(): + raise InterruptedError() + fh.write(chunk) + downloaded += len(chunk) + elapsed = time.monotonic() - start_time + _set_download_state( + downloaded_bytes=downloaded, + percent=( + int(downloaded * 100 / total_bytes) if total_bytes else 0 + ), + bytes_per_second=int(downloaded / elapsed) if elapsed > 0 else 0, + ) + os.replace(part, dest) + + _set_download_state(status="done", percent=100) + logger.info("모델 다운로드 완료: %s (%.1f MB)", model_id, downloaded / 1e6) + + except InterruptedError: + _set_download_state(status="cancelled", message="사용자 취소") + logger.info("모델 다운로드 취소: %s", model_id) + _cleanup_partial(model_id) + except Exception as exc: + _set_download_state(status="error", message=str(exc)) + logger.error("모델 다운로드 실패: %s", exc, exc_info=True) + _cleanup_partial(model_id) + + +def _cleanup_partial(model_id: str) -> None: + """취소/실패 시 .part 잔여 파일 정리.""" + if _models_dir is None: + return + target_dir = _models_dir / model_id + if not target_dir.exists(): + return + for part in target_dir.glob("*.part"): + try: + part.unlink() + except OSError: + pass + + # ── 엔드포인트 ───────────────────────────────────────────── @@ -111,7 +277,7 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 """ global _model, _model_id - model_id: str = body.get("model_id", "large-v3") + model_id: str = body.get("model_id", "large-v3-turbo") logger.info("모델 로딩 시작: %s", model_id) start_time = time.monotonic() @@ -122,8 +288,15 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 device = "cuda" if _gpu_available else "cpu" compute_type = "float16" if _gpu_available else "int8" + # /download로 미리 받아둔 로컬 디렉토리가 있으면 우선 사용. + # 없으면 faster-whisper의 HF 자동 다운로드 경로로 폴백. + local_dir = _local_model_dir(model_id) + model_source = str(local_dir) if local_dir else model_id + if local_dir: + logger.info("로컬 모델 디렉토리 사용: %s", local_dir) + _model = WhisperModel( - model_id, + model_source, device=device, compute_type=compute_type, ) @@ -266,6 +439,71 @@ async def transcribe( ) +@app.post("/download") +async def download_model(body: dict) -> JSONResponse: # noqa: ANN001 + """모델 다운로드를 백그라운드로 시작한다. + + Request body: + { "model_id": "large-v3-turbo" } + + Returns: + { "status": "started" } 또는 이미 완료된 경우 { "status": "done" } + """ + global _download_thread + + if _models_dir is None: + return JSONResponse( + status_code=500, + content={"status": "error", "message": "models-dir가 설정되지 않았습니다"}, + ) + + model_id: str = body.get("model_id", "large-v3-turbo") + + # 이미 다운로드 완료된 모델이면 즉시 done + if _local_model_dir(model_id) is not None: + _set_download_state(status="done", model_id=model_id, percent=100) + return JSONResponse(content={"status": "done"}) + + if _download_thread is not None and _download_thread.is_alive(): + return JSONResponse( + status_code=409, + content={"status": "error", "message": "이미 다운로드가 진행 중입니다"}, + ) + + _download_cancel.clear() + _set_download_state( + status="downloading", + model_id=model_id, + percent=0, + downloaded_bytes=0, + total_bytes=0, + bytes_per_second=0, + message=None, + ) + _download_thread = threading.Thread( + target=_download_worker, args=(model_id,), daemon=True + ) + _download_thread.start() + + return JSONResponse(content={"status": "started"}) + + +@app.get("/download/status") +async def download_status() -> JSONResponse: + """현재 다운로드 상태를 반환한다.""" + with _download_lock: + return JSONResponse(content=dict(_download_state)) + + +@app.post("/download/cancel") +async def download_cancel() -> JSONResponse: + """진행 중인 다운로드를 취소한다.""" + if _download_thread is not None and _download_thread.is_alive(): + _download_cancel.set() + return JSONResponse(content={"status": "cancelling"}) + return JSONResponse(content={"status": "idle"}) + + @app.post("/shutdown") async def shutdown() -> JSONResponse: """서버를 graceful하게 종료한다.""" @@ -282,7 +520,7 @@ async def shutdown() -> JSONResponse: def main() -> None: """CLI 진입점.""" - global _server + global _server, _models_dir parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar") parser.add_argument( @@ -297,8 +535,18 @@ def main() -> None: default="127.0.0.1", help="HTTP 서버 호스트 (기본: 127.0.0.1)", ) + parser.add_argument( + "--models-dir", + type=str, + default=None, + help="사전 다운로드 모델 저장 디렉토리 (미지정 시 HF 캐시만 사용)", + ) args = parser.parse_args() + if args.models_dir: + _models_dir = Path(args.models_dir) + _models_dir.mkdir(parents=True, exist_ok=True) + logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port) def signal_handler(signum: int, _frame: object) -> None: diff --git a/apps/desktop/sidecar/requirements.txt b/apps/desktop/sidecar/requirements.txt index afb99ca..2d52c81 100644 --- a/apps/desktop/sidecar/requirements.txt +++ b/apps/desktop/sidecar/requirements.txt @@ -1,5 +1,7 @@ -faster-whisper>=1.0.0 +faster-whisper>=1.1.0 fastapi>=0.109.0 uvicorn>=0.27.0 python-multipart>=0.0.6 numpy>=1.24.0 +huggingface_hub>=0.23.0 +requests>=2.31.0 diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts index a030412..d0ed72e 100644 --- a/apps/desktop/src/main/ipc/stt-handlers.ts +++ b/apps/desktop/src/main/ipc/stt-handlers.ts @@ -2,12 +2,29 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' -import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' +import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { getLocalSTTService } from '../services/LocalSTTService' import { configGet, configSet } from '../services/ConfigService' -import type { SetSTTModelParams, SetSTTLanguageParams } from '@d3ro/core/types' +import { getMainWindow } from '../windows/WindowManager' +import type { + SetSTTModelParams, + SetSTTLanguageParams, + DownloadModelParams, +} 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 registerSTTHandlers(): void { + // 다운로드 진행률 → 렌더러 + getLocalSTTService().on('download-progress', (payload) => { + safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload) + }) + ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => { try { const stt = getLocalSTTService() @@ -41,6 +58,27 @@ export function registerSTTHandlers(): void { } }) + ipcMain.handle( + IPC_CHANNELS.STT.DOWNLOAD_MODEL, + async (_event, params: DownloadModelParams) => { + try { + await getLocalSTTService().downloadModel(params.modelId) + return ipcSuccess(undefined) + } catch (err) { + if (err instanceof D3ROError) { + return ipcError(err.code, err.message) + } + const msg = err instanceof Error ? err.message : String(err) + return ipcError(ErrorCode.STTModelDownloadFailed, `Model download failed: ${msg}`) + } + }, + ) + + ipcMain.handle(IPC_CHANNELS.STT.CANCEL_DOWNLOAD, async () => { + await getLocalSTTService().cancelDownload() + return ipcSuccess(undefined) + }) + ipcMain.handle(IPC_CHANNELS.STT.GET_LANGUAGE, async () => { return ipcSuccess(configGet('sttLanguage')) }) diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index de7c204..68b0c0f 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -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, diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index ff229b6..6711e34 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -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 { + 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 { + try { + await fetch(`http://localhost:${this._port}/download/cancel`, { + method: 'POST', + signal: AbortSignal.timeout(3000), + }) + } catch { + // sidecar 미기동/통신 불가 — 취소할 다운로드가 없음 + } + } + /** 현재 상태 조회 */ getStatus(): STTStatus { const stateMap: Record = { @@ -359,6 +484,30 @@ class LocalSTTService extends EventEmitter { // ── Sidecar 관리 ── + /** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */ + private async _ensureSidecarRunning(): Promise { + 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((resolve, reject) => { diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts index 0a9449d..08c9fce 100644 --- a/apps/desktop/src/main/utils/paths.ts +++ b/apps/desktop/src/main/utils/paths.ts @@ -143,3 +143,11 @@ export function getFfmpegPath(): string { export function getUserDataPath(): string { return app.getPath('userData') } + +/** + * Whisper 모델 사전 다운로드 디렉토리. + * sidecar --models-dir로 전달되며, 메인 프로세스의 downloaded 판정에도 사용된다. + */ +export function getWhisperModelsDir(): string { + return path.join(app.getPath('userData'), 'whisper-models') +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index df7a40f..84739a0 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -35,6 +35,8 @@ import type { SetSTTModelParams, SetSTTLanguageParams, STTStatusChangedEvent, + DownloadModelParams, + DownloadProgressEvent, HotkeyBinding, SetHotkeyParams, SetEnabledParams, @@ -251,11 +253,16 @@ const electronAPI = { getActiveModel: () => invoke(IPC_CHANNELS.STT.GET_ACTIVE_MODEL), setModel: (params: SetSTTModelParams) => invoke(IPC_CHANNELS.STT.SET_MODEL, params), + downloadModel: (params: DownloadModelParams) => + invoke(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params), + cancelDownload: () => invoke(IPC_CHANNELS.STT.CANCEL_DOWNLOAD), getLanguage: () => invoke(IPC_CHANNELS.STT.GET_LANGUAGE), setLanguage: (params: SetSTTLanguageParams) => invoke(IPC_CHANNELS.STT.SET_LANGUAGE, params), onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe => - on(IPC_CHANNELS.STT.STATUS_CHANGED, cb) + on(IPC_CHANNELS.STT.STATUS_CHANGED, cb), + onDownloadProgress: (cb: (e: DownloadProgressEvent) => void): Unsubscribe => + on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb) }, // ── Hotkey ───────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index b67564e..48a9fe7 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -1,10 +1,14 @@ // src/renderer/components/OnboardingModal.tsx -// 첫 실행 온보딩 모달 — 기본 LLM 모델(gemma4:e4b) 미설치 시 다운로드 유도. +// 첫 실행 온보딩 모달 — 기본 모델 미설치 시 다운로드 유도. +// +// 2단계 부트스트랩: +// 1) LLM: gemma4:e4b (Ollama pull, ~9.6GB) +// 2) STT: Whisper large-v3-turbo (sidecar 사전 다운로드, ~1.6GB) +// 필요한 단계만 실행하며(멱등), 모두 성공 시 config.onboardingCompleted=true 저장. // // 두 경로로 열림: // 1) AppLayout의 첫 실행 감지(onboardingCompleted=false) // 2) 런타임 중 모델 미설치 감지 (주기적 polling) -// 다운로드 성공 시 config.onboardingCompleted=true로 저장. import { useState, useEffect, useCallback, useRef } from 'react' import { @@ -23,36 +27,77 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' -const DEFAULT_MODEL = 'gemma4:e4b' +const DEFAULT_LLM_MODEL = 'gemma4:e4b' +const FALLBACK_STT_MODEL = 'large-v3-turbo' + +type StepKind = 'llm' | 'stt' type Phase = 'prompt' | 'downloading' | 'success' | 'failed' +interface NeededSteps { + steps: StepKind[] + sttModelId: string +} + interface OnboardingModalProps { open: boolean onClose: () => void } +/** 설치가 필요한 단계 목록을 계산한다 (멱등 — 이미 설치된 단계는 제외) */ +async function computeNeededSteps(): Promise { + const steps: StepKind[] = [] + + // 1) LLM 모델 존재 여부 + try { + const llmResult = await window.electronAPI.llm.getModels() + const hasLlm = + llmResult.success && llmResult.data.some((m) => m.id === DEFAULT_LLM_MODEL) + if (!hasLlm) steps.push('llm') + } catch { + steps.push('llm') + } + + // 2) STT 모델 다운로드 여부 (현재 설정된 모델 기준) + let sttModelId = FALLBACK_STT_MODEL + try { + const activeResult = await window.electronAPI.stt.getActiveModel() + if (activeResult.success && activeResult.data) { + sttModelId = activeResult.data + } + const modelsResult = await window.electronAPI.stt.getModels() + const downloaded = + modelsResult.success && + modelsResult.data.some((m) => m.id === sttModelId && m.downloaded) + if (!downloaded) steps.push('stt') + } catch { + steps.push('stt') + } + + return { steps, sttModelId } +} + export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { const { t } = useI18n() const [internalOpen, setInternalOpen] = useState(false) const [phase, setPhase] = useState('prompt') + const [neededSteps, setNeededSteps] = useState([]) + const [stepIndex, setStepIndex] = useState(0) + const [sttModelId, setSttModelId] = useState(FALLBACK_STT_MODEL) const [percent, setPercent] = useState(0) const [status, setStatus] = useState('') + const [detail, setDetail] = useState('') const [errorMsg, setErrorMsg] = useState('') - const unsubRef = useRef<(() => void) | null>(null) + const activeStepRef = useRef(null) const isVisible = open || internalOpen // 모델 존재 여부 체크 — 없으면 auto-open const checkModels = useCallback(async (): Promise => { try { - const result = await window.electronAPI.llm.getModels() - if (!result.success) { - setPhase('prompt') - setInternalOpen(true) - return - } - const hasDefault = result.data.some((m) => m.id === DEFAULT_MODEL) - if (!hasDefault) { + const needed = await computeNeededSteps() + setSttModelId(needed.sttModelId) + if (needed.steps.length > 0) { + setNeededSteps(needed.steps) setPhase('prompt') setInternalOpen(true) } else { @@ -75,36 +120,73 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. } }, [checkModels, phase]) - // pull 진행률 구독 + // LLM pull 진행률 구독 useEffect(() => { const unsub = window.electronAPI.llm.onPullProgress((e) => { - if (e.modelId !== DEFAULT_MODEL) return + if (activeStepRef.current !== 'llm') return + if (e.modelId !== DEFAULT_LLM_MODEL) return setStatus(e.status) if (e.percent > 0) setPercent(e.percent) }) - unsubRef.current = unsub return () => { unsub() - unsubRef.current = null } }, []) + // STT 다운로드 진행률 구독 + useEffect(() => { + const unsub = window.electronAPI.stt.onDownloadProgress((e) => { + if (activeStepRef.current !== 'stt') return + setPercent(e.percent) + if (e.totalBytes > 0) { + setDetail( + t('onboarding.progressDetail', { + downloaded: String(Math.round(e.downloadedBytes / 1e6)), + total: String(Math.round(e.totalBytes / 1e6)), + }), + ) + } + }) + return () => { + unsub() + } + }, [t]) + const handleDownload = useCallback(async (): Promise => { setPhase('downloading') - setPercent(0) - setStatus('') setErrorMsg('') - const result = await window.electronAPI.llm.pullModel({ modelId: DEFAULT_MODEL }) - if (result.success) { - setPhase('success') + // 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산 + const needed = await computeNeededSteps() + setSttModelId(needed.sttModelId) + setNeededSteps(needed.steps) + + for (let i = 0; i < needed.steps.length; i++) { + const step = needed.steps[i] + setStepIndex(i) + setPercent(0) + setStatus('') + setDetail('') + activeStepRef.current = step + + const result = + step === 'llm' + ? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL }) + : await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId }) + + if (!result.success) { + activeStepRef.current = null + setPhase('failed') + setErrorMsg(result.error?.message ?? 'unknown') + return + } setPercent(100) - // 온보딩 완료 플래그 저장 - window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) - } else { - setPhase('failed') - setErrorMsg(result.error?.message ?? 'unknown') } + + activeStepRef.current = null + setPhase('success') + // 온보딩 완료 플래그 저장 + window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) }, []) const handleClose = useCallback(() => { @@ -123,6 +205,34 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. const colorDanger = d3roPalette.tag.red const colorAccent = d3roPalette.accent.amber + const currentStep: StepKind | null = isDownloading + ? (neededSteps[stepIndex] ?? null) + : null + + /** 단계별 안내 박스 렌더 */ + const renderStepInfo = (step: StepKind): React.ReactElement => ( + + + {step === 'llm' + ? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL }) + : t('onboarding.sttModelMissing', { model: sttModelId })} + + + {step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')} + + + ) + return ( - {(phase === 'prompt' || isDownloading) && ( - - - {t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })} - - - {t('onboarding.llmModelSize')} - - - )} + {(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)} {isDownloading && ( - {t('onboarding.downloading')} + {neededSteps.length > 1 + ? `${t('onboarding.step', { + current: String(stepIndex + 1), + total: String(neededSteps.length), + })} — ${t('onboarding.downloading')}` + : t('onboarding.downloading')} {percent}% @@ -197,6 +295,13 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. value={percent} sx={{ height: 8, borderRadius: d3roRadius.xs }} /> + {detail && ( + + {detail} + + )} {status && ( {t('settings.model.small')} {t('settings.model.medium')} {t('settings.model.large')} + {t('settings.model.largeTurbo')} diff --git a/apps/desktop/tests/main/services/VoiceModeService.test.ts b/apps/desktop/tests/main/services/VoiceModeService.test.ts index 120e3d1..1116fa8 100644 --- a/apps/desktop/tests/main/services/VoiceModeService.test.ts +++ b/apps/desktop/tests/main/services/VoiceModeService.test.ts @@ -2,8 +2,8 @@ // 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트 import { describe, it, expect, beforeEach, vi } from 'vitest' -import { RecognitionState, AudioState } from '../../../src/shared/types' -import { TIMING } from '../../../src/shared/constants' +import { RecognitionState, AudioState } from '@d3ro/core/types' +import { TIMING } from '@d3ro/core/constants' // 모든 하위 서비스 모킹 vi.mock('../../../src/main/services/LoggerService', () => ({ diff --git a/apps/desktop/tests/shared/errors.test.ts b/apps/desktop/tests/shared/errors.test.ts index e83f82e..cebf9c2 100644 --- a/apps/desktop/tests/shared/errors.test.ts +++ b/apps/desktop/tests/shared/errors.test.ts @@ -2,7 +2,7 @@ // D3ROError + IPCResult 헬퍼 테스트 import { describe, it, expect } from 'vitest' -import { D3ROError, ErrorCode, ipcSuccess, ipcError } from '../../src/shared/errors' +import { D3ROError, ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors' describe('D3ROError', () => { it('code, message, details를 올바르게 설정한다', () => { diff --git a/docs/design/00-master-architecture.md b/docs/design/00-master-architecture.md index ffc4fde..7857b38 100644 --- a/docs/design/00-master-architecture.md +++ b/docs/design/00-master-architecture.md @@ -847,7 +847,7 @@ export interface UIConfig { export interface STTConfig { engine: 'faster-whisper' | 'whisper-cpp'; - modelSize: 'tiny' | 'base' | 'small' | 'medium' | 'large-v3'; + modelSize: 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo'; language: string; // 기본: 'ko' vadEnabled: boolean; // 기본: true beamSize: number; // 기본: 5 @@ -892,7 +892,8 @@ const CONFIG_DEFAULTS: AppConfig = { }, stt: { engine: 'faster-whisper', - modelSize: 'base', + // large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB + modelSize: 'large-v3-turbo', language: 'ko', vadEnabled: true, beamSize: 5, diff --git a/docs/design/01-service-specifications.md b/docs/design/01-service-specifications.md index fbc71c3..ae12bbc 100644 --- a/docs/design/01-service-specifications.md +++ b/docs/design/01-service-specifications.md @@ -176,7 +176,7 @@ faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환 ```typescript /** STT 모델 정보 */ interface STTModel { - readonly id: string; // 예: 'base', 'small', 'medium', 'large-v3' + readonly id: string; // 예: 'base', 'small', 'medium', 'large-v3', 'large-v3-turbo' readonly name: string; readonly size: number; // 바이트 단위 readonly language: string; // 'auto' | 'ko' | 'en' | ... diff --git a/docs/design/05-external-engine-integration.md b/docs/design/05-external-engine-integration.md index 0e6b40a..46cbea8 100644 --- a/docs/design/05-external-engine-integration.md +++ b/docs/design/05-external-engine-integration.md @@ -60,7 +60,7 @@ async def health(): async def load_model(body: dict): """모델 로드/교체""" global model, current_model_id - model_id = body["model_id"] # "base", "small", "medium", "large-v3" + model_id = body["model_id"] # "base", "small", "medium", "large-v3", "large-v3-turbo" device = body.get("device", "auto") # "auto", "cpu", "cuda" compute_type = body.get("compute_type", "int8") diff --git a/memory/project_status.md b/memory/project_status.md index 2e6aafd..0f50f68 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,6 +1,32 @@ # D3RO-VOICE 프로젝트 현황 -> 마지막 갱신: 2026-04-15 (v0.1.0-alpha 설치파일 생성 성공 + 설치 성공 + 온보딩 모달 표시 확인) +> 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩) + +## 모델 현대화 트랙 (2026-07-21) — B 완료, A 진행 예정 + +### 방향 결정 (사용자 컨펌) +- **A+B 병행**: B(로컬 Whisper turbo 전환 + 부트스트랩 진행률 UI) 먼저 → A(OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드) +- 병렬 트랙: `D:\workspace\agent-switchboard-client`에 D3ROVoice와 동일한 GitLab CI 배포 체계 구축 (백그라운드 에이전트 진행) + +### B 완료: Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩 ✅ +- **기본 STT 모델**: `base` → `large-v3-turbo` (CONFIG_DEFAULTS). 6배 빠름, 정확도 손실 1~2%, 1.6GB +- **사이드카 다운로드 API**: `POST /download`(백그라운드 스레드, HF 파일 스트리밍, 멱등), `GET /download/status`, `POST /download/cancel`, `--models-dir` 인자 + - 저장 위치: `userData/whisper-models//` — `/load`가 로컬 디렉토리 우선, 없으면 HF 자동DL 폴백 + - requirements: faster-whisper>=1.1.0 (turbo 지원), huggingface_hub/requests 명시 +- **LocalSTTService**: `downloadModel()`(폴링 500ms + download-progress emit), `cancelDownload()`, getModels()가 fs로 downloaded 판정 +- **IPC**: 설계서 02에 이미 정의돼 있던 `stt:downloadModel`/`stt:cancelDownload`/`stt:downloadProgress` 채널 구현 완료 (기존 미구현) +- **OnboardingModal 2단계**: LLM(gemma4:e4b) → STT(large-v3-turbo) 순차 다운로드, 단계 표시 + MB 진행률, 필요한 단계만 실행(멱등 재시도) +- **설정**: SettingsModal에 large-v3-turbo 선택지, `settings.model.largeTurbo` 12 locale +- **i18n**: `onboarding.sttModelMissing/sttModelSize/step/progressDetail` (ko/en) +- **설계서 동기화**: 00(STTConfig union + 기본값), 01(카탈로그 주석), 05(/load 주석) +- **테스트 수리**: errors.test.ts / VoiceModeService.test.ts의 모노레포 전환 잔재 import(`src/shared/*` → `@d3ro/core/*`) 수정 → 41/41 통과 +- 검증: desktop tsc EXIT 0, vitest 41 passed + +### A 예정: gpt-realtime-2.1 라이브 음성 대화 (Premium) +- 2026-05 출시 OpenAI Realtime 계열: gpt-realtime-2/2.1(~$0.05/분), 2.1-mini(~$0.016/분), GPT-Realtime-Whisper($0.017/분), GPT-Realtime-Translate($0.034/분) +- 아키텍처: Supabase Edge Function이 ephemeral token 발급(`POST /v1/realtime/client_secrets`) → 렌더러가 OpenAI와 직접 WebRTC → STT→LLM→TTS 파이프라인 대체 +- 선행 조건: OpenAI API 키 Supabase Secrets 등록, 분 단위 쿼터 정책 설계 +- 참고: Parakeet은 한국어 미지원이라 로컬 STT 대체 후보에서 제외 ## v0.1.0-alpha 배포 전체 흐름 (2026-04-15 night) ✅ diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index da670a6..2c4450d 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, gut)", "settings.model.medium": "medium (769 MB, sehr gut)", "settings.model.large": "large-v3 (1,5 GB, beste Qualität)", + "settings.model.largeTurbo": "large-v3-turbo (1,6 GB, schnell und präzise)", "settings.sttLanguage": "Erkennungssprache", "settings.sttLang.auto": "Automatische Erkennung", "settings.ollamaServer": "Ollama-Server", diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 9522e9a..72c5a91 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -122,6 +122,7 @@ "settings.model.small": "small (244 MB, good)", "settings.model.medium": "medium (769 MB, great)", "settings.model.large": "large-v3 (1.5 GB, best)", + "settings.model.largeTurbo": "large-v3-turbo (1.6 GB, fast & accurate)", "settings.sttLanguage": "Recognition Language", "settings.sttLang.auto": "Auto-detect", "settings.ollamaServer": "Ollama Server", @@ -627,5 +628,9 @@ "onboarding.success": "Setup complete! You can now use the app.", "onboarding.failed": "Download failed: {{message}}", "onboarding.retry": "Retry", + "onboarding.sttModelMissing": "Speech recognition model ({{model}}) is not installed.", + "onboarding.sttModelSize": "Size: ~1.6GB — takes 2–10 min depending on network speed", + "onboarding.step": "Step {{current}}/{{total}}", + "onboarding.progressDetail": "{{downloaded}} / {{total}} MB", "onboarding.ollamaNotRunning": "Ollama server is not running. Please retry shortly." } diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 2fd16d8..49b9a33 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, bueno)", "settings.model.medium": "medium (769 MB, muy bueno)", "settings.model.large": "large-v3 (1.5 GB, mejor)", + "settings.model.largeTurbo": "large-v3-turbo (1.6 GB, rápido y preciso)", "settings.sttLanguage": "Idioma de reconocimiento", "settings.sttLang.auto": "Detección automática", "settings.ollamaServer": "Servidor Ollama", diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 1552da8..033d81e 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 Mo, bon)", "settings.model.medium": "medium (769 Mo, très bon)", "settings.model.large": "large-v3 (1,5 Go, meilleur)", + "settings.model.largeTurbo": "large-v3-turbo (1,6 Go, rapide et précis)", "settings.sttLanguage": "Langue de reconnaissance", "settings.sttLang.auto": "Détection automatique", "settings.ollamaServer": "Serveur Ollama", diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index 25d3ee9..ea03c11 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, 良好)", "settings.model.medium": "medium (769 MB, 高精度)", "settings.model.large": "large-v3 (1.5 GB, 最高)", + "settings.model.largeTurbo": "large-v3-turbo(1.6 GB、高速・高精度)", "settings.sttLanguage": "認識言語", "settings.sttLang.auto": "自動検出", "settings.ollamaServer": "Ollamaサーバー", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 3980466..3a50e9a 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -123,6 +123,7 @@ "settings.model.small": "small (244 MB, 양호)", "settings.model.medium": "medium (769 MB, 우수)", "settings.model.large": "large-v3 (1.5 GB, 최고)", + "settings.model.largeTurbo": "large-v3-turbo (1.6 GB, 빠르고 정확)", "settings.sttLanguage": "인식 언어", "settings.sttLang.auto": "자동 감지", "settings.ollamaServer": "Ollama 서버", @@ -627,5 +628,9 @@ "onboarding.success": "설치 완료! 이제 사용할 수 있습니다.", "onboarding.failed": "다운로드 실패: {{message}}", "onboarding.retry": "재시도", + "onboarding.sttModelMissing": "음성 인식 모델({{model}})이 설치되어 있지 않습니다.", + "onboarding.sttModelSize": "크기: 약 1.6GB — 네트워크 속도에 따라 2~10분 소요", + "onboarding.step": "단계 {{current}}/{{total}}", + "onboarding.progressDetail": "{{downloaded}} / {{total}} MB", "onboarding.ollamaNotRunning": "Ollama 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요." } diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index 5717aa8..faacdcf 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, bom)", "settings.model.medium": "medium (769 MB, muito bom)", "settings.model.large": "large-v3 (1,5 GB, melhor)", + "settings.model.largeTurbo": "large-v3-turbo (1,6 GB, rápido e preciso)", "settings.sttLanguage": "Idioma de reconhecimento", "settings.sttLang.auto": "Detecção automática", "settings.ollamaServer": "Servidor Ollama", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index eebbfcb..f2e710b 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 МБ, хорошая)", "settings.model.medium": "medium (769 МБ, отличная)", "settings.model.large": "large-v3 (1.5 ГБ, лучшая)", + "settings.model.largeTurbo": "large-v3-turbo (1,6 ГБ, быстрая и точная)", "settings.sttLanguage": "Язык распознавания", "settings.sttLang.auto": "Автоопределение", "settings.ollamaServer": "Сервер Ollama", diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index a4e9c00..a5026b8 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, ดี)", "settings.model.medium": "medium (769 MB, ดีเยี่ยม)", "settings.model.large": "large-v3 (1.5 GB, ดีที่สุด)", + "settings.model.largeTurbo": "large-v3-turbo (1.6 GB, เร็วและแม่นยำ)", "settings.sttLanguage": "ภาษาที่รู้จัก", "settings.sttLang.auto": "ตรวจจับอัตโนมัติ", "settings.ollamaServer": "เซิร์ฟเวอร์ Ollama", diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index 6f53fdd..9502f2e 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -116,6 +116,7 @@ "settings.model.small": "small (244 MB, tốt)", "settings.model.medium": "medium (769 MB, xuất sắc)", "settings.model.large": "large-v3 (1.5 GB, tốt nhất)", + "settings.model.largeTurbo": "large-v3-turbo (1.6 GB, nhanh và chính xác)", "settings.sttLanguage": "Ngôn ngữ nhận dạng", "settings.sttLang.auto": "Tự động phát hiện", "settings.ollamaServer": "Máy chủ Ollama", diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index 806be39..3ae184e 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -116,6 +116,7 @@ "settings.model.small": "small(244 MB,良好)", "settings.model.medium": "medium(769 MB,優秀)", "settings.model.large": "large-v3(1.5 GB,最佳)", + "settings.model.largeTurbo": "large-v3-turbo(1.6 GB,快速且準確)", "settings.sttLanguage": "識別語言", "settings.sttLang.auto": "自動偵測", "settings.ollamaServer": "Ollama伺服器", diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index a7e1933..0521c17 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -116,6 +116,7 @@ "settings.model.small": "small(244 MB,良好)", "settings.model.medium": "medium(769 MB,优秀)", "settings.model.large": "large-v3(1.5 GB,最佳)", + "settings.model.largeTurbo": "large-v3-turbo(1.6 GB,快速且准确)", "settings.sttLanguage": "识别语言", "settings.sttLang.auto": "自动检测", "settings.ollamaServer": "Ollama服务器",