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