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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue