feat(desktop): make local speech transcription work end to end
Local dictation had never produced a transcript on an installed build. The engine itself was healthy; every connection to it was broken. Installed builds shipped no speech engine at all: the packaging config had no entry for the faster-whisper sidecar and no pipeline step built one, so the app always fell back to a system Python without the runtime. Development was broken too, because the sidecar and SoX paths were resolved against the Vite output directory instead of the app root, which also meant recording failed with a SoX ENOENT. On hosts where localhost resolves only to IPv6, every local request was refused outright, which silently disabled both local transcription and the local LLM. The sidecar is now built and bundled (including the Silero VAD data it needs), gated by a packaging check that fails when the engine or its data is missing. Paths are discovered from the app root and fail loudly when the engine is absent. Local engine URLs are normalized to the IPv4 loopback, decoding is tuned so repeated hallucinations cannot compound (the same transcript now takes about a fifth of the time), the engine is warmed up at startup, and holding the hotkey now shows the text forming live in the recording tip.
This commit is contained in:
parent
359b244dc9
commit
2d585bfc29
52 changed files with 1450 additions and 3861 deletions
|
|
@ -36,6 +36,8 @@ asarUnpack:
|
|||
- "node_modules/better-sqlite3/**"
|
||||
- "node_modules/uiohook-napi/**"
|
||||
- "node_modules/@nut-tree-fork/**"
|
||||
# ffmpeg 정적 바이너리는 실행 파일이므로 asar 내부에서 spawn할 수 없다
|
||||
- "node_modules/@ffmpeg-installer/**"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Windows
|
||||
|
|
@ -108,16 +110,29 @@ extraResources:
|
|||
- "*.wav"
|
||||
|
||||
# SoX 바이너리
|
||||
# 주의: filter에 "**/*"를 쓰면 하위 디렉토리가 통째로 누락된다(실측).
|
||||
# 디렉토리를 그대로 복사할 때는 filter를 지정하지 않는다.
|
||||
- from: resources/sox/
|
||||
to: sox/
|
||||
|
||||
# faster-whisper STT 사이드카 (PyInstaller onedir: sidecar.exe + _internal/).
|
||||
# 반드시 존재해야 한다. 누락되면 로컬 전사가 전혀 동작하지 않는다.
|
||||
# 빌드: npm --prefix apps/desktop run sidecar:build
|
||||
# electron-builder는 이 트리를 재귀로 복사한다(_internal 포함).
|
||||
# 서명 검증에 실패하면 복사가 중간에 끊겨 _internal이 빠지므로,
|
||||
# 서명 없이 로컬 검증할 때는 -c.win.forceCodeSigning=false 를 사용한다.
|
||||
- from: sidecar-dist/sidecar/
|
||||
to: sidecar/
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
# ffmpeg (파일 전사/미디어 변환용). CI가 resources/ffmpeg/에 배치한다.
|
||||
- from: resources/ffmpeg/
|
||||
to: ffmpeg/
|
||||
|
||||
# Ollama 바이너리 (포터블 zip을 사전 배치)
|
||||
- from: resources/ollama/
|
||||
to: ollama/
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
nsis:
|
||||
# 파일명 공백 금지 — mac.artifactName 주석과 동일한 이유 (latest.yml url 정합)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/desktop",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"productName": "d3ro-voice",
|
||||
"description": "로컬 AI 음성 어시스턴트 (Electron)",
|
||||
"main": "./out/main/index.js",
|
||||
|
|
@ -20,7 +20,10 @@
|
|||
"dist": "electron-vite build && electron-builder --config electron-builder.yml",
|
||||
"dist:win": "electron-vite build && electron-builder --win --config electron-builder.yml --publish never",
|
||||
"dist:mac": "electron-vite build && electron-builder --mac --config electron-builder.yml --publish never -c.mac.identity=- -c.mac.hardenedRuntime=false",
|
||||
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
|
||||
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1",
|
||||
"sidecar:setup": "node scripts/setup-sidecar.mjs",
|
||||
"sidecar:build": "node scripts/build-sidecar.mjs",
|
||||
"dist:win:full": "npm run sidecar:build && electron-vite build && electron-builder --win --config electron-builder.yml --publish never"
|
||||
},
|
||||
"author": "D3RO",
|
||||
"license": "MIT",
|
||||
|
|
@ -45,6 +48,7 @@
|
|||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
|
|
|
|||
0
apps/desktop/resources/ffmpeg/.gitkeep
Normal file
0
apps/desktop/resources/ffmpeg/.gitkeep
Normal file
127
apps/desktop/scripts/build-sidecar.mjs
Normal file
127
apps/desktop/scripts/build-sidecar.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// scripts/build-sidecar.mjs
|
||||
// faster-whisper 사이드카를 PyInstaller(onedir)로 묶어 sidecar-dist/sidecar/ 에 만든다.
|
||||
//
|
||||
// 사용: npm --prefix apps/desktop run sidecar:build
|
||||
// 출력: apps/desktop/sidecar-dist/sidecar/sidecar(.exe) ← electron-builder extraResources 대상
|
||||
//
|
||||
// 주의:
|
||||
// - `--collect-all faster_whisper` 가 필수다. faster-whisper는 VAD용
|
||||
// `assets/silero_vad_v6.onnx` 데이터 파일을 패키지 안에 두는데, 이걸 누락하면
|
||||
// 번들된 앱에서 VAD 사용 시 런타임에 실패한다.
|
||||
// - 콘솔 모드를 유지한다(--noconsole 금지). 메인 프로세스가 stdout/stderr를 로그로
|
||||
// 수집하는데, windowed 모드에서는 스트림이 사라져 진단이 불가능해진다.
|
||||
// 콘솔 창 깜빡임은 메인 프로세스 spawn의 windowsHide로 막는다.
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, rmSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const desktopDir = path.resolve(scriptDir, '..')
|
||||
const sidecarDir = path.join(desktopDir, 'sidecar')
|
||||
const mainPy = path.join(sidecarDir, 'main.py')
|
||||
const outputDir = path.join(desktopDir, 'sidecar-dist')
|
||||
const isWindows = process.platform === 'win32'
|
||||
const exeSuffix = isWindows ? '.exe' : ''
|
||||
|
||||
const venvPython = isWindows
|
||||
? path.join(sidecarDir, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(sidecarDir, '.venv', 'bin', 'python3')
|
||||
|
||||
const python = existsSync(venvPython)
|
||||
? venvPython
|
||||
: isWindows
|
||||
? 'python'
|
||||
: 'python3'
|
||||
|
||||
if (!existsSync(mainPy)) {
|
||||
console.error(`사이드카 소스를 찾을 수 없습니다: ${mainPy}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const probe = spawnSync(
|
||||
python,
|
||||
['-c', 'import PyInstaller, faster_whisper; print("ok")'],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
if (probe.status !== 0) {
|
||||
console.error(
|
||||
[
|
||||
`PyInstaller/faster-whisper를 사용할 수 없습니다 (python: ${python}).`,
|
||||
'먼저 사이드카 환경을 준비하세요:',
|
||||
' npm --prefix apps/desktop run sidecar:setup',
|
||||
probe.stderr?.trim() || '',
|
||||
].join('\n'),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (existsSync(outputDir)) {
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-m',
|
||||
'PyInstaller',
|
||||
'--name',
|
||||
'sidecar',
|
||||
'--distpath',
|
||||
outputDir,
|
||||
'--workpath',
|
||||
path.join(desktopDir, 'build', 'sidecar-build'),
|
||||
'--specpath',
|
||||
path.join(desktopDir, 'build'),
|
||||
'--noconfirm',
|
||||
'--clean',
|
||||
// 패키지 데이터/바이너리 포함 (VAD onnx, ctranslate2 DLL 등)
|
||||
'--collect-all',
|
||||
'faster_whisper',
|
||||
'--collect-all',
|
||||
'ctranslate2',
|
||||
'--collect-all',
|
||||
'tokenizers',
|
||||
'--collect-all',
|
||||
'huggingface_hub',
|
||||
// uvicorn은 동적 임포트를 사용하므로 명시
|
||||
'--hidden-import',
|
||||
'uvicorn.logging',
|
||||
'--hidden-import',
|
||||
'uvicorn.protocols.http',
|
||||
'--hidden-import',
|
||||
'uvicorn.protocols.http.auto',
|
||||
'--hidden-import',
|
||||
'uvicorn.protocols.http.h11_impl',
|
||||
'--hidden-import',
|
||||
'uvicorn.protocols.websockets',
|
||||
'--hidden-import',
|
||||
'uvicorn.protocols.websockets.auto',
|
||||
'--hidden-import',
|
||||
'uvicorn.lifespan',
|
||||
'--hidden-import',
|
||||
'uvicorn.lifespan.on',
|
||||
'--hidden-import',
|
||||
'uvicorn.lifespan.off',
|
||||
mainPy,
|
||||
]
|
||||
|
||||
console.log('='.repeat(64))
|
||||
console.log(`D3RO-VOICE 사이드카 빌드 (${process.platform} ${process.arch})`)
|
||||
console.log('='.repeat(64))
|
||||
console.log(`\n$ ${python} ${args.join(' ')}\n`)
|
||||
|
||||
const result = spawnSync(python, args, { stdio: 'inherit', cwd: sidecarDir })
|
||||
if (result.status !== 0) {
|
||||
console.error(`\nPyInstaller 빌드 실패 (exit ${result.status ?? 'null'})`)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
const exePath = path.join(outputDir, 'sidecar', `sidecar${exeSuffix}`)
|
||||
if (!existsSync(exePath)) {
|
||||
console.error(`\n빌드 산출물을 찾을 수 없습니다: ${exePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const exeMb = statSync(exePath).size / (1024 * 1024)
|
||||
console.log(`\n빌드 성공: ${exePath} (${exeMb.toFixed(1)} MB)`)
|
||||
console.log('electron-builder가 sidecar-dist/sidecar/ 를 resources/sidecar/ 로 복사한다.')
|
||||
103
apps/desktop/scripts/setup-sidecar.mjs
Normal file
103
apps/desktop/scripts/setup-sidecar.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// scripts/setup-sidecar.mjs
|
||||
// STT 사이드카 개발/빌드 환경을 준비한다.
|
||||
//
|
||||
// - sidecar/.venv 가 없으면 생성한다.
|
||||
// - requirements.txt + pyinstaller 를 설치한다.
|
||||
//
|
||||
// 사용: npm --prefix apps/desktop run sidecar:setup
|
||||
//
|
||||
// 주의: faster-whisper/ctranslate2 휠은 수백 MB이며 최초 실행 시 네트워크가 필요하다.
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const desktopDir = path.resolve(scriptDir, '..')
|
||||
const sidecarDir = path.join(desktopDir, 'sidecar')
|
||||
const isWindows = process.platform === 'win32'
|
||||
const venvPython = isWindows
|
||||
? path.join(sidecarDir, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(sidecarDir, '.venv', 'bin', 'python3')
|
||||
|
||||
function run(command, args, label) {
|
||||
console.log(`\n$ ${command} ${args.join(' ')}`)
|
||||
const result = spawnSync(command, args, { stdio: 'inherit', cwd: sidecarDir })
|
||||
if (result.status !== 0) {
|
||||
console.error(`\n${label} 단계가 실패했습니다 (exit ${result.status ?? 'null'})`)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function findSystemPython() {
|
||||
const candidates = isWindows
|
||||
? [
|
||||
['py', ['-3.11']],
|
||||
['py', ['-3']],
|
||||
['python', []],
|
||||
['python3', []],
|
||||
]
|
||||
: [
|
||||
['python3.11', []],
|
||||
['python3', []],
|
||||
['python', []],
|
||||
]
|
||||
|
||||
for (const [command, prefixArgs] of candidates) {
|
||||
const probe = spawnSync(command, [...prefixArgs, '--version'], {
|
||||
encoding: 'utf-8',
|
||||
shell: isWindows,
|
||||
})
|
||||
if (probe.status === 0) {
|
||||
console.log(`시스템 Python 발견: ${command} ${prefixArgs.join(' ')} → ${probe.stdout.trim()}`)
|
||||
return { command, prefixArgs, shell: isWindows }
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
'Python 3.11+ 를 찾을 수 없습니다. https://www.python.org/downloads/ 에서 설치하거나 PATH에 추가하세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function hasModule(python, moduleName) {
|
||||
return (
|
||||
spawnSync(python, ['-c', `import ${moduleName}`], { stdio: 'ignore' }).status === 0
|
||||
)
|
||||
}
|
||||
|
||||
if (!existsSync(sidecarDir)) {
|
||||
console.error(`사이드카 디렉토리를 찾을 수 없습니다: ${sidecarDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (existsSync(venvPython) && hasModule(venvPython, 'faster_whisper')) {
|
||||
// CI/재실행 시 불필요한 재설치를 건너뛴다 (수백 MB 다운로드 방지).
|
||||
console.log(`사이드카 환경이 이미 준비되어 있습니다: ${venvPython}`)
|
||||
if (!hasModule(venvPython, 'PyInstaller')) {
|
||||
run(venvPython, ['-m', 'pip', 'install', 'pyinstaller>=6.0'], 'PyInstaller 설치')
|
||||
}
|
||||
} else {
|
||||
if (!existsSync(venvPython)) {
|
||||
const systemPython = findSystemPython()
|
||||
mkdirSync(path.dirname(venvPython), { recursive: true })
|
||||
run(
|
||||
systemPython.command,
|
||||
[...systemPython.prefixArgs, '-m', 'venv', path.join(sidecarDir, '.venv')],
|
||||
'가상환경 생성',
|
||||
)
|
||||
} else {
|
||||
console.log(`기존 가상환경 사용: ${venvPython}`)
|
||||
}
|
||||
|
||||
run(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip'], 'pip 업그레이드')
|
||||
run(
|
||||
venvPython,
|
||||
['-m', 'pip', 'install', '-r', path.join(sidecarDir, 'requirements.txt')],
|
||||
'사이드카 의존성 설치',
|
||||
)
|
||||
run(venvPython, ['-m', 'pip', 'install', 'pyinstaller>=6.0'], 'PyInstaller 설치')
|
||||
}
|
||||
|
||||
console.log('\n사이드카 환경 준비 완료.')
|
||||
|
|
@ -39,6 +39,14 @@ from fastapi.responses import JSONResponse
|
|||
|
||||
# ── 로깅 설정 ──────────────────────────────────────────────
|
||||
|
||||
# Windows에서 파이프로 연결되면 Python이 로케일(cp949) 인코딩으로 출력해
|
||||
# 메인 프로세스의 UTF-8 로그가 깨진다. 명시적으로 UTF-8로 고정한다.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
||||
|
|
@ -121,6 +129,11 @@ def _detect_gpu() -> None:
|
|||
logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc)
|
||||
|
||||
|
||||
def _cpu_threads() -> int:
|
||||
"""CPU 추론에 사용할 스레드 수 (과도한 점유 방지 위해 8로 상한)."""
|
||||
return max(1, min(8, os.cpu_count() or 4))
|
||||
|
||||
|
||||
# ── 모델 다운로드 헬퍼 ─────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -250,6 +263,52 @@ def _cleanup_partial(model_id: str) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _build_transcribe_kwargs(
|
||||
language: str,
|
||||
vad_filter: str,
|
||||
initial_prompt: str,
|
||||
is_partial: bool,
|
||||
) -> dict:
|
||||
"""전사 옵션을 만든다.
|
||||
|
||||
받아쓰기 정합성을 위해 컨텍스트 누적(condition_on_previous_text)을 끈다.
|
||||
Whisper가 앞 세그먼트 오류를 반복 증폭하는 현상(환각 루프)을 막는다.
|
||||
미리보기(partial)는 지연이 목표이므로 greedy + VAD 없음으로 디코딩한다.
|
||||
"""
|
||||
if is_partial:
|
||||
kwargs: dict = {
|
||||
"beam_size": 1,
|
||||
"temperature": 0.0,
|
||||
"vad_filter": False,
|
||||
"condition_on_previous_text": False,
|
||||
"word_timestamps": False,
|
||||
}
|
||||
else:
|
||||
kwargs = {
|
||||
"beam_size": 5,
|
||||
# 0.0 단일 온도는 실패 시 재시도가 없어 환각이 남는다.
|
||||
# 낮은 온도 폴백만 허용하되 컨텍스트를 끊어 반복을 차단한다.
|
||||
"temperature": [0.0, 0.2, 0.4],
|
||||
"condition_on_previous_text": False,
|
||||
"no_speech_threshold": 0.6,
|
||||
"compression_ratio_threshold": 2.4,
|
||||
"log_prob_threshold": -1.0,
|
||||
"vad_filter": vad_filter.lower() == "true",
|
||||
"word_timestamps": False,
|
||||
}
|
||||
if kwargs["vad_filter"]:
|
||||
# 무음 구간을 촘촘히 잘라 속도를 올린다.
|
||||
kwargs["vad_parameters"] = {"min_silence_duration_ms": 300}
|
||||
|
||||
if language != "auto":
|
||||
kwargs["language"] = language
|
||||
|
||||
if initial_prompt and not is_partial:
|
||||
kwargs["initial_prompt"] = initial_prompt
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
# ── 엔드포인트 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -260,7 +319,9 @@ async def health() -> JSONResponse:
|
|||
content={
|
||||
"status": "ready" if _model is not None else "no_model",
|
||||
"model": _model_id,
|
||||
"model_loaded": _model is not None,
|
||||
"gpu": _gpu_available,
|
||||
"device": "cuda" if _gpu_available else "cpu",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -282,6 +343,18 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
|||
|
||||
start_time = time.monotonic()
|
||||
|
||||
# 같은 모델이 이미 로딩되어 있으면 재사용 (재로딩은 수초 지연을 만든다)
|
||||
if _model is not None and _model_id == model_id:
|
||||
logger.info("이미 로딩된 모델 재사용: %s", model_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "loaded",
|
||||
"model_id": model_id,
|
||||
"load_time_ms": 0,
|
||||
"reused": True,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
|
|
@ -295,10 +368,15 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
|||
if local_dir:
|
||||
logger.info("로컬 모델 디렉토리 사용: %s", local_dir)
|
||||
|
||||
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
|
||||
_model = None
|
||||
|
||||
_model = WhisperModel(
|
||||
model_source,
|
||||
device=device,
|
||||
compute_type=compute_type,
|
||||
cpu_threads=_cpu_threads(),
|
||||
num_workers=1,
|
||||
)
|
||||
_model_id = model_id
|
||||
|
||||
|
|
@ -333,14 +411,16 @@ async def transcribe(
|
|||
language: str = Form("auto"),
|
||||
vad_filter: str = Form("true"),
|
||||
initial_prompt: str = Form(""),
|
||||
partial: str = Form("false"),
|
||||
) -> JSONResponse:
|
||||
"""오디오 파일을 전사한다.
|
||||
|
||||
Multipart form:
|
||||
audio - PCM16 16kHz mono 바이너리 파일
|
||||
language - 언어 코드 ('auto', 'ko', 'en', ...)
|
||||
vad_filter - VAD 필터 활성화 ('true' / 'false')
|
||||
audio - PCM16 16kHz mono 바이너리 파일
|
||||
language - 언어 코드 ('auto', 'ko', 'en', ...)
|
||||
vad_filter - VAD 필터 활성화 ('true' / 'false')
|
||||
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
|
||||
partial - 녹음 중 미리보기 모드 ('true'면 greedy 디코딩 + 컨텍스트 미사용)
|
||||
"""
|
||||
if _model is None:
|
||||
return JSONResponse(
|
||||
|
|
@ -348,6 +428,7 @@ async def transcribe(
|
|||
content={"status": "error", "message": "모델이 로딩되지 않았습니다"},
|
||||
)
|
||||
|
||||
is_partial = partial.lower() == "true"
|
||||
start_time = time.monotonic()
|
||||
|
||||
try:
|
||||
|
|
@ -367,22 +448,19 @@ async def transcribe(
|
|||
audio_duration = len(audio_array) / sample_rate
|
||||
|
||||
logger.info(
|
||||
"전사 시작: %.1f초 오디오, language=%s, vad=%s",
|
||||
"전사 시작: %.1f초 오디오, language=%s, vad=%s, partial=%s",
|
||||
audio_duration,
|
||||
language,
|
||||
vad_filter,
|
||||
is_partial,
|
||||
)
|
||||
|
||||
transcribe_kwargs: dict = {
|
||||
"vad_filter": vad_filter.lower() == "true",
|
||||
"beam_size": 5,
|
||||
}
|
||||
|
||||
if language != "auto":
|
||||
transcribe_kwargs["language"] = language
|
||||
|
||||
if initial_prompt:
|
||||
transcribe_kwargs["initial_prompt"] = initial_prompt
|
||||
transcribe_kwargs = _build_transcribe_kwargs(
|
||||
language=language,
|
||||
vad_filter=vad_filter,
|
||||
initial_prompt=initial_prompt,
|
||||
is_partial=is_partial,
|
||||
)
|
||||
|
||||
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export async function bootstrap(): Promise<void> {
|
|||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||
{ name: 'stt-warmup', critical: false, fn: initSTTWarmup },
|
||||
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
|
||||
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
|
||||
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
|
||||
|
|
@ -270,6 +271,20 @@ async function initLLMPolling(): Promise<void> {
|
|||
await startLocalLLMAvailability()
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 STT(sidecar + Whisper 모델)를 앱 시작 시 백그라운드로 미리 데운다.
|
||||
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리는 체감 지연을 없앤다.
|
||||
* bootstrap을 막지 않도록 await하지 않는다 — 실패는 warmUpLocal이 흡수한다.
|
||||
*/
|
||||
async function initSTTWarmup(): Promise<void> {
|
||||
try {
|
||||
const { getSTTManager } = await import('./services/stt/STTManager')
|
||||
void getSTTManager().warmUpLocal()
|
||||
} catch (err) {
|
||||
logger.warn('STT warmup scheduling failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function initCloudSync(): Promise<void> {
|
||||
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||
const sync = getCloudSyncService()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getLocalLLMService } from '../services/LocalLLMService'
|
|||
import { getPremiumLLMService } from '../services/PremiumLLMService'
|
||||
import { getOnlineLLMService } from '../services/OnlineLLMService'
|
||||
import { configGet, configSet } from '../services/ConfigService'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types'
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ export function registerLLMHandlers(): void {
|
|||
|
||||
// ONLINE AUTH HANDLERS
|
||||
ipcMain.handle(IPC_CHANNELS.ONLINE_AUTH.REGISTER, async (_event, params: { email: string; password: string }) => {
|
||||
const apiUrl = configGet('onlineApiUrl') ?? 'http://localhost:5000'
|
||||
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
|
|
@ -151,7 +152,7 @@ export function registerLLMHandlers(): void {
|
|||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ONLINE_AUTH.LOGIN, async (_event, params: { email: string; password: string }) => {
|
||||
const apiUrl = configGet('onlineApiUrl') ?? 'http://localhost:5000'
|
||||
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ class AudioCaptureService extends EventEmitter {
|
|||
]
|
||||
|
||||
logger.info(`SoX args: ${soxArgs.join(' ')}`)
|
||||
this._soxProcess = spawn(soxExe, soxArgs, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
this._soxProcess = spawn(soxExe, soxArgs, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
this._stream = this._soxProcess.stdout
|
||||
this._residualBuffer = Buffer.alloc(0)
|
||||
this._levelAccumulator = []
|
||||
|
|
@ -168,8 +168,12 @@ class AudioCaptureService extends EventEmitter {
|
|||
|
||||
this._soxProcess.on('error', (err: Error) => {
|
||||
logger.error(`SoX process spawn error: ${err.message}`)
|
||||
const hint =
|
||||
soxExe === 'sox' && (err as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? ' 번들된 SoX(resources/sox/sox.exe)도, 시스템 PATH의 sox도 없습니다. `npm --prefix apps/desktop run setup:sox`로 내려받으세요.'
|
||||
: ''
|
||||
this._handleError(
|
||||
new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 프로세스 시작 실패: ${err.message}`),
|
||||
new D3ROError(ErrorCode.AudioCaptureStartFailed, `SoX 프로세스 시작 실패: ${err.message}.${hint}`),
|
||||
'error'
|
||||
)
|
||||
})
|
||||
|
|
@ -392,7 +396,7 @@ class AudioCaptureService extends EventEmitter {
|
|||
'-b', '16', '-e', 'signed-integer', '-t', 'raw', '-']
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(soxExe, soxArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const proc = spawn(soxExe, soxArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
||||
const buffers: Buffer[] = []
|
||||
let peakRms = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
sttProvider: 'local' as const,
|
||||
sttProviderConfigs: {
|
||||
local: { modelId: 'large-v3-turbo' },
|
||||
'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://localhost:5000' },
|
||||
'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://127.0.0.1:5000' },
|
||||
openai: { modelId: 'whisper-1', apiKey: '', baseUrl: 'https://api.openai.com/v1' },
|
||||
groq: { modelId: 'whisper-large-v3-turbo', apiKey: '', baseUrl: 'https://api.groq.com/openai/v1' },
|
||||
deepgram: { modelId: 'nova-3', apiKey: '', baseUrl: 'https://api.deepgram.com' },
|
||||
assemblyai: { modelId: 'best', apiKey: '', baseUrl: 'https://api.assemblyai.com/v2' },
|
||||
google: { modelId: 'gemini-2.0-flash', apiKey: '', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
custom: { modelId: 'whisper-1', apiKey: '', baseUrl: 'http://localhost:8000/v1' },
|
||||
custom: { modelId: 'whisper-1', apiKey: '', baseUrl: 'http://127.0.0.1:8000/v1' },
|
||||
},
|
||||
sttFallbackToLocal: true,
|
||||
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드)
|
||||
|
|
@ -40,10 +40,10 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
sttLanguage: 'auto',
|
||||
ttsVoiceId: null,
|
||||
ttsSpeed: 1.0,
|
||||
onlineApiUrl: 'http://localhost:5000',
|
||||
onlineApiUrl: 'http://127.0.0.1:5000',
|
||||
localModelsDir: '',
|
||||
llmModelId: 'gemma-2-2b-it.Q4_K_M.gguf',
|
||||
ollamaServerUrl: 'http://localhost:11434',
|
||||
ollamaServerUrl: 'http://127.0.0.1:11434',
|
||||
appUsageMode: null,
|
||||
authToken: null,
|
||||
userEmail: null,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|||
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
import { getBundledOllamaPath } from '../utils/paths'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
|
||||
const logger = getLogger('LocalLLMService')
|
||||
|
||||
/** Ollama 서버 URL — localhost는 ::1로 해석되어 실패하므로 IPv4 루프백으로 정규화한다. */
|
||||
export function getOllamaServerUrl(): string {
|
||||
return normalizeLoopbackUrl(configGet('ollamaServerUrl') || 'http://127.0.0.1:11434')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 내부 타입
|
||||
// ============================================================
|
||||
|
|
@ -172,7 +178,7 @@ class LocalLLMService extends EventEmitter {
|
|||
* Ollama /api/version 또는 /api/tags 엔드포인트로 가용성 핑. 지정 타임아웃 내 응답이 오면 true.
|
||||
*/
|
||||
private async _ping(timeoutMs: number): Promise<boolean> {
|
||||
const serverUrl = configGet('ollamaServerUrl') || 'http://localhost:11434'
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/version`, {
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
|
|
@ -325,7 +331,7 @@ class LocalLLMService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama 서버에 연결할 수 없습니다')
|
||||
}
|
||||
|
||||
const serverUrl = configGet('ollamaServerUrl')
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
||||
|
||||
this._state = LLMState.Generating
|
||||
|
|
@ -392,7 +398,7 @@ class LocalLLMService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama 서버에 연결할 수 없습니다')
|
||||
}
|
||||
|
||||
const serverUrl = configGet('ollamaServerUrl')
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
||||
|
||||
this._state = LLMState.Generating
|
||||
|
|
@ -536,7 +542,7 @@ class LocalLLMService extends EventEmitter {
|
|||
* Ollama에 설치된 모델 목록을 조회한다.
|
||||
*/
|
||||
async getModels(): Promise<LLMModel[]> {
|
||||
const serverUrl = configGet('ollamaServerUrl')
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/tags`, {
|
||||
|
|
@ -582,7 +588,7 @@ class LocalLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
private async _doPullModel(modelId: string): Promise<void> {
|
||||
const serverUrl = configGet('ollamaServerUrl')
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
logger.info(`Pull 시작: ${modelId}`)
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/pull`, {
|
||||
|
|
@ -656,7 +662,7 @@ class LocalLLMService extends EventEmitter {
|
|||
|
||||
return {
|
||||
connectionState,
|
||||
serverUrl: configGet('ollamaServerUrl') || 'http://localhost:11434',
|
||||
serverUrl: getOllamaServerUrl(),
|
||||
activeModel: configGet('llmModelId') || 'gemma2:2b',
|
||||
serverVersion: this._serverVersion
|
||||
}
|
||||
|
|
@ -679,7 +685,7 @@ class LocalLLMService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama server not available')
|
||||
}
|
||||
|
||||
const serverUrl = configGet('ollamaServerUrl') || 'http://localhost:11434'
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma2:2b'
|
||||
|
||||
this._abortController = new AbortController()
|
||||
|
|
@ -754,7 +760,7 @@ class LocalLLMService extends EventEmitter {
|
|||
|
||||
private async _checkAvailability(): Promise<void> {
|
||||
if (this._disposed) return
|
||||
const serverUrl = configGet('ollamaServerUrl') || 'http://localhost:11434'
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
|
||||
let isOk = false
|
||||
let detectedVersion: string | null = null
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { existsSync } from 'fs'
|
|||
import { join } from 'path'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getSidecarCommand, getWhisperModelsDir } from '../utils/paths'
|
||||
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
STTModel,
|
||||
|
|
@ -52,6 +52,8 @@ export interface TranscribeOptions {
|
|||
language?: string
|
||||
initialPrompt?: string
|
||||
vadFilter?: boolean
|
||||
/** 음 중 실시간 미리보기 요청 — 상태/이벤트를 건드리지 않고 greedy 디코딩을 사용한다. */
|
||||
partial?: boolean
|
||||
}
|
||||
|
||||
/** sidecar /health 응답 */
|
||||
|
|
@ -109,6 +111,8 @@ const HEALTH_CHECK_INTERVAL_MS = 1000
|
|||
const HEALTH_CHECK_TIMEOUT_MS = 30000
|
||||
const MAX_RESTART_COUNT = 3
|
||||
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
|
||||
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
|
||||
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
|
||||
|
||||
/** 알려진 Whisper 모델 카탈로그 */
|
||||
const MODEL_CATALOG: STTModel[] = [
|
||||
|
|
@ -207,6 +211,11 @@ class LocalSTTService extends EventEmitter {
|
|||
return this._currentModelId
|
||||
}
|
||||
|
||||
/** sidecar HTTP 기본 URL — IPv4 루프백 고정 (localhost는 ::1로 해석되어 실패) */
|
||||
private get _baseUrl(): string {
|
||||
return getSidecarBaseUrl(this._port)
|
||||
}
|
||||
|
||||
// ── 공개 메서드 ──
|
||||
|
||||
/**
|
||||
|
|
@ -300,6 +309,65 @@ class LocalSTTService extends EventEmitter {
|
|||
return this._sendToSidecar(audioBuffer, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 시작 시 sidecar와 모델을 미리 데운다.
|
||||
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
||||
* 실패는 조용히 경고로만 남기고 예외를 던지지 않는다.
|
||||
*/
|
||||
async warmUp(): Promise<boolean> {
|
||||
if (this._disposed) return false
|
||||
if (this._state === STTState.Ready && this._currentModelId) return true
|
||||
|
||||
const modelId = configGet('sttModelId')
|
||||
if (!modelId) {
|
||||
logger.info('STT 워밍업 생략: 모델이 선택되지 않았습니다')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!existsSync(join(getWhisperModelsDir(), modelId, 'model.bin'))) {
|
||||
logger.info(`STT 워밍업 생략: 모델 미설치 (${modelId})`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initialize(modelId)
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`STT 워밍업 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 중 실시간 미리보기 전사.
|
||||
* 최종 결과와 분리되어 삽입되지 않으며, 실패해도 빈 문자열을 반환한다.
|
||||
* 지연 최소화를 위해 상태/이벤트를 건드리지 않는다.
|
||||
*/
|
||||
async transcribePartial(
|
||||
audioBuffer: Buffer,
|
||||
options?: TranscribeOptions,
|
||||
): Promise<string> {
|
||||
if (this._disposed) return ''
|
||||
if (!this._modelReady) return ''
|
||||
if (audioBuffer.length === 0) return ''
|
||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) return ''
|
||||
|
||||
try {
|
||||
const result = await this._sendToSidecar(audioBuffer, {
|
||||
...options,
|
||||
partial: true,
|
||||
})
|
||||
return result.text
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`부분 전사 실패(무시): ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운로드된 모델 목록 조회.
|
||||
* models-dir 사전 다운로드 여부 + 현재 로딩 여부로 downloaded를 판정한다.
|
||||
|
|
@ -328,7 +396,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
await this._ensureSidecarRunning()
|
||||
|
||||
const startRes = await fetch(`http://localhost:${this._port}/download`, {
|
||||
const startRes = await fetch(`${this._baseUrl}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: modelId }),
|
||||
|
|
@ -361,7 +429,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
let status: DownloadStatusResponse
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${this._port}/download/status`, {
|
||||
const res = await fetch(`${this._baseUrl}/download/status`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
|
@ -408,7 +476,7 @@ class LocalSTTService extends EventEmitter {
|
|||
/** 진행 중인 모델 다운로드 취소 요청 */
|
||||
async cancelDownload(): Promise<void> {
|
||||
try {
|
||||
await fetch(`http://localhost:${this._port}/download/cancel`, {
|
||||
await fetch(`${this._baseUrl}/download/cancel`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
|
@ -570,77 +638,120 @@ class LocalSTTService extends EventEmitter {
|
|||
// dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음.
|
||||
this._port = await this._findFreePort(SIDECAR_PORT, 20)
|
||||
|
||||
const { command, args } = getSidecarCommand()
|
||||
// 번들/venv 경로가 깨졌으면 여기서 즉시 실패한다 (조용한 PATH 폴백 금지).
|
||||
const launch = getSidecarCommand()
|
||||
const fullArgs = [
|
||||
...args,
|
||||
...launch.args,
|
||||
'--port',
|
||||
String(this._port),
|
||||
'--models-dir',
|
||||
getWhisperModelsDir(),
|
||||
]
|
||||
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
|
||||
logger.info(
|
||||
`Sidecar 시작(${launch.source}): ${launch.command} ${fullArgs.join(' ')}`,
|
||||
)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
this._sidecarProcess = spawn(
|
||||
command,
|
||||
fullArgs,
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
const d3roErr = new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
reject(d3roErr)
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
const child = spawn(launch.command, fullArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// 사이드카 로그와 파일 경로가 UTF-8로 오가도록 고정 (Windows cp949 깨짐 방지)
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1',
|
||||
},
|
||||
// Windows에서 콘솔 창이 깜빡이지 않게 한다.
|
||||
windowsHide: true,
|
||||
})
|
||||
this._sidecarProcess = child
|
||||
|
||||
const sidecarLogger = getLogger('sidecar')
|
||||
this._pipeSidecarLogs(child, getLogger('sidecar'))
|
||||
|
||||
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const text = data.toString().trim()
|
||||
if (text) {
|
||||
sidecarLogger.info(text)
|
||||
}
|
||||
// spawn 성공 = 프로세스가 실제로 시작됨. 즉시 resolve해 healthcheck로 넘어간다.
|
||||
child.once('spawn', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
})
|
||||
|
||||
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const text = data.toString().trim()
|
||||
if (text) {
|
||||
sidecarLogger.warn(text)
|
||||
}
|
||||
})
|
||||
|
||||
this._sidecarProcess.on('error', (err: Error) => {
|
||||
// spawn 실패(ENOENT 등)는 즉시 실패시킨다. 예전엔 즉시 resolve 후
|
||||
// healthcheck 30초를 헛되게 태우고 원인을 숨겼다.
|
||||
child.once('error', (err: Error) => {
|
||||
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
|
||||
reject(
|
||||
new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 프로세스 에러: ${err.message}`,
|
||||
),
|
||||
)
|
||||
this._sidecarProcess = null
|
||||
if (settled) return
|
||||
settled = true
|
||||
reject(this._spawnFailureError(err, launch))
|
||||
})
|
||||
|
||||
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
|
||||
child.on('exit', (code: number | null, signal: string | null) => {
|
||||
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
|
||||
this._sidecarProcess = null
|
||||
if (this._sidecarProcess === child) {
|
||||
this._sidecarProcess = null
|
||||
}
|
||||
this._modelReady = false
|
||||
|
||||
if (!this._disposed) {
|
||||
this._handleSidecarCrash()
|
||||
}
|
||||
})
|
||||
|
||||
// spawn 자체는 비동기적이므로 즉시 resolve
|
||||
// 실제 준비는 _waitForHealth에서 확인
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/** sidecar stdout/stderr를 줄 단위로 로그에 흘려보낸다. */
|
||||
private _pipeSidecarLogs(
|
||||
child: ChildProcess,
|
||||
sidecarLogger: ReturnType<typeof getLogger>,
|
||||
): void {
|
||||
const consume = (
|
||||
stream: NodeJS.ReadableStream | null | undefined,
|
||||
write: (message: string) => void,
|
||||
): void => {
|
||||
if (!stream) return
|
||||
let pending = ''
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
pending += chunk.toString('utf8')
|
||||
const lines = pending.split(/\r?\n/)
|
||||
// 마지막 조각은 줄이 완성되지 않았을 수 있으니 다음 청크와 합친다.
|
||||
pending = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) write(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
consume(child.stdout, (message) => sidecarLogger.info(message))
|
||||
consume(child.stderr, (message) => sidecarLogger.warn(message))
|
||||
}
|
||||
|
||||
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
||||
private _spawnFailureError(
|
||||
err: Error,
|
||||
launch: { command: string; source: 'bundled' | 'venv' | 'python' },
|
||||
): D3ROError {
|
||||
const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
if (!enoent) {
|
||||
return new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 프로세스 에러: ${err.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
const hint =
|
||||
launch.source === 'bundled'
|
||||
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
||||
: launch.source === 'venv'
|
||||
? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.'
|
||||
: '시스템 Python을 찾을 수 없습니다. `npm --prefix apps/desktop run sidecar:setup`으로 가상환경을 만드세요.'
|
||||
|
||||
return new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 실행 파일을 찾을 수 없습니다: ${launch.command} (${launch.source}). ${hint}`,
|
||||
)
|
||||
}
|
||||
|
||||
private async _waitForHealth(): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
|
||||
|
|
@ -655,7 +766,7 @@ class LocalSTTService extends EventEmitter {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${this._port}/health`, {
|
||||
const response = await fetch(`${this._baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
|
||||
|
|
@ -685,7 +796,7 @@ class LocalSTTService extends EventEmitter {
|
|||
logger.info(`모델 로딩 시작: ${modelId}`)
|
||||
const startTime = Date.now()
|
||||
|
||||
const response = await fetch(`http://localhost:${this._port}/load`, {
|
||||
const response = await fetch(`${this._baseUrl}/load`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: modelId }),
|
||||
|
|
@ -729,12 +840,16 @@ class LocalSTTService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
this._setState(STTState.Transcribing)
|
||||
const isPartial = options?.partial === true
|
||||
|
||||
if (!isPartial) {
|
||||
this._setState(STTState.Transcribing)
|
||||
}
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const language = options?.language ?? configGet('sttLanguage')
|
||||
const vadFilter = options?.vadFilter ?? true
|
||||
const vadFilter = options?.vadFilter ?? !isPartial
|
||||
const initialPrompt = options?.initialPrompt ?? ''
|
||||
|
||||
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
|
||||
|
|
@ -751,18 +866,19 @@ class LocalSTTService extends EventEmitter {
|
|||
)
|
||||
formData.append('language', language)
|
||||
formData.append('vad_filter', String(vadFilter))
|
||||
// 부분 전사는 greedy 디코딩 + 컨텍스트 미사용으로 지연을 최소화한다.
|
||||
formData.append('partial', String(isPartial))
|
||||
if (initialPrompt) {
|
||||
formData.append('initial_prompt', initialPrompt)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`http://localhost:${this._port}/transcribe`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
)
|
||||
const response = await fetch(`${this._baseUrl}/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(
|
||||
isPartial ? SIDECAR_PARTIAL_TIMEOUT_MS : SIDECAR_REQUEST_TIMEOUT_MS,
|
||||
),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
|
|
@ -788,6 +904,12 @@ class LocalSTTService extends EventEmitter {
|
|||
processingTime,
|
||||
}
|
||||
|
||||
if (isPartial) {
|
||||
// 미리보기 — 상태/이벤트를 건드리지 않는다 (최종 삽입과 무관).
|
||||
logger.debug(`부분 전사: "${result.text.substring(0, 40)}" (${processingTime}ms)`)
|
||||
return result
|
||||
}
|
||||
|
||||
// 중간 결과 이벤트 (isFinal=true)
|
||||
this.emit('transcription-delta', { text: result.text, isFinal: true })
|
||||
this.emit('transcription-complete', { result })
|
||||
|
|
@ -800,7 +922,9 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
return result
|
||||
} catch (err) {
|
||||
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
|
||||
if (!isPartial) {
|
||||
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
|
||||
}
|
||||
|
||||
if (err instanceof D3ROError) {
|
||||
throw err
|
||||
|
|
@ -874,7 +998,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
try {
|
||||
// POST /shutdown 요청
|
||||
await fetch(`http://localhost:${this._port}/shutdown`, {
|
||||
await fetch(`${this._baseUrl}/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LLMAction } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
|
|
@ -47,7 +48,7 @@ class OnlineLLMService extends EventEmitter {
|
|||
customPrompt?: string
|
||||
): Promise<string> {
|
||||
const token = this._ensureAuth()
|
||||
const apiUrl = configGet('onlineApiUrl') ?? 'http://localhost:5000'
|
||||
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
|
||||
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
|
||||
|
||||
try {
|
||||
|
|
@ -100,7 +101,7 @@ class OnlineLLMService extends EventEmitter {
|
|||
options?: { model?: string; temperature?: number }
|
||||
): AsyncGenerator<string, string> {
|
||||
const token = this._ensureAuth()
|
||||
const apiUrl = configGet('onlineApiUrl') ?? 'http://localhost:5000'
|
||||
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/llm/chat`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import path from 'path'
|
|||
import { eq } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getOllamaServerUrl } from './LocalLLMService'
|
||||
import { getDatabase } from '../db'
|
||||
import { ragDocuments, ragChunks } from '../db/schema'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
|
|
@ -307,7 +307,7 @@ ${context}`
|
|||
* Ollama /api/embed 엔드포인트로 텍스트 임베딩
|
||||
*/
|
||||
private async _embed(text: string): Promise<number[]> {
|
||||
const serverUrl = configGet('ollamaServerUrl')
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/embed`, {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
sendAudioLevelToTip,
|
||||
sendPartialTranscriptToTip,
|
||||
showResultPopup,
|
||||
} from '../windows/WindowManager'
|
||||
import type { ScreenContext } from '@d3ro/core/types'
|
||||
|
|
@ -98,6 +99,18 @@ const TERMINAL_STATES = new Set<RecognitionState>([
|
|||
RecognitionState.DESTROYED
|
||||
])
|
||||
|
||||
// ── 실시간 부분 전사(미리보기) ──
|
||||
// 16kHz 16bit mono = 32 bytes/ms
|
||||
const BYTES_PER_MS = 32
|
||||
/** 부분 전사 주기 */
|
||||
const PARTIAL_INTERVAL_MS = 1500
|
||||
/** 부분 전사를 시작할 최소 녹음 길이 */
|
||||
const PARTIAL_MIN_AUDIO_MS = 1200
|
||||
/** 부분 전사에 보낼 최대 오디오 창(끝부분만) — 오래 말해도 지연이 늘지 않게 한다 */
|
||||
const PARTIAL_MAX_WINDOW_MS = 7500
|
||||
/** 녹음 종료 시 진행 중 부분 전사를 기다리는 최대 시간 */
|
||||
const PARTIAL_DRAIN_TIMEOUT_MS = 2500
|
||||
|
||||
// ============================================================
|
||||
// VoiceModeService
|
||||
// ============================================================
|
||||
|
|
@ -123,6 +136,10 @@ class VoiceModeService extends EventEmitter {
|
|||
/** 녹음 종료 후 STT 준비 대기 타이머 — 전사 시작 시 반드시 해제 */
|
||||
private _sttWaitTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 실시간 부분 전사(미리보기)
|
||||
private _partialTimer: NodeJS.Timeout | null = null
|
||||
private _partialInFlight: Promise<void> | null = null
|
||||
|
||||
// Action Queue (이벤트 직렬화)
|
||||
private _actionQueue: VoiceAction[] = []
|
||||
private _isProcessingQueue = false
|
||||
|
|
@ -462,6 +479,8 @@ class VoiceModeService extends EventEmitter {
|
|||
this._setAudioState(AudioState.STREAMING)
|
||||
logger.info('Audio capture started')
|
||||
|
||||
this._startPartialLoop()
|
||||
|
||||
this._tryFlushAll()
|
||||
} catch (error) {
|
||||
if (this._isInTerminalState()) return
|
||||
|
|
@ -488,6 +507,7 @@ class VoiceModeService extends EventEmitter {
|
|||
this._audioLevelHandler = null
|
||||
}
|
||||
this._audioStarted = false
|
||||
this._stopPartialLoop()
|
||||
|
||||
try {
|
||||
await audio.stop()
|
||||
|
|
@ -496,6 +516,73 @@ class VoiceModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 실시간 부분 전사(미리보기) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 녹음 중 주기적으로 지금까지의 오디오를 전사해 RecordingTip에 미리보기를 띄운다.
|
||||
* 최종 삽입 텍스트와는 완전히 분리된 경로이며, 실패는 조용히 무시된다.
|
||||
*/
|
||||
private _startPartialLoop(): void {
|
||||
this._stopPartialLoop()
|
||||
if ((configGet('sttProvider') ?? 'local') !== 'local') return
|
||||
|
||||
this._partialTimer = setInterval(() => {
|
||||
void this._runPartial()
|
||||
}, PARTIAL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private _stopPartialLoop(): void {
|
||||
if (this._partialTimer) {
|
||||
clearInterval(this._partialTimer)
|
||||
this._partialTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 진행 중인 부분 전사가 끝나기를 최대 PARTIAL_DRAIN_TIMEOUT_MS까지 기다린다. */
|
||||
private async _drainPartial(): Promise<void> {
|
||||
const inFlight = this._partialInFlight
|
||||
if (!inFlight) return
|
||||
await Promise.race([
|
||||
inFlight,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, PARTIAL_DRAIN_TIMEOUT_MS)),
|
||||
])
|
||||
}
|
||||
|
||||
private async _runPartial(): Promise<void> {
|
||||
if (!this._audioStarted || this._partialInFlight) return
|
||||
if (this._isInTerminalState()) return
|
||||
if (!this._sttReady) return
|
||||
if (this._audioBufferBytes < PARTIAL_MIN_AUDIO_MS * BYTES_PER_MS) return
|
||||
|
||||
const sessionId = this._session?.id
|
||||
const merged = Buffer.concat(this._audioBuffer)
|
||||
const maxBytes = PARTIAL_MAX_WINDOW_MS * BYTES_PER_MS
|
||||
const window = merged.length > maxBytes ? merged.subarray(merged.length - maxBytes) : merged
|
||||
const language = configGet('sttLanguage')
|
||||
|
||||
const task = (async (): Promise<void> => {
|
||||
try {
|
||||
const text = await getSTTManager().transcribePartial(window, {
|
||||
language: language === 'auto' ? undefined : language,
|
||||
vadFilter: false,
|
||||
})
|
||||
// 녹음이 끝났거나 세션이 바뀌었으면 미리보기를 버린다.
|
||||
if (!this._audioStarted || this._isInTerminalState()) return
|
||||
if (this._session?.id !== sessionId) return
|
||||
if (!text) return
|
||||
sendPartialTranscriptToTip(text)
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`부분 전사 미리보기 무시: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
} finally {
|
||||
this._partialInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
this._partialInFlight = task
|
||||
}
|
||||
|
||||
// ── 이중 조건 플러시 ───────────────────────────────────
|
||||
|
||||
private _tryFlushAll(): void {
|
||||
|
|
@ -553,6 +640,10 @@ class VoiceModeService extends EventEmitter {
|
|||
// DictionaryService 미초기화 시 무시
|
||||
}
|
||||
|
||||
// 사이드카는 요청을 직렬 처리하므로, 진행 중인 미리보기 요청이 최종 전사를
|
||||
// 지연시키지 않도록 먼저 배수한다(최대 PARTIAL_DRAIN_TIMEOUT_MS).
|
||||
await this._drainPartial()
|
||||
|
||||
const result: TranscriptionResult = await stt.transcribe(merged, {
|
||||
language: language === 'auto' ? undefined : language,
|
||||
initialPrompt,
|
||||
|
|
@ -864,6 +955,8 @@ class VoiceModeService extends EventEmitter {
|
|||
|
||||
private _resetToIdle(): void {
|
||||
this._clearSttWaitTimer()
|
||||
this._stopPartialLoop()
|
||||
this._partialInFlight = null
|
||||
this._session = null
|
||||
this._audioBuffer = []
|
||||
this._audioBufferBytes = 0
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { AssemblyAIDriver } from './drivers/AssemblyAIDriver'
|
|||
import { GoogleDriver } from './drivers/GoogleDriver'
|
||||
import { CustomDriver } from './drivers/CustomDriver'
|
||||
import { D3ROCloudDriver } from './drivers/D3ROCloudDriver'
|
||||
import { normalizeLoopbackUrl } from '../../utils/loopback'
|
||||
|
||||
const logger = getLogger('STTManager')
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [
|
|||
badge: 'Cloud · Zero Config',
|
||||
requiresApiKey: false,
|
||||
defaultModel: 'default',
|
||||
defaultBaseUrl: 'http://localhost:5000',
|
||||
defaultBaseUrl: 'http://127.0.0.1:5000',
|
||||
models: ['default', 'whisper-large-v3-turbo', 'nova-3', 'gemini-2.0-flash'],
|
||||
isCloud: true,
|
||||
},
|
||||
|
|
@ -109,7 +110,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [
|
|||
badge: 'Self-Hosted / Proxy',
|
||||
requiresApiKey: false,
|
||||
defaultModel: 'whisper-1',
|
||||
defaultBaseUrl: 'http://localhost:8000/v1',
|
||||
defaultBaseUrl: 'http://127.0.0.1:8000/v1',
|
||||
models: ['whisper-1', 'custom'],
|
||||
isCloud: true,
|
||||
},
|
||||
|
|
@ -155,7 +156,8 @@ export class STTManager extends EventEmitter {
|
|||
|
||||
return {
|
||||
apiKey: specificConfig.apiKey ?? '',
|
||||
baseUrl: specificConfig.baseUrl ?? meta?.defaultBaseUrl ?? '',
|
||||
// 저장된 값이 localhost일 수 있다(IPv6 해석 실패) → IPv4 루프백으로 정규화.
|
||||
baseUrl: normalizeLoopbackUrl(specificConfig.baseUrl ?? meta?.defaultBaseUrl ?? ''),
|
||||
modelId: specificConfig.modelId ?? meta?.defaultModel ?? '',
|
||||
temperature: specificConfig.temperature ?? 0,
|
||||
}
|
||||
|
|
@ -245,6 +247,25 @@ export class STTManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 중 실시간 미리보기 전사(최종 삽입과 무관).
|
||||
* 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.
|
||||
* 실패는 빈 문자열로 흡수된다.
|
||||
*/
|
||||
async transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise<string> {
|
||||
if (this.getActiveProvider() !== 'local') return ''
|
||||
return getLocalSTTService().transcribePartial(audioBuffer, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 STT 엔진(sidecar + 모델)을 백그라운드로 미리 데운다.
|
||||
* 첫 받아쓰기 지연을 없애는 것이 목적이며 실패해도 조용히 넘어간다.
|
||||
*/
|
||||
async warmUpLocal(): Promise<boolean> {
|
||||
if (this.getActiveProvider() !== 'local') return false
|
||||
return getLocalSTTService().warmUp()
|
||||
}
|
||||
|
||||
getStatus(): STTStatus {
|
||||
const provider = this.getActiveProvider()
|
||||
if (provider === 'local') {
|
||||
|
|
|
|||
39
apps/desktop/src/main/utils/loopback.ts
Normal file
39
apps/desktop/src/main/utils/loopback.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// src/main/utils/loopback.ts
|
||||
// 로컬 엔진(Ollama, STT sidecar) URL 정규화.
|
||||
//
|
||||
// 배경: Windows 호스트 파일에 `::1 localhost`만 있고 `127.0.0.1 localhost`가 없으면
|
||||
// localhost가 IPv6(::1)로만 해석된다. Ollama/uvicorn은 IPv4(127.0.0.1)에만 바인딩하므로
|
||||
// `http://localhost:<port>` 요청이 전부 ECONNREFUSED로 실패한다(실측).
|
||||
// 로컬 엔진은 바인딩 주소가 IPv4 루프백으로 고정이므로 항상 127.0.0.1로 정규화한다.
|
||||
|
||||
/** IPv4 루프백으로 정규화할 호스트 이름 */
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', 'localhost.'])
|
||||
|
||||
/** 로컬 엔진 기본 호스트 */
|
||||
export const LOOPBACK_HOST = '127.0.0.1'
|
||||
|
||||
/**
|
||||
* URL의 호스트가 localhost 계열이면 127.0.0.1로 바꾼다.
|
||||
* 그 외 호스트/잘못된 URL은 원본을 그대로 반환한다.
|
||||
*/
|
||||
export function normalizeLoopbackUrl(rawUrl: string): string {
|
||||
if (!rawUrl) return rawUrl
|
||||
|
||||
try {
|
||||
const parsed = new URL(rawUrl)
|
||||
if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase())) {
|
||||
return rawUrl
|
||||
}
|
||||
parsed.hostname = LOOPBACK_HOST
|
||||
// URL 직렬화는 경로가 없을 때 '/'를 붙인다. 호출측이 `${base}/api/...`로
|
||||
// 이어 붙이므로 말미 슬래시는 제거해 중복 래시를 막는다.
|
||||
return parsed.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return rawUrl.replace(/^(https?:\/\/)localhost(?=[:/]|$)/i, `$1${LOOPBACK_HOST}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 루프백 호스트로 접속 가능한 로컬 엔진 기본 URL을 만든다. */
|
||||
export function loopbackUrl(port: number, protocol = 'http'): string {
|
||||
return `${protocol}://${LOOPBACK_HOST}:${port}`
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { loopbackUrl } from './loopback'
|
||||
|
||||
/** Windows는 .exe 접미사, 그 외는 없음 */
|
||||
const EXE_SUFFIX = process.platform === 'win32' ? '.exe' : ''
|
||||
|
|
@ -17,33 +19,112 @@ function isPackaged(): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* SoX 실행 파일 경로. 번들된 게 있으면 그것, 없으면 시스템 PATH의 sox.
|
||||
* - Windows: sox.exe
|
||||
* - macOS/Linux: sox (brew install sox / apt install sox 필요)
|
||||
* dev 실행 시 리소스 루트(apps/desktop)를 찾는다.
|
||||
*
|
||||
* electron-vite는 electron을 `out/main/index.js`로 직접 띄우기 때문에
|
||||
* `app.getAppPath()`가 `apps/desktop/out/main`을 가리킨다. 그대로 쓰면
|
||||
* `out/main/sidecar/main.py`, `out/main/resources/sox` 같은 존재하지 않는 경로가
|
||||
* 만들어져 sidecar/SoX가 조용히 시스템 PATH 폴백으로 새고 로컬 전사가 실패한다(실측).
|
||||
* 따라서 상위 디렉토리를 훑어 실제 앱 루트를 찾아 캐시한다.
|
||||
*/
|
||||
export function getSoxPath(): string {
|
||||
const soxBin = `sox${EXE_SUFFIX}`
|
||||
const bundledSox = isPackaged()
|
||||
? path.join(process.resourcesPath, 'sox', soxBin)
|
||||
: path.join(app.getAppPath(), 'resources', 'sox', soxBin)
|
||||
const APP_ROOT_MARKERS = [
|
||||
path.join('sidecar', 'main.py'),
|
||||
path.join('resources', 'sox'),
|
||||
'electron-builder.yml',
|
||||
]
|
||||
|
||||
if (existsSync(bundledSox)) {
|
||||
return bundledSox
|
||||
const MAX_ROOT_WALK_UP = 4
|
||||
|
||||
let cachedAppRoot: string | null = null
|
||||
|
||||
function looksLikeAppRoot(dir: string): boolean {
|
||||
return APP_ROOT_MARKERS.some((marker) => existsSync(path.join(dir, marker)))
|
||||
}
|
||||
|
||||
/** 리소스 루트(apps/desktop)를 반환한다. dev에서 못 찾으면 app.getAppPath(). */
|
||||
export function getAppRoot(): string {
|
||||
if (cachedAppRoot) return cachedAppRoot
|
||||
|
||||
const bases: string[] = [app.getAppPath(), process.cwd()]
|
||||
// electron-vite는 main 번들을 CJS로 내보내므로 __dirname 사용 가능.
|
||||
if (typeof __dirname === 'string' && __dirname) {
|
||||
bases.push(__dirname)
|
||||
}
|
||||
|
||||
// 번들 없으면 시스템 PATH에서 찾기 (dev 환경 폴백)
|
||||
return 'sox'
|
||||
for (const base of bases) {
|
||||
let dir = base
|
||||
for (let step = 0; step <= MAX_ROOT_WALK_UP; step++) {
|
||||
if (looksLikeAppRoot(dir)) {
|
||||
cachedAppRoot = dir
|
||||
return dir
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
cachedAppRoot = app.getAppPath()
|
||||
return cachedAppRoot
|
||||
}
|
||||
|
||||
/** 테스트에서 경로 캐시를 초기화한다. */
|
||||
export function resetPathCache(): void {
|
||||
cachedAppRoot = null
|
||||
cachedSoxPath = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* rec 실행 파일 경로 (SoX의 녹음 명령).
|
||||
* 번들 리소스의 dev 경로.
|
||||
* packaged → process.resourcesPath/<name>, dev → <앱 루트>/resources/<name>
|
||||
*/
|
||||
function devResourcePath(...segments: string[]): string {
|
||||
return path.join(getAppRoot(), 'resources', ...segments)
|
||||
}
|
||||
|
||||
function packagedResourcePath(...segments: string[]): string {
|
||||
return path.join(process.resourcesPath, ...segments)
|
||||
}
|
||||
|
||||
let cachedSoxPath: string | undefined
|
||||
|
||||
/**
|
||||
* SoX 실행 파일 경로. 번들된 실행 파일을 우선 사용한다.
|
||||
* - packaged: resources/sox/sox(.exe)
|
||||
* - dev: <앱 루트>/resources/sox/sox(.exe)
|
||||
* 번들이 없으면 시스템 PATH의 `sox`로 폴백한다(설치 안내는 호출측에서 처리).
|
||||
*/
|
||||
export function getSoxPath(): string {
|
||||
if (cachedSoxPath !== undefined) return cachedSoxPath
|
||||
|
||||
const soxBin = `sox${EXE_SUFFIX}`
|
||||
const candidates = [
|
||||
isPackaged()
|
||||
? packagedResourcePath('sox', soxBin)
|
||||
: devResourcePath('sox', soxBin),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedSoxPath = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 번들 없으면 시스템 PATH에서 찾기 (dev 환경 폴백)
|
||||
cachedSoxPath = 'sox'
|
||||
return cachedSoxPath
|
||||
}
|
||||
|
||||
/**
|
||||
* rec 실행 파일 경로 (SoX의 음 명령).
|
||||
* node-record-lpcm16은 rec를 사용한다.
|
||||
*/
|
||||
export function getRecPath(): string {
|
||||
const recBin = `rec${EXE_SUFFIX}`
|
||||
const bundledRec = isPackaged()
|
||||
? path.join(process.resourcesPath, 'sox', recBin)
|
||||
: path.join(app.getAppPath(), 'resources', 'sox', recBin)
|
||||
? packagedResourcePath('sox', recBin)
|
||||
: devResourcePath('sox', recBin)
|
||||
|
||||
if (existsSync(bundledRec)) {
|
||||
return bundledRec
|
||||
|
|
@ -52,52 +133,75 @@ export function getRecPath(): string {
|
|||
return 'rec'
|
||||
}
|
||||
|
||||
/** sidecar 실행 방법 */
|
||||
export interface SidecarLaunch {
|
||||
command: string
|
||||
args: string[]
|
||||
/** 어디에서 결정되었는지 (로그/진단용) */
|
||||
source: 'bundled' | 'venv' | 'python'
|
||||
}
|
||||
|
||||
/**
|
||||
* STT sidecar 실행 경로.
|
||||
* - dev: sidecar/.venv/bin/python (있으면) + sidecar/main.py, 없으면 시스템 python3
|
||||
* - production: sidecar/sidecar(.exe) (PyInstaller 빌드)
|
||||
* - packaged: resources/sidecar/sidecar(.exe) — 없으면 명확한 에러 (조용한 폴백 금지)
|
||||
* - dev: sidecar/.venv python + sidecar/main.py (없으면 시스템 python 폴백)
|
||||
*/
|
||||
export function getSidecarCommand(): { command: string; args: string[] } {
|
||||
export function getSidecarCommand(): SidecarLaunch {
|
||||
const sidecarBin = `sidecar${EXE_SUFFIX}`
|
||||
|
||||
if (isPackaged()) {
|
||||
const exePath = path.join(process.resourcesPath, 'sidecar', sidecarBin)
|
||||
const exePath = packagedResourcePath('sidecar', sidecarBin)
|
||||
if (existsSync(exePath)) {
|
||||
return { command: exePath, args: [] }
|
||||
return { command: exePath, args: [], source: 'bundled' }
|
||||
}
|
||||
// PyInstaller 번들 실패 대비 폴백
|
||||
const pyPath = path.join(process.resourcesPath, 'sidecar', 'main.py')
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||
return { command: pythonCmd, args: [pyPath] }
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`번들된 STT 사이드카를 찾을 수 없습니다: ${exePath}. ` +
|
||||
'설치 패키지에 sidecar 리소스가 누락되었습니다(로컬 전사 불가). ' +
|
||||
'앱을 다시 설치하거나 개발 모드에서 `npm run sidecar:build`로 빌드하세요.',
|
||||
)
|
||||
}
|
||||
|
||||
// dev: venv 우선 → 없으면 시스템 python
|
||||
const sidecarDir = path.join(app.getAppPath(), 'sidecar')
|
||||
const sidecarDir = path.join(getAppRoot(), 'sidecar')
|
||||
const sidecarPath = path.join(sidecarDir, 'main.py')
|
||||
|
||||
if (!existsSync(sidecarPath)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`STT 사이드카 소스를 찾을 수 없습니다: ${sidecarPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
const venvPython =
|
||||
process.platform === 'win32'
|
||||
? path.join(sidecarDir, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(sidecarDir, '.venv', 'bin', 'python3')
|
||||
|
||||
if (existsSync(venvPython)) {
|
||||
return { command: venvPython, args: [sidecarPath] }
|
||||
return { command: venvPython, args: [sidecarPath], source: 'venv' }
|
||||
}
|
||||
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||
return { command: pythonCmd, args: [sidecarPath] }
|
||||
return { command: pythonCmd, args: [sidecarPath], source: 'python' }
|
||||
}
|
||||
|
||||
/** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */
|
||||
export function getSidecarBaseUrl(port: number): string {
|
||||
return loopbackUrl(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들된 Ollama 실행 파일 경로. 존재하지 않으면 null을 반환해 시스템 설치본 탐색으로 폴백.
|
||||
* - Windows: ollama.exe
|
||||
* - macOS/Linux: ollama
|
||||
* (Ollama 탐색은 LocalLLMService 참조)
|
||||
*/
|
||||
export function getBundledOllamaPath(): string | null {
|
||||
const ollamaBin = `ollama${EXE_SUFFIX}`
|
||||
const bundled = isPackaged()
|
||||
? path.join(process.resourcesPath, 'ollama', ollamaBin)
|
||||
: path.join(app.getAppPath(), 'resources', 'ollama', ollamaBin)
|
||||
? packagedResourcePath('ollama', ollamaBin)
|
||||
: devResourcePath('ollama', ollamaBin)
|
||||
|
||||
return existsSync(bundled) ? bundled : null
|
||||
}
|
||||
|
|
@ -106,35 +210,46 @@ export function getBundledOllamaPath(): string | null {
|
|||
* 효과음 파일 경로.
|
||||
*/
|
||||
export function getSoundPath(filename: string): string {
|
||||
if (isPackaged()) {
|
||||
return path.join(process.resourcesPath, 'sounds', filename)
|
||||
}
|
||||
return path.join(app.getAppPath(), 'resources', 'sounds', filename)
|
||||
return isPackaged()
|
||||
? packagedResourcePath('sounds', filename)
|
||||
: devResourcePath('sounds', filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg 실행 파일 경로.
|
||||
* - dev: @ffmpeg-installer/ffmpeg의 node_modules 경로 (플랫폼별 자동)
|
||||
* - production: extraResources로 번들된 경로
|
||||
* 1) extraResources로 번들된 resources/ffmpeg/ffmpeg(.exe)
|
||||
* 2) @ffmpeg-installer/ffmpeg npm 패키지(플랫폼별 정적 바이너리)
|
||||
* 3) 시스템 PATH의 ffmpeg
|
||||
*/
|
||||
export function getFfmpegPath(): string {
|
||||
const ffmpegBin = `ffmpeg${EXE_SUFFIX}`
|
||||
|
||||
if (isPackaged()) {
|
||||
const bundled = path.join(process.resourcesPath, 'ffmpeg', ffmpegBin)
|
||||
if (existsSync(bundled)) {
|
||||
return bundled
|
||||
}
|
||||
const bundled = isPackaged()
|
||||
? packagedResourcePath('ffmpeg', ffmpegBin)
|
||||
: devResourcePath('ffmpeg', ffmpegBin)
|
||||
|
||||
if (existsSync(bundled)) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
// dev: @ffmpeg-installer/ffmpeg에서 제공하는 경로 (플랫폼별 자동 선택)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const installer = require('@ffmpeg-installer/ffmpeg')
|
||||
return installer.path as string
|
||||
const installer = require('@ffmpeg-installer/ffmpeg') as { path?: string }
|
||||
const installerPath = installer.path
|
||||
if (installerPath) {
|
||||
// asar 내부 경로는 실행 파일로 쓸 수 없다 → unpacked 경로로 치환
|
||||
const unpacked = installerPath.replace(
|
||||
`${path.sep}app.asar${path.sep}`,
|
||||
`${path.sep}app.asar.unpacked${path.sep}`,
|
||||
)
|
||||
if (existsSync(unpacked)) return unpacked
|
||||
if (existsSync(installerPath)) return installerPath
|
||||
}
|
||||
} catch {
|
||||
return 'ffmpeg'
|
||||
// 설치 패키지 없음 → 시스템 PATH 폴백
|
||||
}
|
||||
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -161,7 +276,7 @@ export function getWhisperModelsDir(): string {
|
|||
export function getAppIconPath(): string | null {
|
||||
const filename = process.platform === 'win32' ? 'icon.ico' : 'icon.png'
|
||||
const candidate = isPackaged()
|
||||
? path.join(process.resourcesPath, 'icons', filename)
|
||||
: path.join(app.getAppPath(), 'build', filename)
|
||||
? packagedResourcePath('icons', filename)
|
||||
: path.join(getAppRoot(), 'build', filename)
|
||||
return existsSync(candidate) ? candidate : null
|
||||
}
|
||||
}
|
||||
|
|
@ -383,4 +383,44 @@ describe('STTManager & Multi-provider Drivers', () => {
|
|||
expect(result.text).toBe('로컬 Whisper 폴백 성공')
|
||||
})
|
||||
})
|
||||
|
||||
describe('STTManager Live Partial (미리보기)', () => {
|
||||
it('routes partial transcription through the local engine', async () => {
|
||||
const mgr = getSTTManager()
|
||||
mgr.setProvider('local')
|
||||
|
||||
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
|
||||
const partialSpy = vi
|
||||
.spyOn(getLocalSTTService(), 'transcribePartial')
|
||||
.mockResolvedValue('미리보기 텍스트')
|
||||
|
||||
await expect(mgr.transcribePartial(Buffer.alloc(32000))).resolves.toBe('미리보기 텍스트')
|
||||
expect(partialSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call the local engine for cloud providers', async () => {
|
||||
const mgr = getSTTManager()
|
||||
mgr.setProvider('groq')
|
||||
mgr.setProviderConfig('groq', { apiKey: 'gsk-test' })
|
||||
|
||||
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
|
||||
const partialSpy = vi.spyOn(getLocalSTTService(), 'transcribePartial')
|
||||
|
||||
await expect(mgr.transcribePartial(Buffer.alloc(32000))).resolves.toBe('')
|
||||
expect(partialSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warms up the local engine only for the local provider', async () => {
|
||||
const mgr = getSTTManager()
|
||||
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
|
||||
const warmSpy = vi.spyOn(getLocalSTTService(), 'warmUp').mockResolvedValue(true)
|
||||
|
||||
mgr.setProvider('local')
|
||||
await expect(mgr.warmUpLocal()).resolves.toBe(true)
|
||||
|
||||
mgr.setProvider('deepgram')
|
||||
await expect(mgr.warmUpLocal()).resolves.toBe(false)
|
||||
expect(warmSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
54
apps/desktop/tests/main/utils/loopback.test.ts
Normal file
54
apps/desktop/tests/main/utils/loopback.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// tests/main/utils/loopback.test.ts
|
||||
// 로컬 엔진 URL 정규화 테스트.
|
||||
// Windows에서 localhost가 ::1로만 해석되어 Ollama/sidecar 연결이 실패했던 회귀를 고정한다.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { loopbackUrl, normalizeLoopbackUrl } from '../../../src/main/utils/loopback'
|
||||
|
||||
describe('normalizeLoopbackUrl', () => {
|
||||
it('rewrites a bare localhost host to the IPv4 loopback', () => {
|
||||
expect(normalizeLoopbackUrl('http://localhost:11434')).toBe('http://127.0.0.1:11434')
|
||||
})
|
||||
|
||||
it('keeps the port and path', () => {
|
||||
expect(normalizeLoopbackUrl('http://localhost:8000/v1')).toBe('http://127.0.0.1:8000/v1')
|
||||
})
|
||||
|
||||
it('handles https and trailing dot host forms', () => {
|
||||
expect(normalizeLoopbackUrl('https://localhost:5000/health')).toBe(
|
||||
'https://127.0.0.1:5000/health',
|
||||
)
|
||||
expect(normalizeLoopbackUrl('http://localhost.:1234')).toBe('http://127.0.0.1:1234')
|
||||
})
|
||||
|
||||
it('leaves remote hosts untouched', () => {
|
||||
expect(normalizeLoopbackUrl('https://api.openai.com/v1')).toBe('https://api.openai.com/v1')
|
||||
expect(normalizeLoopbackUrl('http://192.168.0.10:11434')).toBe('http://192.168.0.10:11434')
|
||||
expect(normalizeLoopbackUrl('http://127.0.0.1:11434')).toBe('http://127.0.0.1:11434')
|
||||
})
|
||||
|
||||
it('does not treat lookalike hostnames as loopback', () => {
|
||||
expect(normalizeLoopbackUrl('http://localhost.evil.com')).toBe('http://localhost.evil.com')
|
||||
})
|
||||
|
||||
it('falls back to a textual rewrite when the URL is not parseable', () => {
|
||||
// 잘못된 포트는 URL 파서가 던진다 → 정규식 폴백 경로를 탄다.
|
||||
expect(normalizeLoopbackUrl('http://localhost:99999999')).toBe(
|
||||
'http://127.0.0.1:99999999',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through inputs without a recognizable host', () => {
|
||||
expect(normalizeLoopbackUrl('localhost:11434')).toBe('localhost:11434')
|
||||
})
|
||||
|
||||
it('returns empty input unchanged', () => {
|
||||
expect(normalizeLoopbackUrl('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loopbackUrl', () => {
|
||||
it('builds an IPv4 loopback URL for a port', () => {
|
||||
expect(loopbackUrl(18765)).toBe('http://127.0.0.1:18765')
|
||||
})
|
||||
})
|
||||
97
apps/desktop/tests/main/utils/paths.test.ts
Normal file
97
apps/desktop/tests/main/utils/paths.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// tests/main/utils/paths.test.ts
|
||||
// dev/packaged 경로 해석 테스트.
|
||||
//
|
||||
// 회귀 배경: electron-vite dev에서 app.getAppPath()가 `out/main`을 가리켜
|
||||
// sidecar/SoX가 존재하지 않는 경로로 잡혔고, 결과적으로 로컬 전사와 녹음이
|
||||
// 모두 실패했다(시스템 python/PATH 폴백). 여기서 그 해석을 고정한다.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
getAppRoot,
|
||||
getSidecarCommand,
|
||||
getSidecarBaseUrl,
|
||||
getSoxPath,
|
||||
resetPathCache,
|
||||
} from '../../../src/main/utils/paths'
|
||||
|
||||
const desktopDir = path.resolve(__dirname, '..', '..', '..')
|
||||
|
||||
describe('paths (dev)', () => {
|
||||
beforeEach(() => {
|
||||
resetPathCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
resetPathCache()
|
||||
})
|
||||
|
||||
it('resolves the app root a level above the vite out/main bundle', () => {
|
||||
// dev에서 app.getAppPath() = <desktop>/out/main 이다.
|
||||
vi.mocked(app.getAppPath).mockReturnValue(path.join(desktopDir, 'out', 'main'))
|
||||
resetPathCache()
|
||||
|
||||
expect(getAppRoot()).toBe(desktopDir)
|
||||
})
|
||||
|
||||
it('prefers the bundled SoX binary over the system PATH', () => {
|
||||
expect(getSoxPath()).toContain(path.join('resources', 'sox', 'sox'))
|
||||
expect(existsSync(getSoxPath())).toBe(true)
|
||||
expect(getSoxPath()).not.toBe('sox')
|
||||
})
|
||||
|
||||
it('uses the sidecar virtualenv python when it exists', () => {
|
||||
const launch = getSidecarCommand()
|
||||
|
||||
expect(launch.source).toBe('venv')
|
||||
expect(launch.command).toContain('.venv')
|
||||
expect(launch.args[0]).toBe(path.join(desktopDir, 'sidecar', 'main.py'))
|
||||
expect(existsSync(launch.command)).toBe(true)
|
||||
})
|
||||
|
||||
it('builds the sidecar base URL on the IPv4 loopback', () => {
|
||||
expect(getSidecarBaseUrl(18765)).toBe('http://127.0.0.1:18765')
|
||||
})
|
||||
})
|
||||
|
||||
describe('paths (packaged)', () => {
|
||||
beforeEach(() => {
|
||||
resetPathCache()
|
||||
Object.defineProperty(app, 'isPackaged', { value: true, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(app, 'isPackaged', { value: false, configurable: true })
|
||||
vi.restoreAllMocks()
|
||||
resetPathCache()
|
||||
})
|
||||
|
||||
it('fails loudly when the packaged sidecar bundle is missing', () => {
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
value: path.join(desktopDir, 'definitely-not-bundled'),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(() => getSidecarCommand()).toThrowError(/사이드카를 찾을 수 없습니다/)
|
||||
})
|
||||
|
||||
it('uses the packaged sidecar executable when present', () => {
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
value: path.join(desktopDir, 'resources'),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// resources/sox 는 커밋되어 있으므로 resources/sidecar/sidecar(.exe)도
|
||||
// 같은 방식으로 배치된다. 존재하지 않는 플랫폼이면 번들 실패로 처리된다.
|
||||
const exeSuffix = process.platform === 'win32' ? '.exe' : ''
|
||||
const bundled = path.join(desktopDir, 'resources', 'sidecar', `sidecar${exeSuffix}`)
|
||||
if (existsSync(bundled)) {
|
||||
expect(getSidecarCommand().source).toBe('bundled')
|
||||
} else {
|
||||
expect(() => getSidecarCommand()).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue