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

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

View file

@ -3,13 +3,16 @@ 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 /shutdown - 서버 종료 POST /download - 모델 다운로드 시작 (백그라운드)
GET /download/status - 다운로드 진행률 조회
POST /download/cancel - 다운로드 취소
POST /shutdown - 서버 종료
: 화자 구분(diarization) Phase 15.5에서 LLM 추정 경로가 primary이며, : 화자 구분(diarization) Phase 15.5에서 LLM 추정 경로가 primary이며,
pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정. pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정.
@ -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:

View file

@ -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

View file

@ -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'))
}) })

View file

@ -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,

View file

@ -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) => {

View file

@ -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')
}

View file

@ -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 ─────────────────────────────────────────────

View file

@ -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)
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) 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(() => { 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 }}

View file

@ -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>

View file

@ -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', () => ({

View file

@ -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를 올바르게 설정한다', () => {

View file

@ -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,

View file

@ -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' | ...

View file

@ -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")

View file

@ -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) ✅

View file

@ -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",

View file

@ -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 210 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."
} }

View file

@ -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",

View file

@ -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",

View file

@ -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-turbo1.6 GB、高速・高精度",
"settings.sttLanguage": "認識言語", "settings.sttLanguage": "認識言語",
"settings.sttLang.auto": "自動検出", "settings.sttLang.auto": "自動検出",
"settings.ollamaServer": "Ollamaサーバー", "settings.ollamaServer": "Ollamaサーバー",

View file

@ -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 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요."
} }

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -116,6 +116,7 @@
"settings.model.small": "small244 MB良好", "settings.model.small": "small244 MB良好",
"settings.model.medium": "medium769 MB優秀", "settings.model.medium": "medium769 MB優秀",
"settings.model.large": "large-v31.5 GB最佳", "settings.model.large": "large-v31.5 GB最佳",
"settings.model.largeTurbo": "large-v3-turbo1.6 GB快速且準確",
"settings.sttLanguage": "識別語言", "settings.sttLanguage": "識別語言",
"settings.sttLang.auto": "自動偵測", "settings.sttLang.auto": "自動偵測",
"settings.ollamaServer": "Ollama伺服器", "settings.ollamaServer": "Ollama伺服器",

View file

@ -116,6 +116,7 @@
"settings.model.small": "small244 MB良好", "settings.model.small": "small244 MB良好",
"settings.model.medium": "medium769 MB优秀", "settings.model.medium": "medium769 MB优秀",
"settings.model.large": "large-v31.5 GB最佳", "settings.model.large": "large-v31.5 GB最佳",
"settings.model.largeTurbo": "large-v3-turbo1.6 GB快速且准确",
"settings.sttLanguage": "识别语言", "settings.sttLanguage": "识别语言",
"settings.sttLang.auto": "自动检测", "settings.sttLang.auto": "自动检测",
"settings.ollamaServer": "Ollama服务器", "settings.ollamaServer": "Ollama服务器",