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
|
|
@ -3,12 +3,15 @@ D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
|
||||||
faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포.
|
faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포.
|
||||||
|
|
||||||
사용법:
|
사용법:
|
||||||
python main.py --port 18765
|
python main.py --port 18765 --models-dir <path>
|
||||||
|
|
||||||
엔드포인트:
|
엔드포인트:
|
||||||
GET /health - 헬스체크
|
GET /health - 헬스체크
|
||||||
POST /load - Whisper 모델 로딩
|
POST /load - Whisper 모델 로딩
|
||||||
POST /transcribe - 오디오 전사 (multipart)
|
POST /transcribe - 오디오 전사 (multipart)
|
||||||
|
POST /download - 모델 다운로드 시작 (백그라운드)
|
||||||
|
GET /download/status - 다운로드 진행률 조회
|
||||||
|
POST /download/cancel - 다운로드 취소
|
||||||
POST /shutdown - 서버 종료
|
POST /shutdown - 서버 종료
|
||||||
|
|
||||||
주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며,
|
주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며,
|
||||||
|
|
@ -18,12 +21,15 @@ pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -47,6 +53,37 @@ _model: "WhisperModel | None" = None
|
||||||
_model_id: str | None = None
|
_model_id: str | None = None
|
||||||
_gpu_available: bool = False
|
_gpu_available: bool = False
|
||||||
_server: uvicorn.Server | None = None
|
_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 앱 ─────────────────────────────────────────────
|
# ── FastAPI 앱 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -84,6 +121,135 @@ def _detect_gpu() -> None:
|
||||||
logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc)
|
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
|
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)
|
logger.info("모델 로딩 시작: %s", model_id)
|
||||||
|
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
@ -122,8 +288,15 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
||||||
device = "cuda" if _gpu_available else "cpu"
|
device = "cuda" if _gpu_available else "cpu"
|
||||||
compute_type = "float16" if _gpu_available else "int8"
|
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 = WhisperModel(
|
||||||
model_id,
|
model_source,
|
||||||
device=device,
|
device=device,
|
||||||
compute_type=compute_type,
|
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")
|
@app.post("/shutdown")
|
||||||
async def shutdown() -> JSONResponse:
|
async def shutdown() -> JSONResponse:
|
||||||
"""서버를 graceful하게 종료한다."""
|
"""서버를 graceful하게 종료한다."""
|
||||||
|
|
@ -282,7 +520,7 @@ async def shutdown() -> JSONResponse:
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""CLI 진입점."""
|
"""CLI 진입점."""
|
||||||
global _server
|
global _server, _models_dir
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar")
|
parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -297,8 +535,18 @@ def main() -> None:
|
||||||
default="127.0.0.1",
|
default="127.0.0.1",
|
||||||
help="HTTP 서버 호스트 (기본: 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()
|
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)
|
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
|
||||||
|
|
||||||
def signal_handler(signum: int, _frame: object) -> None:
|
def signal_handler(signum: int, _frame: object) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
faster-whisper>=1.0.0
|
faster-whisper>=1.1.0
|
||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
huggingface_hub>=0.23.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,29 @@
|
||||||
|
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
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 { getLocalSTTService } from '../services/LocalSTTService'
|
||||||
import { configGet, configSet } from '../services/ConfigService'
|
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 {
|
export function registerSTTHandlers(): void {
|
||||||
|
// 다운로드 진행률 → 렌더러
|
||||||
|
getLocalSTTService().on('download-progress', (payload) => {
|
||||||
|
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => {
|
ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => {
|
||||||
try {
|
try {
|
||||||
const stt = getLocalSTTService()
|
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 () => {
|
ipcMain.handle(IPC_CHANNELS.STT.GET_LANGUAGE, async () => {
|
||||||
return ipcSuccess(configGet('sttLanguage'))
|
return ipcSuccess(configGet('sttLanguage'))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ const CONFIG_DEFAULTS: AppConfig = {
|
||||||
autoLaunch: false,
|
autoLaunch: false,
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
sttModelId: 'base',
|
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드)
|
||||||
|
sttModelId: 'large-v3-turbo',
|
||||||
sttLanguage: 'auto',
|
sttLanguage: 'auto',
|
||||||
ttsVoiceId: null,
|
ttsVoiceId: null,
|
||||||
ttsSpeed: 1.0,
|
ttsSpeed: 1.0,
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,18 @@
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { type ChildProcess, spawn } from 'child_process'
|
import { type ChildProcess, spawn } from 'child_process'
|
||||||
import { createServer } from 'net'
|
import { createServer } from 'net'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { getSidecarCommand } from '../utils/paths'
|
import { getSidecarCommand, getWhisperModelsDir } from '../utils/paths'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
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
|
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 응답 */
|
/** sidecar /transcribe 응답 */
|
||||||
interface TranscribeResponse {
|
interface TranscribeResponse {
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -80,6 +98,7 @@ export interface LocalSTTEvents {
|
||||||
'transcription-delta': { text: string; isFinal: boolean }
|
'transcription-delta': { text: string; isFinal: boolean }
|
||||||
'transcription-complete': { result: TranscriptionResult }
|
'transcription-complete': { result: TranscriptionResult }
|
||||||
'model-loaded': { model: STTModel; loadTimeMs: number }
|
'model-loaded': { model: STTModel; loadTimeMs: number }
|
||||||
|
'download-progress': DownloadProgressEvent
|
||||||
'error': { error: D3ROError }
|
'error': { error: D3ROError }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +157,15 @@ const MODEL_CATALOG: STTModel[] = [
|
||||||
accuracy: 5,
|
accuracy: 5,
|
||||||
speed: 1,
|
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 {
|
try {
|
||||||
// sidecar가 아직 실행 중이 아니면 시작
|
// sidecar가 아직 실행 중이 아니면 시작
|
||||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
await this._ensureSidecarRunning()
|
||||||
await this._spawnSidecar()
|
|
||||||
await this._waitForHealth()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모델 로딩
|
// 모델 로딩
|
||||||
await this._loadModel(targetModel)
|
await this._loadModel(targetModel)
|
||||||
|
|
@ -269,16 +294,116 @@ class LocalSTTService extends EventEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다운로드된 모델 목록 조회.
|
* 다운로드된 모델 목록 조회.
|
||||||
* 실제 다운로드 여부는 sidecar에 위임 (faster-whisper가 자동 다운로드).
|
* models-dir 사전 다운로드 여부 + 현재 로딩 여부로 downloaded를 판정한다.
|
||||||
*/
|
*/
|
||||||
getModels(): STTModel[] {
|
getModels(): STTModel[] {
|
||||||
|
const modelsDir = getWhisperModelsDir()
|
||||||
return MODEL_CATALOG.map((m) => ({
|
return MODEL_CATALOG.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
// 현재 로딩된 모델은 downloaded=true로 표시
|
downloaded:
|
||||||
downloaded: m.id === this._currentModelId ? true : m.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 {
|
getStatus(): STTStatus {
|
||||||
const stateMap: Record<STTState, STTEngineState> = {
|
const stateMap: Record<STTState, STTEngineState> = {
|
||||||
|
|
@ -359,6 +484,30 @@ class LocalSTTService extends EventEmitter {
|
||||||
|
|
||||||
// ── Sidecar 관리 ──
|
// ── 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 포트를 찾는다.
|
* 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다.
|
||||||
* dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어
|
* dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어
|
||||||
|
|
@ -409,7 +558,13 @@ class LocalSTTService extends EventEmitter {
|
||||||
this._port = await this._findFreePort(SIDECAR_PORT, 20)
|
this._port = await this._findFreePort(SIDECAR_PORT, 20)
|
||||||
|
|
||||||
const { command, args } = getSidecarCommand()
|
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(' ')}`)
|
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
|
|
||||||
|
|
@ -143,3 +143,11 @@ export function getFfmpegPath(): string {
|
||||||
export function getUserDataPath(): string {
|
export function getUserDataPath(): string {
|
||||||
return app.getPath('userData')
|
return app.getPath('userData')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whisper 모델 사전 다운로드 디렉토리.
|
||||||
|
* sidecar --models-dir로 전달되며, 메인 프로세스의 downloaded 판정에도 사용된다.
|
||||||
|
*/
|
||||||
|
export function getWhisperModelsDir(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'whisper-models')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import type {
|
||||||
SetSTTModelParams,
|
SetSTTModelParams,
|
||||||
SetSTTLanguageParams,
|
SetSTTLanguageParams,
|
||||||
STTStatusChangedEvent,
|
STTStatusChangedEvent,
|
||||||
|
DownloadModelParams,
|
||||||
|
DownloadProgressEvent,
|
||||||
HotkeyBinding,
|
HotkeyBinding,
|
||||||
SetHotkeyParams,
|
SetHotkeyParams,
|
||||||
SetEnabledParams,
|
SetEnabledParams,
|
||||||
|
|
@ -251,11 +253,16 @@ const electronAPI = {
|
||||||
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
|
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
|
||||||
setModel: (params: SetSTTModelParams) =>
|
setModel: (params: SetSTTModelParams) =>
|
||||||
invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
|
invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
|
||||||
|
downloadModel: (params: DownloadModelParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params),
|
||||||
|
cancelDownload: () => invoke<void>(IPC_CHANNELS.STT.CANCEL_DOWNLOAD),
|
||||||
getLanguage: () => invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
|
getLanguage: () => invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
|
||||||
setLanguage: (params: SetSTTLanguageParams) =>
|
setLanguage: (params: SetSTTLanguageParams) =>
|
||||||
invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),
|
invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),
|
||||||
onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe =>
|
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 ─────────────────────────────────────────────
|
// ── Hotkey ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
// src/renderer/components/OnboardingModal.tsx
|
// 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)
|
// 1) AppLayout의 첫 실행 감지(onboardingCompleted=false)
|
||||||
// 2) 런타임 중 모델 미설치 감지 (주기적 polling)
|
// 2) 런타임 중 모델 미설치 감지 (주기적 polling)
|
||||||
// 다운로드 성공 시 config.onboardingCompleted=true로 저장.
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,36 +27,77 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
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'
|
type Phase = 'prompt' | 'downloading' | 'success' | 'failed'
|
||||||
|
|
||||||
|
interface NeededSteps {
|
||||||
|
steps: StepKind[]
|
||||||
|
sttModelId: string
|
||||||
|
}
|
||||||
|
|
||||||
interface OnboardingModalProps {
|
interface OnboardingModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 설치가 필요한 단계 목록을 계산한다 (멱등 — 이미 설치된 단계는 제외) */
|
||||||
|
async function computeNeededSteps(): Promise<NeededSteps> {
|
||||||
|
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 {
|
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [phase, setPhase] = useState<Phase>('prompt')
|
const [phase, setPhase] = useState<Phase>('prompt')
|
||||||
|
const [neededSteps, setNeededSteps] = useState<StepKind[]>([])
|
||||||
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
|
const [sttModelId, setSttModelId] = useState(FALLBACK_STT_MODEL)
|
||||||
const [percent, setPercent] = useState(0)
|
const [percent, setPercent] = useState(0)
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
|
const [detail, setDetail] = useState('')
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
const unsubRef = useRef<(() => void) | null>(null)
|
const activeStepRef = useRef<StepKind | null>(null)
|
||||||
|
|
||||||
const isVisible = open || internalOpen
|
const isVisible = open || internalOpen
|
||||||
|
|
||||||
// 모델 존재 여부 체크 — 없으면 auto-open
|
// 모델 존재 여부 체크 — 없으면 auto-open
|
||||||
const checkModels = useCallback(async (): Promise<void> => {
|
const checkModels = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.llm.getModels()
|
const needed = await computeNeededSteps()
|
||||||
if (!result.success) {
|
setSttModelId(needed.sttModelId)
|
||||||
setPhase('prompt')
|
if (needed.steps.length > 0) {
|
||||||
setInternalOpen(true)
|
setNeededSteps(needed.steps)
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasDefault = result.data.some((m) => m.id === DEFAULT_MODEL)
|
|
||||||
if (!hasDefault) {
|
|
||||||
setPhase('prompt')
|
setPhase('prompt')
|
||||||
setInternalOpen(true)
|
setInternalOpen(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -75,36 +120,73 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
}
|
}
|
||||||
}, [checkModels, phase])
|
}, [checkModels, phase])
|
||||||
|
|
||||||
// pull 진행률 구독
|
// LLM pull 진행률 구독
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = window.electronAPI.llm.onPullProgress((e) => {
|
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)
|
setStatus(e.status)
|
||||||
if (e.percent > 0) setPercent(e.percent)
|
if (e.percent > 0) setPercent(e.percent)
|
||||||
})
|
})
|
||||||
unsubRef.current = unsub
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub()
|
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<void> => {
|
const handleDownload = useCallback(async (): Promise<void> => {
|
||||||
setPhase('downloading')
|
setPhase('downloading')
|
||||||
setPercent(0)
|
|
||||||
setStatus('')
|
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
|
|
||||||
const result = await window.electronAPI.llm.pullModel({ modelId: DEFAULT_MODEL })
|
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
|
||||||
if (result.success) {
|
const needed = await computeNeededSteps()
|
||||||
setPhase('success')
|
setSttModelId(needed.sttModelId)
|
||||||
setPercent(100)
|
setNeededSteps(needed.steps)
|
||||||
// 온보딩 완료 플래그 저장
|
|
||||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
for (let i = 0; i < needed.steps.length; i++) {
|
||||||
} else {
|
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')
|
setPhase('failed')
|
||||||
setErrorMsg(result.error?.message ?? 'unknown')
|
setErrorMsg(result.error?.message ?? 'unknown')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setPercent(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStepRef.current = null
|
||||||
|
setPhase('success')
|
||||||
|
// 온보딩 완료 플래그 저장
|
||||||
|
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|
@ -123,6 +205,34 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
const colorDanger = d3roPalette.tag.red
|
const colorDanger = d3roPalette.tag.red
|
||||||
const colorAccent = d3roPalette.accent.amber
|
const colorAccent = d3roPalette.accent.amber
|
||||||
|
|
||||||
|
const currentStep: StepKind | null = isDownloading
|
||||||
|
? (neededSteps[stepIndex] ?? null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
/** 단계별 안내 박스 렌더 */
|
||||||
|
const renderStepInfo = (step: StepKind): React.ReactElement => (
|
||||||
|
<Box
|
||||||
|
key={step}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: d3roRadius.inner,
|
||||||
|
backgroundColor: d3roPalette.bg.card,
|
||||||
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
|
mb: 2,
|
||||||
|
opacity: currentStep === null || currentStep === step ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
|
||||||
|
{step === 'llm'
|
||||||
|
? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL })
|
||||||
|
: t('onboarding.sttModelMissing', { model: sttModelId })}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
||||||
|
{step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isVisible}
|
open={isVisible}
|
||||||
|
|
@ -163,30 +273,18 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
{t('onboarding.subtitle')}
|
{t('onboarding.subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{(phase === 'prompt' || isDownloading) && (
|
{(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)}
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: d3roRadius.inner,
|
|
||||||
backgroundColor: d3roPalette.bg.card,
|
|
||||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
|
|
||||||
{t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
|
||||||
{t('onboarding.llmModelSize')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDownloading && (
|
{isDownloading && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
||||||
{t('onboarding.downloading')}
|
{neededSteps.length > 1
|
||||||
|
? `${t('onboarding.step', {
|
||||||
|
current: String(stepIndex + 1),
|
||||||
|
total: String(neededSteps.length),
|
||||||
|
})} — ${t('onboarding.downloading')}`
|
||||||
|
: t('onboarding.downloading')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
|
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
|
||||||
{percent}%
|
{percent}%
|
||||||
|
|
@ -197,6 +295,13 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
value={percent}
|
value={percent}
|
||||||
sx={{ height: 8, borderRadius: d3roRadius.xs }}
|
sx={{ height: 8, borderRadius: d3roRadius.xs }}
|
||||||
/>
|
/>
|
||||||
|
{detail && (
|
||||||
|
<Typography
|
||||||
|
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{status && (
|
{status && (
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
|
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
|
||||||
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
|
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
|
||||||
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
|
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
|
||||||
|
<MenuItem value="large-v3-turbo">{t('settings.model.largeTurbo')}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
// 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트
|
// 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { RecognitionState, AudioState } from '../../../src/shared/types'
|
import { RecognitionState, AudioState } from '@d3ro/core/types'
|
||||||
import { TIMING } from '../../../src/shared/constants'
|
import { TIMING } from '@d3ro/core/constants'
|
||||||
|
|
||||||
// 모든 하위 서비스 모킹
|
// 모든 하위 서비스 모킹
|
||||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// D3ROError + IPCResult 헬퍼 테스트
|
// D3ROError + IPCResult 헬퍼 테스트
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('D3ROError', () => {
|
||||||
it('code, message, details를 올바르게 설정한다', () => {
|
it('code, message, details를 올바르게 설정한다', () => {
|
||||||
|
|
|
||||||
|
|
@ -847,7 +847,7 @@ export interface UIConfig {
|
||||||
|
|
||||||
export interface STTConfig {
|
export interface STTConfig {
|
||||||
engine: 'faster-whisper' | 'whisper-cpp';
|
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'
|
language: string; // 기본: 'ko'
|
||||||
vadEnabled: boolean; // 기본: true
|
vadEnabled: boolean; // 기본: true
|
||||||
beamSize: number; // 기본: 5
|
beamSize: number; // 기본: 5
|
||||||
|
|
@ -892,7 +892,8 @@ const CONFIG_DEFAULTS: AppConfig = {
|
||||||
},
|
},
|
||||||
stt: {
|
stt: {
|
||||||
engine: 'faster-whisper',
|
engine: 'faster-whisper',
|
||||||
modelSize: 'base',
|
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB
|
||||||
|
modelSize: 'large-v3-turbo',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
vadEnabled: true,
|
vadEnabled: true,
|
||||||
beamSize: 5,
|
beamSize: 5,
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환
|
||||||
```typescript
|
```typescript
|
||||||
/** STT 모델 정보 */
|
/** STT 모델 정보 */
|
||||||
interface STTModel {
|
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 name: string;
|
||||||
readonly size: number; // 바이트 단위
|
readonly size: number; // 바이트 단위
|
||||||
readonly language: string; // 'auto' | 'ko' | 'en' | ...
|
readonly language: string; // 'auto' | 'ko' | 'en' | ...
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ async def health():
|
||||||
async def load_model(body: dict):
|
async def load_model(body: dict):
|
||||||
"""모델 로드/교체"""
|
"""모델 로드/교체"""
|
||||||
global model, current_model_id
|
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"
|
device = body.get("device", "auto") # "auto", "cpu", "cuda"
|
||||||
compute_type = body.get("compute_type", "int8")
|
compute_type = body.get("compute_type", "int8")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,32 @@
|
||||||
# D3RO-VOICE 프로젝트 현황
|
# 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/<model_id>/` — `/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) ✅
|
## v0.1.0-alpha 배포 전체 흐름 (2026-04-15 night) ✅
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, gut)",
|
"settings.model.small": "small (244 MB, gut)",
|
||||||
"settings.model.medium": "medium (769 MB, sehr gut)",
|
"settings.model.medium": "medium (769 MB, sehr gut)",
|
||||||
"settings.model.large": "large-v3 (1,5 GB, beste Qualität)",
|
"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.sttLanguage": "Erkennungssprache",
|
||||||
"settings.sttLang.auto": "Automatische Erkennung",
|
"settings.sttLang.auto": "Automatische Erkennung",
|
||||||
"settings.ollamaServer": "Ollama-Server",
|
"settings.ollamaServer": "Ollama-Server",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@
|
||||||
"settings.model.small": "small (244 MB, good)",
|
"settings.model.small": "small (244 MB, good)",
|
||||||
"settings.model.medium": "medium (769 MB, great)",
|
"settings.model.medium": "medium (769 MB, great)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, best)",
|
"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.sttLanguage": "Recognition Language",
|
||||||
"settings.sttLang.auto": "Auto-detect",
|
"settings.sttLang.auto": "Auto-detect",
|
||||||
"settings.ollamaServer": "Ollama Server",
|
"settings.ollamaServer": "Ollama Server",
|
||||||
|
|
@ -627,5 +628,9 @@
|
||||||
"onboarding.success": "Setup complete! You can now use the app.",
|
"onboarding.success": "Setup complete! You can now use the app.",
|
||||||
"onboarding.failed": "Download failed: {{message}}",
|
"onboarding.failed": "Download failed: {{message}}",
|
||||||
"onboarding.retry": "Retry",
|
"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."
|
"onboarding.ollamaNotRunning": "Ollama server is not running. Please retry shortly."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, bueno)",
|
"settings.model.small": "small (244 MB, bueno)",
|
||||||
"settings.model.medium": "medium (769 MB, muy bueno)",
|
"settings.model.medium": "medium (769 MB, muy bueno)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, mejor)",
|
"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.sttLanguage": "Idioma de reconocimiento",
|
||||||
"settings.sttLang.auto": "Detección automática",
|
"settings.sttLang.auto": "Detección automática",
|
||||||
"settings.ollamaServer": "Servidor Ollama",
|
"settings.ollamaServer": "Servidor Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 Mo, bon)",
|
"settings.model.small": "small (244 Mo, bon)",
|
||||||
"settings.model.medium": "medium (769 Mo, très bon)",
|
"settings.model.medium": "medium (769 Mo, très bon)",
|
||||||
"settings.model.large": "large-v3 (1,5 Go, meilleur)",
|
"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.sttLanguage": "Langue de reconnaissance",
|
||||||
"settings.sttLang.auto": "Détection automatique",
|
"settings.sttLang.auto": "Détection automatique",
|
||||||
"settings.ollamaServer": "Serveur Ollama",
|
"settings.ollamaServer": "Serveur Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, 良好)",
|
"settings.model.small": "small (244 MB, 良好)",
|
||||||
"settings.model.medium": "medium (769 MB, 高精度)",
|
"settings.model.medium": "medium (769 MB, 高精度)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, 最高)",
|
"settings.model.large": "large-v3 (1.5 GB, 最高)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo(1.6 GB、高速・高精度)",
|
||||||
"settings.sttLanguage": "認識言語",
|
"settings.sttLanguage": "認識言語",
|
||||||
"settings.sttLang.auto": "自動検出",
|
"settings.sttLang.auto": "自動検出",
|
||||||
"settings.ollamaServer": "Ollamaサーバー",
|
"settings.ollamaServer": "Ollamaサーバー",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@
|
||||||
"settings.model.small": "small (244 MB, 양호)",
|
"settings.model.small": "small (244 MB, 양호)",
|
||||||
"settings.model.medium": "medium (769 MB, 우수)",
|
"settings.model.medium": "medium (769 MB, 우수)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, 최고)",
|
"settings.model.large": "large-v3 (1.5 GB, 최고)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo (1.6 GB, 빠르고 정확)",
|
||||||
"settings.sttLanguage": "인식 언어",
|
"settings.sttLanguage": "인식 언어",
|
||||||
"settings.sttLang.auto": "자동 감지",
|
"settings.sttLang.auto": "자동 감지",
|
||||||
"settings.ollamaServer": "Ollama 서버",
|
"settings.ollamaServer": "Ollama 서버",
|
||||||
|
|
@ -627,5 +628,9 @@
|
||||||
"onboarding.success": "설치 완료! 이제 사용할 수 있습니다.",
|
"onboarding.success": "설치 완료! 이제 사용할 수 있습니다.",
|
||||||
"onboarding.failed": "다운로드 실패: {{message}}",
|
"onboarding.failed": "다운로드 실패: {{message}}",
|
||||||
"onboarding.retry": "재시도",
|
"onboarding.retry": "재시도",
|
||||||
|
"onboarding.sttModelMissing": "음성 인식 모델({{model}})이 설치되어 있지 않습니다.",
|
||||||
|
"onboarding.sttModelSize": "크기: 약 1.6GB — 네트워크 속도에 따라 2~10분 소요",
|
||||||
|
"onboarding.step": "단계 {{current}}/{{total}}",
|
||||||
|
"onboarding.progressDetail": "{{downloaded}} / {{total}} MB",
|
||||||
"onboarding.ollamaNotRunning": "Ollama 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요."
|
"onboarding.ollamaNotRunning": "Ollama 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, bom)",
|
"settings.model.small": "small (244 MB, bom)",
|
||||||
"settings.model.medium": "medium (769 MB, muito bom)",
|
"settings.model.medium": "medium (769 MB, muito bom)",
|
||||||
"settings.model.large": "large-v3 (1,5 GB, melhor)",
|
"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.sttLanguage": "Idioma de reconhecimento",
|
||||||
"settings.sttLang.auto": "Detecção automática",
|
"settings.sttLang.auto": "Detecção automática",
|
||||||
"settings.ollamaServer": "Servidor Ollama",
|
"settings.ollamaServer": "Servidor Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 МБ, хорошая)",
|
"settings.model.small": "small (244 МБ, хорошая)",
|
||||||
"settings.model.medium": "medium (769 МБ, отличная)",
|
"settings.model.medium": "medium (769 МБ, отличная)",
|
||||||
"settings.model.large": "large-v3 (1.5 ГБ, лучшая)",
|
"settings.model.large": "large-v3 (1.5 ГБ, лучшая)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo (1,6 ГБ, быстрая и точная)",
|
||||||
"settings.sttLanguage": "Язык распознавания",
|
"settings.sttLanguage": "Язык распознавания",
|
||||||
"settings.sttLang.auto": "Автоопределение",
|
"settings.sttLang.auto": "Автоопределение",
|
||||||
"settings.ollamaServer": "Сервер Ollama",
|
"settings.ollamaServer": "Сервер Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, ดี)",
|
"settings.model.small": "small (244 MB, ดี)",
|
||||||
"settings.model.medium": "medium (769 MB, ดีเยี่ยม)",
|
"settings.model.medium": "medium (769 MB, ดีเยี่ยม)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, ดีที่สุด)",
|
"settings.model.large": "large-v3 (1.5 GB, ดีที่สุด)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo (1.6 GB, เร็วและแม่นยำ)",
|
||||||
"settings.sttLanguage": "ภาษาที่รู้จัก",
|
"settings.sttLanguage": "ภาษาที่รู้จัก",
|
||||||
"settings.sttLang.auto": "ตรวจจับอัตโนมัติ",
|
"settings.sttLang.auto": "ตรวจจับอัตโนมัติ",
|
||||||
"settings.ollamaServer": "เซิร์ฟเวอร์ Ollama",
|
"settings.ollamaServer": "เซิร์ฟเวอร์ Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small (244 MB, tốt)",
|
"settings.model.small": "small (244 MB, tốt)",
|
||||||
"settings.model.medium": "medium (769 MB, xuất sắc)",
|
"settings.model.medium": "medium (769 MB, xuất sắc)",
|
||||||
"settings.model.large": "large-v3 (1.5 GB, tốt nhất)",
|
"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.sttLanguage": "Ngôn ngữ nhận dạng",
|
||||||
"settings.sttLang.auto": "Tự động phát hiện",
|
"settings.sttLang.auto": "Tự động phát hiện",
|
||||||
"settings.ollamaServer": "Máy chủ Ollama",
|
"settings.ollamaServer": "Máy chủ Ollama",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small(244 MB,良好)",
|
"settings.model.small": "small(244 MB,良好)",
|
||||||
"settings.model.medium": "medium(769 MB,優秀)",
|
"settings.model.medium": "medium(769 MB,優秀)",
|
||||||
"settings.model.large": "large-v3(1.5 GB,最佳)",
|
"settings.model.large": "large-v3(1.5 GB,最佳)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo(1.6 GB,快速且準確)",
|
||||||
"settings.sttLanguage": "識別語言",
|
"settings.sttLanguage": "識別語言",
|
||||||
"settings.sttLang.auto": "自動偵測",
|
"settings.sttLang.auto": "自動偵測",
|
||||||
"settings.ollamaServer": "Ollama伺服器",
|
"settings.ollamaServer": "Ollama伺服器",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@
|
||||||
"settings.model.small": "small(244 MB,良好)",
|
"settings.model.small": "small(244 MB,良好)",
|
||||||
"settings.model.medium": "medium(769 MB,优秀)",
|
"settings.model.medium": "medium(769 MB,优秀)",
|
||||||
"settings.model.large": "large-v3(1.5 GB,最佳)",
|
"settings.model.large": "large-v3(1.5 GB,最佳)",
|
||||||
|
"settings.model.largeTurbo": "large-v3-turbo(1.6 GB,快速且准确)",
|
||||||
"settings.sttLanguage": "识别语言",
|
"settings.sttLanguage": "识别语言",
|
||||||
"settings.sttLang.auto": "自动检测",
|
"settings.sttLang.auto": "自动检测",
|
||||||
"settings.ollamaServer": "Ollama服务器",
|
"settings.ollamaServer": "Ollama服务器",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue