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:
Yun Chan 2026-09-18 00:48:47 +09:00
parent 359b244dc9
commit 2d585bfc29
52 changed files with 1450 additions and 3861 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}`
}

View file

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