fix(stt): gate local fallback on installed engine and extract SidecarSupervisor
This commit is contained in:
parent
4588b65dfa
commit
2cd462333f
7 changed files with 1057 additions and 343 deletions
|
|
@ -4,14 +4,14 @@
|
||||||
// 상태 머신 및 이중 조건 플러시 패턴 적용.
|
// 상태 머신 및 이중 조건 플러시 패턴 적용.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { type ChildProcess, spawn } from 'child_process'
|
|
||||||
import { createServer } from 'net'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
||||||
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
||||||
|
import { SidecarSupervisor, type SidecarSupervisorEvents } from './stt/SidecarSupervisor'
|
||||||
|
import { isSidecarLaunchAvailableOffline, resolveSidecarLaunch } from './stt/sidecarLaunch'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type {
|
import type {
|
||||||
STTModel,
|
STTModel,
|
||||||
|
|
@ -59,13 +59,12 @@ export interface TranscribeOptions {
|
||||||
modelId?: string
|
modelId?: string
|
||||||
/** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */
|
/** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */
|
||||||
retriedAfterLoad?: boolean
|
retriedAfterLoad?: boolean
|
||||||
}
|
/**
|
||||||
|
* 모델이 아직 올라가 있지 않을 때, 모델 파일과 엔진이 이미 로컬에 있어야만 초기화한다.
|
||||||
/** sidecar /health 응답 */
|
* 없으면 내려받기(런타임 ~100MB, 모델 최대 수 GB)를 시작하지 않고 즉시 실패한다.
|
||||||
interface HealthResponse {
|
* 클라우드 실패 → 로컬 폴백처럼 "지금 바로 쓸 수 있을 때만" 로컬을 쓰는 경로용.
|
||||||
status: string
|
*/
|
||||||
model: string | null
|
requireInstalled?: boolean
|
||||||
gpu: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** sidecar /load 응답 */
|
/** sidecar /load 응답 */
|
||||||
|
|
@ -120,10 +119,6 @@ export interface LocalSTTEvents {
|
||||||
|
|
||||||
// ── 상수 ──────────────────────────────────────────────────
|
// ── 상수 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const SIDECAR_PORT = 18765
|
|
||||||
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_REQUEST_TIMEOUT_MS = 120000
|
||||||
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
|
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
|
||||||
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
|
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
|
||||||
|
|
@ -190,9 +185,29 @@ const MODEL_CATALOG: STTModel[] = [
|
||||||
|
|
||||||
const logger = getLogger('LocalSTTService')
|
const logger = getLogger('LocalSTTService')
|
||||||
|
|
||||||
|
/** 테스트/조립용 의존성 — 기본값은 실제 사이드카 감독자 */
|
||||||
|
export interface LocalSTTServiceDeps {
|
||||||
|
supervisor?: SidecarSupervisor
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultSupervisor(): SidecarSupervisor {
|
||||||
|
return new SidecarSupervisor({
|
||||||
|
resolveLaunch: resolveSidecarLaunch,
|
||||||
|
modelsDir: getWhisperModelsDir,
|
||||||
|
baseUrlFor: getSidecarBaseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
class LocalSTTService extends EventEmitter {
|
class LocalSTTService extends EventEmitter {
|
||||||
constructor() {
|
/** 사이드카 프로세스 수명주기(기동·헬스·크래시 재시작·종료) 담당 */
|
||||||
|
private readonly _supervisor: SidecarSupervisor
|
||||||
|
|
||||||
|
constructor(deps: LocalSTTServiceDeps = {}) {
|
||||||
super()
|
super()
|
||||||
|
this._supervisor = deps.supervisor ?? createDefaultSupervisor()
|
||||||
|
this._supervisor.on('exit', () => this._onSidecarExit())
|
||||||
|
this._supervisor.on('crashed', (payload) => this._onSidecarCrashed(payload))
|
||||||
|
this._supervisor.on('gave-up', ({ error }) => this._onSidecarGaveUp(error))
|
||||||
// EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다
|
// EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다
|
||||||
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
|
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
|
||||||
// 실제 로깅은 _emitError에서 수행.
|
// 실제 로깅은 _emitError에서 수행.
|
||||||
|
|
@ -204,15 +219,10 @@ class LocalSTTService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _state: STTState = STTState.Uninitialized
|
private _state: STTState = STTState.Uninitialized
|
||||||
private _sidecarProcess: ChildProcess | null = null
|
|
||||||
private _sidecarStarting: Promise<void> | null = null
|
|
||||||
private _port: number = SIDECAR_PORT
|
|
||||||
private _currentModelId: string | null = null
|
private _currentModelId: string | null = null
|
||||||
/** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */
|
/** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */
|
||||||
private _auxModelId: string | null = null
|
private _auxModelId: string | null = null
|
||||||
private _restartCount: number = 0
|
|
||||||
private _disposed: boolean = false
|
private _disposed: boolean = false
|
||||||
private _gpuAccelerated: boolean = false
|
|
||||||
|
|
||||||
// ── 준비 상태 ──
|
// ── 준비 상태 ──
|
||||||
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
|
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
|
||||||
|
|
@ -235,9 +245,9 @@ class LocalSTTService extends EventEmitter {
|
||||||
return this._currentModelId
|
return this._currentModelId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** sidecar HTTP 기본 URL — IPv4 루프백 고정 (localhost는 ::1로 해석되어 실패) */
|
/** sidecar HTTP 기본 URL — IPv4 루프백 고정, 포트는 감독자가 동적으로 정한다 */
|
||||||
private get _baseUrl(): string {
|
private get _baseUrl(): string {
|
||||||
return getSidecarBaseUrl(this._port)
|
return this._supervisor.baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 공개 메서드 ──
|
// ── 공개 메서드 ──
|
||||||
|
|
@ -290,14 +300,16 @@ class LocalSTTService extends EventEmitter {
|
||||||
this._errorEmitted = false
|
this._errorEmitted = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// sidecar가 아직 실행 중이 아니면 시작
|
// sidecar가 헬스를 통과할 때까지 기다린다 (필요하면 기동)
|
||||||
await this._ensureSidecarRunning()
|
await this._supervisor.ready()
|
||||||
|
|
||||||
// 모델 로딩
|
// 모델 로딩
|
||||||
await this._loadModel(targetModel)
|
await this._loadModel(targetModel)
|
||||||
this._currentModelId = targetModel
|
this._currentModelId = targetModel
|
||||||
this._modelReady = true
|
this._modelReady = true
|
||||||
this._setState(STTState.Ready)
|
this._setState(STTState.Ready)
|
||||||
|
// 모델까지 올라갔으면 사이드카는 안정적이다 — 크래시 재시작 카운트 초기화
|
||||||
|
this._supervisor.markStable()
|
||||||
|
|
||||||
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -337,6 +349,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
// 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
|
// 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
|
||||||
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
|
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
|
||||||
if (!this._modelReady) {
|
if (!this._modelReady) {
|
||||||
|
if (options?.requireInstalled) this._assertInstalledLocally()
|
||||||
await this._ensureModelReady()
|
await this._ensureModelReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,6 +378,27 @@ class LocalSTTService extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정된 모델과 엔진을 내려받기 없이 쓸 수 있는지 확인한다 (requireInstalled 경로).
|
||||||
|
* 모델이 없으면 사이드카 /load가 HF에서 암묵적으로 내려받고, 엔진이 없으면 런타임을 내려받는다 —
|
||||||
|
* 폴백에서는 둘 다 하지 않고 즉시 실패한다.
|
||||||
|
*/
|
||||||
|
private _assertInstalledLocally(): void {
|
||||||
|
const modelId = configGet('sttModelId')
|
||||||
|
if (!modelId || !existsSync(join(getWhisperModelsDir(), modelId, 'model.bin'))) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTModelNotFound,
|
||||||
|
`로컬 STT 모델이 설치되어 있지 않습니다: ${modelId || '(미선택)'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!this._supervisor.isHealthy && !isSidecarLaunchAvailableOffline()) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTEngineNotInstalled,
|
||||||
|
'로컬 음성 엔진이 설치되어 있지 않습니다',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱 시작 시 sidecar와 모델을 미리 데운다.
|
* 앱 시작 시 sidecar와 모델을 미리 데운다.
|
||||||
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
||||||
|
|
@ -402,9 +436,8 @@ class LocalSTTService extends EventEmitter {
|
||||||
* 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도
|
* 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도
|
||||||
* 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다.
|
* 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다.
|
||||||
*/
|
*/
|
||||||
async ensureSidecar(): Promise<string> {
|
ensureSidecar(): Promise<string> {
|
||||||
await this._ensureSidecarRunning()
|
return this._supervisor.ready()
|
||||||
return this._baseUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -419,7 +452,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
if (this._disposed) return ''
|
if (this._disposed) return ''
|
||||||
if (!this._modelReady) return ''
|
if (!this._modelReady) return ''
|
||||||
if (audioBuffer.length === 0) return ''
|
if (audioBuffer.length === 0) return ''
|
||||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) return ''
|
if (!this._supervisor.isHealthy) return ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this._sendToSidecar(audioBuffer, {
|
const result = await this._sendToSidecar(audioBuffer, {
|
||||||
|
|
@ -461,7 +494,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._ensureSidecarRunning()
|
await this._supervisor.ready()
|
||||||
|
|
||||||
const startRes = await fetch(`${this._baseUrl}/download`, {
|
const startRes = await fetch(`${this._baseUrl}/download`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -566,7 +599,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
engineState: stateMap[this._state],
|
engineState: stateMap[this._state],
|
||||||
activeModel: this._currentModelId,
|
activeModel: this._currentModelId,
|
||||||
engineVersion: null,
|
engineVersion: null,
|
||||||
gpuAccelerated: this._gpuAccelerated,
|
gpuAccelerated: this._supervisor.gpuAccelerated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,36 +614,12 @@ class LocalSTTService extends EventEmitter {
|
||||||
this._modelReady = false
|
this._modelReady = false
|
||||||
this._auxModelId = null
|
this._auxModelId = null
|
||||||
|
|
||||||
await this._shutdownSidecar()
|
await this._supervisor.dispose()
|
||||||
this._setState(STTState.Uninitialized)
|
this._setState(STTState.Uninitialized)
|
||||||
|
|
||||||
logger.info('LocalSTTService dispose 완료')
|
logger.info('LocalSTTService dispose 완료')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidecar 관리 ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sidecar가 실행 중이 아니면 spawn + 헬스체크 대기.
|
|
||||||
*
|
|
||||||
* 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에
|
|
||||||
* (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로
|
|
||||||
* 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다.
|
|
||||||
*/
|
|
||||||
private _ensureSidecarRunning(): Promise<void> {
|
|
||||||
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
if (!this._sidecarStarting) {
|
|
||||||
this._sidecarStarting = (async () => {
|
|
||||||
await this._spawnSidecar()
|
|
||||||
await this._waitForHealth()
|
|
||||||
})().finally(() => {
|
|
||||||
this._sidecarStarting = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return this._sidecarStarting
|
|
||||||
}
|
|
||||||
|
|
||||||
private _emitDownloadProgress(
|
private _emitDownloadProgress(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
percent: number,
|
percent: number,
|
||||||
|
|
@ -627,242 +636,12 @@ class LocalSTTService extends EventEmitter {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다.
|
|
||||||
* dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어
|
|
||||||
* 매번 동적 할당해서 충돌을 회피한다.
|
|
||||||
*/
|
|
||||||
private _findFreePort(startPort: number, maxAttempts: number): Promise<number> {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
let attempt = 0
|
|
||||||
const tryPort = (port: number): void => {
|
|
||||||
const server = createServer()
|
|
||||||
server.unref()
|
|
||||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
|
||||||
attempt += 1
|
|
||||||
if (attempt >= maxAttempts) {
|
|
||||||
reject(
|
|
||||||
new D3ROError(
|
|
||||||
ErrorCode.STTSidecarSpawnFailed,
|
|
||||||
`Free port not found in range ${startPort}~${startPort + maxAttempts - 1}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tryPort(port + 1)
|
|
||||||
} else {
|
|
||||||
reject(
|
|
||||||
new D3ROError(
|
|
||||||
ErrorCode.STTSidecarSpawnFailed,
|
|
||||||
`Port probe failed: ${err.message}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
server.once('listening', () => {
|
|
||||||
server.close(() => {
|
|
||||||
resolve(port)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
server.listen(port, '127.0.0.1')
|
|
||||||
}
|
|
||||||
tryPort(startPort)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _spawnSidecar(): Promise<void> {
|
|
||||||
// 포트 점유 시 동적으로 다음 free 포트 탐색 (최대 20번 시도).
|
|
||||||
// dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음.
|
|
||||||
this._port = await this._findFreePort(SIDECAR_PORT, 20)
|
|
||||||
|
|
||||||
// 설치본에는 엔진이 없다 — 없으면 여기서 feed에서 내려받고 산다. dev는 venv/번들 경로를 쓴다.
|
|
||||||
const launch = await this._resolveSidecarLaunch()
|
|
||||||
const fullArgs = [
|
|
||||||
...launch.args,
|
|
||||||
'--port',
|
|
||||||
String(this._port),
|
|
||||||
'--models-dir',
|
|
||||||
getWhisperModelsDir(),
|
|
||||||
]
|
|
||||||
logger.info(
|
|
||||||
`Sidecar 시작(${launch.source}): ${launch.command} ${fullArgs.join(' ')}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
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
|
|
||||||
|
|
||||||
this._pipeSidecarLogs(child, getLogger('sidecar'))
|
|
||||||
|
|
||||||
// spawn 성공 = 프로세스가 실제로 시작됨. 즉시 resolve해 healthcheck로 넘어간다.
|
|
||||||
child.once('spawn', () => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
// spawn 실패(ENOENT 등)는 즉시 실패시킨다. 예전엔 즉시 resolve 후
|
|
||||||
// healthcheck 30초를 헛되게 태우고 원인을 숨겼다.
|
|
||||||
child.once('error', (err: Error) => {
|
|
||||||
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
|
|
||||||
this._sidecarProcess = null
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
reject(this._spawnFailureError(err, launch))
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on('exit', (code: number | null, signal: string | null) => {
|
|
||||||
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
|
|
||||||
if (this._sidecarProcess === child) {
|
|
||||||
this._sidecarProcess = null
|
|
||||||
}
|
|
||||||
this._modelReady = false
|
|
||||||
this._auxModelId = null
|
|
||||||
|
|
||||||
if (!this._disposed) {
|
|
||||||
this._handleSidecarCrash()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사이드카 실행 방법을 결정한다.
|
|
||||||
* 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다.
|
|
||||||
* 진행률은 runtime-progress 이벤트로 노출된다.
|
|
||||||
*/
|
|
||||||
private async _resolveSidecarLaunch(): Promise<{
|
|
||||||
command: string
|
|
||||||
args: string[]
|
|
||||||
source: 'bundled' | 'provisioned' | 'venv' | 'python'
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
return getSidecarCommand()
|
|
||||||
} catch (err) {
|
|
||||||
const needsInstall =
|
|
||||||
err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled
|
|
||||||
if (!needsInstall) throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 —
|
|
||||||
// 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다.
|
|
||||||
logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)')
|
|
||||||
const binaryPath = await getRuntimeProvisioner().ensure('sidecar')
|
|
||||||
logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`)
|
|
||||||
return { command: binaryPath, args: [], source: 'provisioned' }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
|
||||||
private _spawnFailureError(
|
|
||||||
err: Error,
|
|
||||||
launch: { command: string; source: 'bundled' | 'provisioned' | '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 === 'provisioned'
|
|
||||||
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
|
||||||
: 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()
|
|
||||||
|
|
||||||
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
|
|
||||||
// 프로세스가 이미 죽었으면 30초 타임아웃을 기다리지 않고 즉시 실패
|
|
||||||
// (의존성 미설치 등 즉사 크래시가 30초×큐잉으로 증폭되는 문제 방지)
|
|
||||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
|
||||||
throw new D3ROError(
|
|
||||||
ErrorCode.STTSidecarSpawnFailed,
|
|
||||||
'Sidecar process exited during startup — check Python environment/dependencies',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this._baseUrl}/health`, {
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as HealthResponse
|
|
||||||
this._gpuAccelerated = data.gpu
|
|
||||||
this._restartCount = 0
|
|
||||||
logger.info(
|
|
||||||
`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 아직 준비 안 됨, 재시도
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._sleep(HEALTH_CHECK_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new D3ROError(
|
|
||||||
ErrorCode.STTSidecarCommunicationFailed,
|
|
||||||
`Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 받아쓰기(기본) 모델과 다른 모델을 보조 자리에 올린다 — 실시간 자막처럼 따로 고른 모델용.
|
* 받아쓰기(기본) 모델과 다른 모델을 보조 자리에 올린다 — 실시간 자막처럼 따로 고른 모델용.
|
||||||
* 기본 모델과 같으면 아무것도 하지 않는다.
|
* 기본 모델과 같으면 아무것도 하지 않는다.
|
||||||
*/
|
*/
|
||||||
async ensureAuxModel(modelId: string): Promise<void> {
|
async ensureAuxModel(modelId: string): Promise<void> {
|
||||||
await this._ensureSidecarRunning()
|
await this._supervisor.ready()
|
||||||
if (modelId === this._currentModelId || modelId === this._auxModelId) return
|
if (modelId === this._currentModelId || modelId === this._auxModelId) return
|
||||||
await this._loadModel(modelId, 'aux')
|
await this._loadModel(modelId, 'aux')
|
||||||
this._auxModelId = modelId
|
this._auxModelId = modelId
|
||||||
|
|
@ -909,7 +688,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
audioBuffer: Buffer,
|
audioBuffer: Buffer,
|
||||||
options?: TranscribeOptions,
|
options?: TranscribeOptions,
|
||||||
): Promise<TranscriptionResult> {
|
): Promise<TranscriptionResult> {
|
||||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
if (!this._supervisor.isHealthy) {
|
||||||
throw new D3ROError(
|
throw new D3ROError(
|
||||||
ErrorCode.STTSidecarCommunicationFailed,
|
ErrorCode.STTSidecarCommunicationFailed,
|
||||||
'Sidecar 프로세스가 실행 중이 아닙니다',
|
'Sidecar 프로세스가 실행 중이 아닙니다',
|
||||||
|
|
@ -1034,34 +813,35 @@ class LocalSTTService extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidecar Crash 처리 ──
|
// ── Sidecar 수명주기 이벤트 (SidecarSupervisor) ──
|
||||||
|
|
||||||
private _handleSidecarCrash(): void {
|
/** 사이드카가 죽으면 올려 둔 모델도 함께 사라진다 */
|
||||||
|
private _onSidecarExit(): void {
|
||||||
|
this._modelReady = false
|
||||||
|
this._auxModelId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예기치 않은 종료 — 감독자가 백오프 뒤 재시작을 예약했다.
|
||||||
|
* 모델이 올라가 있었거나 올리는 중이었으면, 재시작에 합류해 그 모델을 다시 올린다.
|
||||||
|
* 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가
|
||||||
|
* 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
|
||||||
|
* 모델이 없었으면(UIA 브리지만 쓰던 경우) 사이드카만 되살리고 모델은 올리지 않는다 —
|
||||||
|
* 설치하지 않은 모델을 /load 하면 사이드카가 암묵적으로 내려받기 때문이다.
|
||||||
|
*/
|
||||||
|
private _onSidecarCrashed(payload: SidecarSupervisorEvents['crashed']): void {
|
||||||
if (this._disposed) return
|
if (this._disposed) return
|
||||||
|
logger.warn(
|
||||||
|
`Sidecar crash — ${payload.delayMs}ms 뒤 재시작 (${payload.attempt}/${payload.maxAttempts})`,
|
||||||
|
)
|
||||||
|
|
||||||
this._restartCount++
|
const modelToReload = this._currentModelId ?? this._initializing?.modelId ?? null
|
||||||
logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`)
|
|
||||||
|
|
||||||
if (this._restartCount > MAX_RESTART_COUNT) {
|
|
||||||
const err = new D3ROError(
|
|
||||||
ErrorCode.STTSidecarCrashed,
|
|
||||||
`Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`,
|
|
||||||
)
|
|
||||||
this._setState(STTState.Error)
|
|
||||||
this._emitError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비동기 재시작
|
|
||||||
const modelToReload = this._currentModelId ?? configGet('sttModelId')
|
|
||||||
this._currentModelId = null
|
this._currentModelId = null
|
||||||
this._modelReady = false
|
this._modelReady = false
|
||||||
this._auxModelId = null
|
this._auxModelId = null
|
||||||
|
if (!modelToReload) return
|
||||||
|
|
||||||
// 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가
|
const restart = this._supervisor.ready().then(async () => {
|
||||||
// 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
|
|
||||||
const delayMs = 1000 * this._restartCount // 점진적 백오프
|
|
||||||
const restart = new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(async () => {
|
|
||||||
if (this._disposed) {
|
if (this._disposed) {
|
||||||
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
|
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
|
||||||
}
|
}
|
||||||
|
|
@ -1075,31 +855,10 @@ class LocalSTTService extends EventEmitter {
|
||||||
this._trackInitialization(modelToReload, restart)
|
this._trackInitialization(modelToReload, restart)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _shutdownSidecar(): Promise<void> {
|
private _onSidecarGaveUp(error: D3ROError): void {
|
||||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
if (this._disposed) return
|
||||||
this._sidecarProcess = null
|
this._setState(STTState.Error)
|
||||||
return
|
this._emitError(error)
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Sidecar 종료 요청')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// POST /shutdown 요청
|
|
||||||
await fetch(`${this._baseUrl}/shutdown`, {
|
|
||||||
method: 'POST',
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// 이미 종료되었거나 통신 불가 — 무시
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로세스가 아직 살아있으면 강제 종료
|
|
||||||
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
|
|
||||||
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
|
|
||||||
this._sidecarProcess.kill('SIGKILL')
|
|
||||||
}
|
|
||||||
|
|
||||||
this._sidecarProcess = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 내부 유틸 ──
|
// ── 내부 유틸 ──
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { GoogleDriver } from './drivers/GoogleDriver'
|
||||||
import { CustomDriver } from './drivers/CustomDriver'
|
import { CustomDriver } from './drivers/CustomDriver'
|
||||||
import { D3ROCloudDriver } from './drivers/D3ROCloudDriver'
|
import { D3ROCloudDriver } from './drivers/D3ROCloudDriver'
|
||||||
import { normalizeLoopbackUrl } from '../../utils/loopback'
|
import { normalizeLoopbackUrl } from '../../utils/loopback'
|
||||||
|
import { isRecoverableByLocalFallback } from './fallbackPolicy'
|
||||||
|
|
||||||
const logger = getLogger('STTManager')
|
const logger = getLogger('STTManager')
|
||||||
|
|
||||||
|
|
@ -250,7 +251,8 @@ export class STTManager extends EventEmitter implements STTEngine {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`)
|
logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`)
|
||||||
|
|
||||||
if (fallbackToLocal) {
|
// 무음/취소처럼 로컬로 다시 해도 결과가 같은 실패는 폴백하지 않는다
|
||||||
|
if (fallbackToLocal && isRecoverableByLocalFallback(error)) {
|
||||||
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
|
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
|
||||||
this.emit('fallback-to-local', { provider, reason: errorMsg })
|
this.emit('fallback-to-local', { provider, reason: errorMsg })
|
||||||
try {
|
try {
|
||||||
|
|
@ -270,13 +272,15 @@ export class STTManager extends EventEmitter implements STTEngine {
|
||||||
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가
|
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가
|
||||||
* 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어
|
* 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어
|
||||||
* 받아쓰기 액션 큐가 굳지 않게 한다.
|
* 받아쓰기 액션 큐가 굳지 않게 한다.
|
||||||
|
* requireInstalled: 모델·엔진이 이미 로컬에 있을 때만 쓴다. 없으면 폴백이 엔진(~100MB)과
|
||||||
|
* 모델(최대 수 GB)을 조용히 내려받기 시작하고 그동안 핫키 큐가 멈춘다.
|
||||||
*/
|
*/
|
||||||
private _transcribeLocalFallback(
|
private _transcribeLocalFallback(
|
||||||
audioBuffer: Buffer,
|
audioBuffer: Buffer,
|
||||||
options?: TranscribeOptions,
|
options?: TranscribeOptions,
|
||||||
): Promise<TranscriptionResult> {
|
): Promise<TranscriptionResult> {
|
||||||
return withTimeout(
|
return withTimeout(
|
||||||
getLocalSTTService().transcribe(audioBuffer, options),
|
getLocalSTTService().transcribe(audioBuffer, { ...options, requireInstalled: true }),
|
||||||
LOCAL_FALLBACK_TIMEOUT_MS,
|
LOCAL_FALLBACK_TIMEOUT_MS,
|
||||||
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
|
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
495
apps/desktop/src/main/services/stt/SidecarSupervisor.ts
Normal file
495
apps/desktop/src/main/services/stt/SidecarSupervisor.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
// apps/desktop/src/main/services/stt/SidecarSupervisor.ts
|
||||||
|
// faster-whisper/UIA 사이드카 프로세스의 수명주기(포트 탐색 → spawn → 헬스 → 크래시 재시작 → 종료)만 맡는다.
|
||||||
|
//
|
||||||
|
// 예전에는 LocalSTTService가 이 일을 모델/전사 API와 함께 맡았고, "기동 중"과 "실행 중"이
|
||||||
|
// _sidecarProcess·_sidecarStarting 두 필드에 흩어져 있었다. spawn 직후(헬스 전)에 들어온
|
||||||
|
// 호출자가 "실행 중"으로 보고 통과해, 아직 listen하지 않는 서버에 /load·/uia/focus를 보내
|
||||||
|
// ECONNREFUSED로 실패했다. 또 헬스 성공마다 재시작 카운트를 0으로 돌려, 모델 로딩 중
|
||||||
|
// 반복 크래시가 MAX_RESTART에 도달하지 못하고 무한 재시작했다.
|
||||||
|
//
|
||||||
|
// 이제 상태는 하나(SidecarState)이고, ready()는 헬스 통과 뒤에만 resolve한다.
|
||||||
|
// 재시작 카운트는 "안정됨"(markStable — 모델 로딩 성공 — 또는 충분히 오래 살아 있었음)일 때만 초기화된다.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn } from 'child_process'
|
||||||
|
import { createServer } from 'net'
|
||||||
|
import { getLogger } from '../LoggerService'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import type { SidecarLaunch } from './sidecarLaunch'
|
||||||
|
|
||||||
|
const logger = getLogger('SidecarSupervisor')
|
||||||
|
|
||||||
|
export type SidecarState =
|
||||||
|
| 'stopped'
|
||||||
|
/** spawn + 헬스 대기 중 — ready() 호출자는 이 기동에 합류한다 */
|
||||||
|
| 'starting'
|
||||||
|
/** 헬스 통과 — HTTP 요청을 보내도 된다 */
|
||||||
|
| 'healthy'
|
||||||
|
/** 프로세스는 살아 있지만 헬스 타임아웃 — 다음 ready()는 재spawn 없이 헬스만 다시 기다린다 */
|
||||||
|
| 'unresponsive'
|
||||||
|
/** 예기치 않게 종료되어 백오프 뒤 재시작 대기 중 — ready()는 그 재시작에 합류한다 */
|
||||||
|
| 'crashed'
|
||||||
|
| 'disposed'
|
||||||
|
|
||||||
|
export interface SidecarSupervisorEvents {
|
||||||
|
/** 현재 프로세스가 종료됨 (expected=dispose에 의한 종료) */
|
||||||
|
exit: { code: number | null; signal: string | null; expected: boolean }
|
||||||
|
/** 예기치 않은 종료 — 백오프 뒤 재시작이 예약됨. 리스너는 ready()로 그 재시작에 합류할 수 있다 */
|
||||||
|
crashed: { attempt: number; maxAttempts: number; delayMs: number }
|
||||||
|
/** 재시작 한도 초과 — 자동 재시작 포기 (다음 ready()는 새로 기동한다) */
|
||||||
|
'gave-up': { error: D3ROError }
|
||||||
|
healthy: { gpu: boolean; baseUrl: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidecarSpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess
|
||||||
|
export type SidecarFetchFn = (input: string, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
export interface SidecarSupervisorOptions {
|
||||||
|
/** 실행 파일 결정 (필요하면 런타임을 내려받는다) */
|
||||||
|
resolveLaunch: () => Promise<SidecarLaunch>
|
||||||
|
modelsDir: () => string
|
||||||
|
baseUrlFor: (port: number) => string
|
||||||
|
spawn?: SidecarSpawnFn
|
||||||
|
fetch?: SidecarFetchFn
|
||||||
|
findFreePort?: (startPort: number, maxAttempts: number) => Promise<number>
|
||||||
|
sleep?: (ms: number) => Promise<void>
|
||||||
|
now?: () => number
|
||||||
|
basePort?: number
|
||||||
|
healthTimeoutMs?: number
|
||||||
|
healthIntervalMs?: number
|
||||||
|
maxRestarts?: number
|
||||||
|
/** n번째 재시작은 restartBackoffMs × n 뒤에 한다 */
|
||||||
|
restartBackoffMs?: number
|
||||||
|
/** 이만큼 헬스 상태로 살아 있었으면 크래시 시 재시작 카운트를 초기화한다 */
|
||||||
|
stableUptimeMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedOptions = Required<SidecarSupervisorOptions>
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: string
|
||||||
|
model: string | null
|
||||||
|
gpu: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDECAR_BASE_PORT = 18765
|
||||||
|
const PORT_PROBE_ATTEMPTS = 20
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
basePort: SIDECAR_BASE_PORT,
|
||||||
|
healthTimeoutMs: 30_000,
|
||||||
|
healthIntervalMs: 1_000,
|
||||||
|
maxRestarts: 3,
|
||||||
|
restartBackoffMs: 1_000,
|
||||||
|
stableUptimeMs: 5 * 60_000,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function defaultSleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다.
|
||||||
|
* dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어
|
||||||
|
* 매번 동적 할당해서 충돌을 회피한다.
|
||||||
|
*/
|
||||||
|
export function findFreeLoopbackPort(startPort: number, maxAttempts: number): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
let attempt = 0
|
||||||
|
const tryPort = (port: number): void => {
|
||||||
|
const server = createServer()
|
||||||
|
server.unref()
|
||||||
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||||
|
attempt += 1
|
||||||
|
if (attempt >= maxAttempts) {
|
||||||
|
reject(
|
||||||
|
new D3ROError(
|
||||||
|
ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
`Free port not found in range ${startPort}~${startPort + maxAttempts - 1}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tryPort(port + 1)
|
||||||
|
} else {
|
||||||
|
reject(new D3ROError(ErrorCode.STTSidecarSpawnFailed, `Port probe failed: ${err.message}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => {
|
||||||
|
resolve(port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
server.listen(port, '127.0.0.1')
|
||||||
|
}
|
||||||
|
tryPort(startPort)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
||||||
|
function spawnFailureError(err: Error, launch: SidecarLaunch): 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 === 'provisioned'
|
||||||
|
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
||||||
|
: 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}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar stdout/stderr를 줄 단위로 로그에 흘려보낸다. */
|
||||||
|
function pipeSidecarLogs(child: ChildProcess): void {
|
||||||
|
const sidecarLogger = getLogger('sidecar')
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlive(child: ChildProcess | null): child is ChildProcess {
|
||||||
|
return child !== null && child.exitCode === null && child.signalCode === null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SidecarSupervisor extends EventEmitter {
|
||||||
|
private readonly _o: ResolvedOptions
|
||||||
|
private _state: SidecarState = 'stopped'
|
||||||
|
private _child: ChildProcess | null = null
|
||||||
|
private _port: number
|
||||||
|
private _starting: Promise<string> | null = null
|
||||||
|
private _pendingRestart: Promise<string> | null = null
|
||||||
|
private _restartCount = 0
|
||||||
|
private _healthyAt: number | null = null
|
||||||
|
private _gpu = false
|
||||||
|
|
||||||
|
constructor(options: SidecarSupervisorOptions) {
|
||||||
|
super()
|
||||||
|
this._o = {
|
||||||
|
spawn: (command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions),
|
||||||
|
// 전역 fetch를 호출 시점에 찾는다 (테스트의 stubGlobal을 존중)
|
||||||
|
fetch: (input, init) => fetch(input, init),
|
||||||
|
findFreePort: findFreeLoopbackPort,
|
||||||
|
sleep: defaultSleep,
|
||||||
|
now: () => Date.now(),
|
||||||
|
...DEFAULTS,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
this._port = this._o.basePort
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상태 조회 ──
|
||||||
|
|
||||||
|
get state(): SidecarState {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헬스를 통과했고 프로세스가 살아 있다 — HTTP 요청을 보내도 된다 */
|
||||||
|
get isHealthy(): boolean {
|
||||||
|
return this._state === 'healthy' && isAlive(this._child)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar HTTP 기본 URL (동적 포트) — 기동 전에는 기본 포트 기준 */
|
||||||
|
get baseUrl(): string {
|
||||||
|
return this._o.baseUrlFor(this._port)
|
||||||
|
}
|
||||||
|
|
||||||
|
get gpuAccelerated(): boolean {
|
||||||
|
return this._gpu
|
||||||
|
}
|
||||||
|
|
||||||
|
get restartCount(): number {
|
||||||
|
return this._restartCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 수명주기 ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헬스를 통과한 사이드카의 base URL을 돌려준다. 필요하면 기동한다.
|
||||||
|
* 동시 호출은 하나의 기동(또는 예약된 재시작)을 공유하며, 헬스 통과 전에는 절대 resolve하지 않는다.
|
||||||
|
*/
|
||||||
|
ready(): Promise<string> {
|
||||||
|
if (this._state === 'disposed') {
|
||||||
|
return Promise.reject(
|
||||||
|
new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'Sidecar 감독자가 이미 종료되었습니다'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (this.isHealthy) return Promise.resolve(this.baseUrl)
|
||||||
|
if (this._pendingRestart) return this._pendingRestart
|
||||||
|
if (this._starting) return this._starting
|
||||||
|
return this._start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이드카가 실제 작업(모델 로딩 등)을 해낼 만큼 안정적임을 알린다 — 재시작 카운트 초기화.
|
||||||
|
* 헬스 통과만으로는 초기화하지 않는다: 로딩 중 반복 크래시가 한도에 도달해야 하기 때문이다.
|
||||||
|
*/
|
||||||
|
markStable(): void {
|
||||||
|
if (!this.isHealthy) return
|
||||||
|
this._restartCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사이드카 종료(/shutdown → 남아 있으면 SIGKILL). 이후 ready()는 거부된다. */
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this._state === 'disposed') return
|
||||||
|
this._setState('disposed')
|
||||||
|
this._pendingRestart = null
|
||||||
|
|
||||||
|
const child = this._child
|
||||||
|
if (!isAlive(child)) {
|
||||||
|
this._child = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Sidecar 종료 요청')
|
||||||
|
try {
|
||||||
|
await this._o.fetch(`${this.baseUrl}/shutdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 이미 종료되었거나 통신 불가 — 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlive(child)) {
|
||||||
|
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
this._child = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 기동 ──
|
||||||
|
|
||||||
|
private _start(): Promise<string> {
|
||||||
|
this._setState('starting')
|
||||||
|
const tracked: Promise<string> = this._runStartup()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// 기동 중 크래시로 이미 'crashed'(재시작 예약)가 됐으면 그 상태를 덮지 않는다
|
||||||
|
if (this._state === 'starting') {
|
||||||
|
this._setState(isAlive(this._child) ? 'unresponsive' : 'stopped')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this._starting === tracked) this._starting = null
|
||||||
|
})
|
||||||
|
this._starting = tracked
|
||||||
|
return tracked
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _runStartup(): Promise<string> {
|
||||||
|
let child: ChildProcess
|
||||||
|
if (isAlive(this._child)) {
|
||||||
|
// 헬스 타임아웃으로 'unresponsive'가 된 프로세스 — 느린 기동(백신 검사 등)일 수 있어 다시 기다린다
|
||||||
|
logger.info('응답 없던 사이드카의 헬스를 다시 확인합니다')
|
||||||
|
child = this._child
|
||||||
|
} else {
|
||||||
|
// 포트 점유 시 동적으로 다음 free 포트 탐색.
|
||||||
|
this._port = await this._o.findFreePort(this._o.basePort, PORT_PROBE_ATTEMPTS)
|
||||||
|
// 설치본에는 엔진이 없을 수 있다 — 없으면 여기서 feed에서 내려받는다.
|
||||||
|
const launch = await this._o.resolveLaunch()
|
||||||
|
this._assertNotDisposed()
|
||||||
|
child = await this._spawn(launch)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._waitForHealth(child)
|
||||||
|
this._healthyAt = this._o.now()
|
||||||
|
this._setState('healthy')
|
||||||
|
this.emit('healthy', { gpu: this._gpu, baseUrl: this.baseUrl })
|
||||||
|
return this.baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private _spawn(launch: SidecarLaunch): Promise<ChildProcess> {
|
||||||
|
const fullArgs = [...launch.args, '--port', String(this._port), '--models-dir', this._o.modelsDir()]
|
||||||
|
logger.info(`Sidecar 시작(${launch.source}): ${launch.command} ${fullArgs.join(' ')}`)
|
||||||
|
|
||||||
|
return new Promise<ChildProcess>((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
const child = this._o.spawn(launch.command, fullArgs, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// 사이드카 로그와 파일 경로가 UTF-8로 오가도록 고정 (Windows cp949 깨짐 방지)
|
||||||
|
PYTHONIOENCODING: 'utf-8',
|
||||||
|
PYTHONUTF8: '1',
|
||||||
|
},
|
||||||
|
// Windows에서 콘솔 창이 깜빡이지 않게 한다.
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
this._child = child
|
||||||
|
this._healthyAt = null
|
||||||
|
|
||||||
|
pipeSidecarLogs(child)
|
||||||
|
|
||||||
|
// spawn 성공 = 프로세스가 실제로 시작됨. 헬스체크로 넘어간다.
|
||||||
|
child.once('spawn', () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(child)
|
||||||
|
})
|
||||||
|
|
||||||
|
// spawn 실패(ENOENT 등)는 즉시 실패시킨다 — 헬스체크 30초를 헛되게 태우지 않는다.
|
||||||
|
child.once('error', (err: Error) => {
|
||||||
|
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
if (this._child === child) this._child = null
|
||||||
|
reject(spawnFailureError(err, launch))
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('exit', (code: number | null, signal: string | null) => {
|
||||||
|
this._onExit(child, code, signal)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _waitForHealth(child: ChildProcess): Promise<void> {
|
||||||
|
const startedAt = this._o.now()
|
||||||
|
|
||||||
|
while (this._o.now() - startedAt < this._o.healthTimeoutMs) {
|
||||||
|
this._assertNotDisposed()
|
||||||
|
// 프로세스가 이미 죽었으면 타임아웃을 기다리지 않고 즉시 실패
|
||||||
|
if (this._child !== child || !isAlive(child)) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
'Sidecar process exited during startup — check Python environment/dependencies',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this._o.fetch(`${this.baseUrl}/health`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as HealthResponse
|
||||||
|
this._gpu = data.gpu === true
|
||||||
|
logger.info(`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 아직 준비 안 됨, 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._o.sleep(this._o.healthIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTSidecarCommunicationFailed,
|
||||||
|
`Sidecar 헬스체크 타임아웃 (${this._o.healthTimeoutMs}ms)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 종료/크래시 ──
|
||||||
|
|
||||||
|
private _onExit(child: ChildProcess, code: number | null, signal: string | null): void {
|
||||||
|
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
|
||||||
|
// 이미 교체되었거나 종료 처리된 프로세스
|
||||||
|
if (this._child !== child) return
|
||||||
|
|
||||||
|
this._child = null
|
||||||
|
const healthyAt = this._healthyAt
|
||||||
|
this._healthyAt = null
|
||||||
|
const expected = this._state === 'disposed'
|
||||||
|
this.emit('exit', { code, signal, expected })
|
||||||
|
if (expected) return
|
||||||
|
|
||||||
|
this._setState('crashed')
|
||||||
|
this._scheduleRestart(healthyAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleRestart(healthyAt: number | null): void {
|
||||||
|
if (healthyAt !== null && this._o.now() - healthyAt >= this._o.stableUptimeMs) {
|
||||||
|
this._restartCount = 0
|
||||||
|
}
|
||||||
|
this._restartCount += 1
|
||||||
|
const attempt = this._restartCount
|
||||||
|
const maxAttempts = this._o.maxRestarts
|
||||||
|
logger.warn(`Sidecar crash 감지, 재시작 시도 ${attempt}/${maxAttempts}`)
|
||||||
|
|
||||||
|
if (attempt > maxAttempts) {
|
||||||
|
this._setState('stopped')
|
||||||
|
this.emit('gave-up', {
|
||||||
|
error: new D3ROError(
|
||||||
|
ErrorCode.STTSidecarCrashed,
|
||||||
|
`Sidecar가 ${maxAttempts}회 crash 후 재시작 포기`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = this._o.restartBackoffMs * attempt // 점진적 백오프
|
||||||
|
const pending: Promise<string> = this._o.sleep(delayMs).then(() => {
|
||||||
|
if (this._pendingRestart === pending) this._pendingRestart = null
|
||||||
|
if (this._state === 'disposed') {
|
||||||
|
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
|
||||||
|
}
|
||||||
|
return this.ready()
|
||||||
|
})
|
||||||
|
pending.catch((err: unknown) => {
|
||||||
|
logger.error(`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
})
|
||||||
|
this._pendingRestart = pending
|
||||||
|
this.emit('crashed', { attempt, maxAttempts, delayMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 유틸 ──
|
||||||
|
|
||||||
|
private _assertNotDisposed(): void {
|
||||||
|
if (this._state === 'disposed') {
|
||||||
|
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 사이드카 기동 취소')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setState(next: SidecarState): void {
|
||||||
|
if (this._state === next) return
|
||||||
|
logger.debug(`SidecarState: ${this._state} -> ${next}`)
|
||||||
|
this._state = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 타입 안전한 이벤트 메서드 ──
|
||||||
|
|
||||||
|
override emit<K extends keyof SidecarSupervisorEvents>(
|
||||||
|
event: K,
|
||||||
|
payload: SidecarSupervisorEvents[K],
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
override on<K extends keyof SidecarSupervisorEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: SidecarSupervisorEvents[K]) => void,
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override off<K extends keyof SidecarSupervisorEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: SidecarSupervisorEvents[K]) => void,
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/desktop/src/main/services/stt/fallbackPolicy.ts
Normal file
22
apps/desktop/src/main/services/stt/fallbackPolicy.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// apps/desktop/src/main/services/stt/fallbackPolicy.ts
|
||||||
|
// 클라우드 STT 실패 → 로컬 Whisper 폴백 여부를 결정하는 순수 정책.
|
||||||
|
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 엔진으로 다시 해도 결과가 달라지지 않는 실패.
|
||||||
|
* - 음성 없음/너무 짧음: 같은 오디오를 로컬로 돌려도 빈 결과다. 무음 핫키 한 번에
|
||||||
|
* 로컬 모델 로딩(수 초)과 액션 큐 정지를 치를 이유가 없다.
|
||||||
|
* - 취소: 사용자가 이미 그만뒀다.
|
||||||
|
*/
|
||||||
|
const NON_RECOVERABLE_CODES: ReadonlySet<ErrorCode> = new Set([
|
||||||
|
ErrorCode.STTNoAudioData,
|
||||||
|
ErrorCode.STTAudioTooShort,
|
||||||
|
ErrorCode.STTTranscriptionCancelled,
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 클라우드 오류가 로컬 폴백으로 복구될 수 있는 종류인지 */
|
||||||
|
export function isRecoverableByLocalFallback(error: unknown): boolean {
|
||||||
|
if (error instanceof D3ROError && NON_RECOVERABLE_CODES.has(error.code)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
56
apps/desktop/src/main/services/stt/sidecarLaunch.ts
Normal file
56
apps/desktop/src/main/services/stt/sidecarLaunch.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// apps/desktop/src/main/services/stt/sidecarLaunch.ts
|
||||||
|
// 사이드카 실행 방법 결정(번들/venv/시스템 python/내려받은 런타임).
|
||||||
|
// 프로세스 수명주기(SidecarSupervisor)와 분리해, 감독자는 "무엇을 실행할지"를 주입받기만 한다.
|
||||||
|
|
||||||
|
import { getLogger } from '../LoggerService'
|
||||||
|
import { getSidecarCommand } from '../../utils/paths'
|
||||||
|
import { getRuntimeProvisioner } from '../RuntimeProvisioner'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
||||||
|
const logger = getLogger('SidecarLaunch')
|
||||||
|
|
||||||
|
export type SidecarLaunchSource = 'bundled' | 'provisioned' | 'venv' | 'python'
|
||||||
|
|
||||||
|
export interface SidecarLaunch {
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
source: SidecarLaunchSource
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEngineNotInstalled(err: unknown): boolean {
|
||||||
|
return err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이드카 실행 방법을 결정한다.
|
||||||
|
* 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다.
|
||||||
|
* 진행률은 RuntimeProvisioner 'progress' 이벤트로 노출된다.
|
||||||
|
*/
|
||||||
|
export async function resolveSidecarLaunch(): Promise<SidecarLaunch> {
|
||||||
|
try {
|
||||||
|
return getSidecarCommand()
|
||||||
|
} catch (err) {
|
||||||
|
if (!isEngineNotInstalled(err)) throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 —
|
||||||
|
// 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다.
|
||||||
|
logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)')
|
||||||
|
const binaryPath = await getRuntimeProvisioner().ensure('sidecar')
|
||||||
|
logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`)
|
||||||
|
return { command: binaryPath, args: [], source: 'provisioned' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내려받기 없이 사이드카를 띄울 수 있는지 (번들/venv/시스템 python 또는 이미 설치된 런타임).
|
||||||
|
* 폴백처럼 "지금 당장 쓸 수 있을 때만" 로컬 엔진을 쓰는 경로가 판정에 쓴다.
|
||||||
|
*/
|
||||||
|
export function isSidecarLaunchAvailableOffline(): boolean {
|
||||||
|
try {
|
||||||
|
getSidecarCommand()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (!isEngineNotInstalled(err)) return false
|
||||||
|
return getRuntimeProvisioner().isInstalled('sidecar')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
// tests/main/services/stt-sidecar-redteam-r2-6.test.ts
|
||||||
|
// 1) 클라우드 STT 실패 → 로컬 폴백이 무음/미설치 상황에서 엔진·모델 내려받기를 조용히 시작하던 버그
|
||||||
|
// 2) 기동 중(헬스 전) 사이드카를 '실행 중'으로 보고 통과시키던 버그 (SidecarSupervisor.ready)
|
||||||
|
// 3) 헬스 성공마다 재시작 카운트가 0으로 돌아가 로딩 중 반복 크래시가 끝나지 않던 버그
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { ChildProcess } from 'child_process'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
||||||
|
const launchMock = vi.hoisted(() => ({ offline: true }))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/stt/sidecarLaunch', () => ({
|
||||||
|
resolveSidecarLaunch: vi.fn(async () => ({ command: 'fake-sidecar', args: [], source: 'venv' as const })),
|
||||||
|
isSidecarLaunchAvailableOffline: vi.fn(() => launchMock.offline),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
|
||||||
|
import {
|
||||||
|
LocalSTTService,
|
||||||
|
getLocalSTTService,
|
||||||
|
resetLocalSTTServiceForTests,
|
||||||
|
} from '../../../src/main/services/LocalSTTService'
|
||||||
|
import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService'
|
||||||
|
import { SidecarSupervisor, type SidecarFetchFn } from '../../../src/main/services/stt/SidecarSupervisor'
|
||||||
|
import { isRecoverableByLocalFallback } from '../../../src/main/services/stt/fallbackPolicy'
|
||||||
|
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
|
||||||
|
import { getWhisperModelsDir } from '../../../src/main/utils/paths'
|
||||||
|
|
||||||
|
// ── 공용 헬퍼 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
type LocalInternals = {
|
||||||
|
_modelReady: boolean
|
||||||
|
_sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise<TranscriptionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
const okResult = (text: string): TranscriptionResult => ({
|
||||||
|
text,
|
||||||
|
segments: [],
|
||||||
|
language: 'ko',
|
||||||
|
duration: 1,
|
||||||
|
processingTime: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function flush(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeChild extends EventEmitter {
|
||||||
|
exitCode: number | null = null
|
||||||
|
signalCode: NodeJS.Signals | null = null
|
||||||
|
stdout = null
|
||||||
|
stderr = null
|
||||||
|
kill = vi.fn((): boolean => {
|
||||||
|
this.die(null, 'SIGKILL')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
die(code: number | null, signal: NodeJS.Signals | null = null): void {
|
||||||
|
if (this.exitCode !== null || this.signalCode !== null) return
|
||||||
|
this.exitCode = code
|
||||||
|
this.signalCode = signal
|
||||||
|
this.emit('exit', code, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
supervisor: SidecarSupervisor
|
||||||
|
fetch: SidecarFetchFn
|
||||||
|
children: FakeChild[]
|
||||||
|
spawn: ReturnType<typeof vi.fn>
|
||||||
|
fetchCalls: string[]
|
||||||
|
setHealthy: (value: boolean) => void
|
||||||
|
onLoad: (handler: () => Promise<Response>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(): Harness {
|
||||||
|
const children: FakeChild[] = []
|
||||||
|
const fetchCalls: string[] = []
|
||||||
|
let healthy = false
|
||||||
|
let clock = 0
|
||||||
|
let loadHandler: () => Promise<Response> = async () =>
|
||||||
|
jsonResponse({ status: 'ok', model_id: 'tiny', load_time_ms: 1 })
|
||||||
|
|
||||||
|
const spawn = vi.fn(() => {
|
||||||
|
const child = new FakeChild()
|
||||||
|
children.push(child)
|
||||||
|
setImmediate(() => child.emit('spawn'))
|
||||||
|
return child as unknown as ChildProcess
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchFn: SidecarFetchFn = async (input) => {
|
||||||
|
fetchCalls.push(input)
|
||||||
|
if (input.endsWith('/health')) {
|
||||||
|
if (!healthy) throw new TypeError('fetch failed (ECONNREFUSED)')
|
||||||
|
return jsonResponse({ status: 'ok', model: null, gpu: false })
|
||||||
|
}
|
||||||
|
if (input.endsWith('/load')) return loadHandler()
|
||||||
|
if (input.endsWith('/shutdown')) return jsonResponse({ ok: true })
|
||||||
|
throw new TypeError(`unexpected ${input}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supervisor = new SidecarSupervisor({
|
||||||
|
resolveLaunch: async () => ({ command: 'fake-sidecar', args: [], source: 'venv' }),
|
||||||
|
modelsDir: () => 'models',
|
||||||
|
baseUrlFor: (port) => `http://127.0.0.1:${port}`,
|
||||||
|
spawn,
|
||||||
|
fetch: fetchFn,
|
||||||
|
findFreePort: async (start) => start,
|
||||||
|
now: () => clock,
|
||||||
|
sleep: async (ms) => {
|
||||||
|
clock += ms
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
supervisor,
|
||||||
|
fetch: fetchFn,
|
||||||
|
children,
|
||||||
|
spawn,
|
||||||
|
fetchCalls,
|
||||||
|
setHealthy: (value) => {
|
||||||
|
healthy = value
|
||||||
|
},
|
||||||
|
onLoad: (handler) => {
|
||||||
|
loadHandler = handler
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 클라우드 실패 → 로컬 폴백 ─────────────────────────
|
||||||
|
|
||||||
|
describe('STTManager — 로컬 폴백은 복구 가능한 실패 + 설치된 로컬 엔진에서만', () => {
|
||||||
|
const MODEL_ID = 'r2-6-installed-model'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initInMemoryConfig()
|
||||||
|
resetSTTManagerForTests()
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
launchMock.offline = true
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
rmSync(join(getWhisperModelsDir(), MODEL_ID), { recursive: true, force: true })
|
||||||
|
resetInMemoryConfig()
|
||||||
|
resetSTTManagerForTests()
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function useOpenAI(fetchResponse: unknown): void {
|
||||||
|
const mgr = getSTTManager()
|
||||||
|
mgr.setProvider('openai')
|
||||||
|
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||||||
|
configSet('sttFallbackToLocal', true)
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fetchResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
function installModel(): void {
|
||||||
|
const dir = join(getWhisperModelsDir(), MODEL_ID)
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(join(dir, 'model.bin'), 'x')
|
||||||
|
configSet('sttModelId', MODEL_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('무음(STTNoAudioData)은 로컬로 넘기지 않고 그대로 던진다', async () => {
|
||||||
|
useOpenAI({ ok: true, status: 200, json: async () => ({ text: ' ' }) })
|
||||||
|
const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe')
|
||||||
|
|
||||||
|
await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.STTNoAudioData,
|
||||||
|
})
|
||||||
|
expect(localTranscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('로컬 모델이 설치되지 않았으면 초기화(=내려받기)를 시작하지 않고 원래 클라우드 에러로 끝난다', async () => {
|
||||||
|
useOpenAI({ ok: false, status: 503, text: async () => 'offline' })
|
||||||
|
configSet('sttModelId', 'large-v3-turbo')
|
||||||
|
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.STTTranscriptionFailed,
|
||||||
|
})
|
||||||
|
expect(init).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('엔진이 설치되지 않았으면(런타임 내려받기 필요) 초기화하지 않는다', async () => {
|
||||||
|
useOpenAI({ ok: false, status: 503, text: async () => 'offline' })
|
||||||
|
installModel()
|
||||||
|
launchMock.offline = false
|
||||||
|
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.STTTranscriptionFailed,
|
||||||
|
})
|
||||||
|
expect(init).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모델과 엔진이 로컬에 있으면 폴백해 로컬 결과를 돌려준다', async () => {
|
||||||
|
useOpenAI({ ok: false, status: 401, text: async () => 'Unauthorized' })
|
||||||
|
installModel()
|
||||||
|
const local = getLocalSTTService()
|
||||||
|
const internals = local as unknown as LocalInternals
|
||||||
|
const init = vi.spyOn(local, 'initialize').mockImplementation(async () => {
|
||||||
|
internals._modelReady = true
|
||||||
|
})
|
||||||
|
vi.spyOn(internals, '_sendToSidecar').mockResolvedValue(okResult('로컬 결과'))
|
||||||
|
|
||||||
|
const result = await getSTTManager().transcribe(Buffer.alloc(32000), { language: 'ko' })
|
||||||
|
expect(result.text).toBe('로컬 결과')
|
||||||
|
expect(init).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('폴백 정책: 무음/너무 짧음/취소는 복구 불가, 그 밖(인증·서버·네트워크)은 복구 가능', () => {
|
||||||
|
expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTNoAudioData, 'x'))).toBe(false)
|
||||||
|
expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTAudioTooShort, 'x'))).toBe(false)
|
||||||
|
expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTTranscriptionCancelled, 'x'))).toBe(false)
|
||||||
|
expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTTranscriptionFailed, '401'))).toBe(true)
|
||||||
|
expect(isRecoverableByLocalFallback(new Error('network'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. SidecarSupervisor — 기동 중 호출자는 헬스까지 기다린다 ──
|
||||||
|
|
||||||
|
describe('SidecarSupervisor', () => {
|
||||||
|
it('기동 중(헬스 전)에 들어온 두 번째 호출자는 즉시 통과하지 않고 헬스까지 기다린다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
const first = h.supervisor.ready()
|
||||||
|
await flush()
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||||
|
expect(h.supervisor.state).toBe('starting')
|
||||||
|
|
||||||
|
let secondSettled = false
|
||||||
|
const second = h.supervisor.ready().then((url) => {
|
||||||
|
secondSettled = true
|
||||||
|
return url
|
||||||
|
})
|
||||||
|
await flush(10)
|
||||||
|
expect(secondSettled).toBe(false)
|
||||||
|
expect(h.supervisor.isHealthy).toBe(false)
|
||||||
|
|
||||||
|
h.setHealthy(true)
|
||||||
|
const [a, b] = await Promise.all([first, second])
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(h.supervisor.isHealthy).toBe(true)
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('크래시 백오프 중 ready()는 새로 spawn하지 않고 예약된 재시작에 합류한다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
h.setHealthy(true)
|
||||||
|
await h.supervisor.ready()
|
||||||
|
const crashed = vi.fn()
|
||||||
|
h.supervisor.on('crashed', crashed)
|
||||||
|
|
||||||
|
h.children[0].die(1)
|
||||||
|
expect(h.supervisor.state).toBe('crashed')
|
||||||
|
expect(crashed).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1 }))
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([h.supervisor.ready(), h.supervisor.ready()])
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(h.supervisor.isHealthy).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('markStable 후에는 재시작 카운트가 초기화된다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
h.setHealthy(true)
|
||||||
|
await h.supervisor.ready()
|
||||||
|
h.children[0].die(1)
|
||||||
|
await h.supervisor.ready()
|
||||||
|
expect(h.supervisor.restartCount).toBe(1)
|
||||||
|
h.supervisor.markStable()
|
||||||
|
expect(h.supervisor.restartCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose 뒤 예약된 재시작은 취소되고 ready()는 거부된다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
h.setHealthy(true)
|
||||||
|
await h.supervisor.ready()
|
||||||
|
h.children[0].die(1)
|
||||||
|
const pending = h.supervisor.ready()
|
||||||
|
await h.supervisor.dispose()
|
||||||
|
await expect(pending).rejects.toBeInstanceOf(D3ROError)
|
||||||
|
await expect(h.supervisor.ready()).rejects.toBeInstanceOf(D3ROError)
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. LocalSTTService + 감독자 — 크래시 재시작 정책 ──────
|
||||||
|
|
||||||
|
describe('LocalSTTService — 사이드카 크래시 재시작', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initInMemoryConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
resetInMemoryConfig()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모델 로딩 중 반복 크래시는 MAX 재시작에서 멈추고 STTSidecarCrashed를 알린다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
vi.stubGlobal('fetch', h.fetch)
|
||||||
|
h.setHealthy(true)
|
||||||
|
// /load 도중 사이드카가 죽는다 (OOM/CUDA 실패 등)
|
||||||
|
h.onLoad(async () => {
|
||||||
|
h.children[h.children.length - 1].die(1)
|
||||||
|
throw new TypeError('fetch failed')
|
||||||
|
})
|
||||||
|
const local = new LocalSTTService({ supervisor: h.supervisor })
|
||||||
|
const errors: D3ROError[] = []
|
||||||
|
local.on('error', ({ error }) => errors.push(error))
|
||||||
|
|
||||||
|
await expect(local.initialize('tiny')).rejects.toBeInstanceOf(D3ROError)
|
||||||
|
for (let i = 0; i < 200 && h.supervisor.state !== 'stopped'; i++) {
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(h.supervisor.state).toBe('stopped')
|
||||||
|
// 최초 1회 + 재시작 3회 — 헬스 성공이 카운트를 되돌리지 않는다
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(4)
|
||||||
|
expect(errors.some((e) => e.code === ErrorCode.STTSidecarCrashed)).toBe(true)
|
||||||
|
expect(local.getStatus().engineState).toBe('error')
|
||||||
|
await local.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모델이 없던(UIA만 쓰던) 사이드카가 죽으면 되살리기만 하고 모델은 올리지 않는다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
vi.stubGlobal('fetch', h.fetch)
|
||||||
|
h.setHealthy(true)
|
||||||
|
const local = new LocalSTTService({ supervisor: h.supervisor })
|
||||||
|
|
||||||
|
await local.ensureSidecar()
|
||||||
|
h.children[0].die(1)
|
||||||
|
await local.ensureSidecar()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(h.fetchCalls.some((url) => url.endsWith('/load'))).toBe(false)
|
||||||
|
await local.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('크래시 재시작 대기 중 들어온 transcribe는 재시작(모델 재로딩) 결과를 기다려 전사한다', async () => {
|
||||||
|
const h = createHarness()
|
||||||
|
vi.stubGlobal('fetch', h.fetch)
|
||||||
|
h.setHealthy(true)
|
||||||
|
const local = new LocalSTTService({ supervisor: h.supervisor })
|
||||||
|
const internals = local as unknown as LocalInternals
|
||||||
|
await local.initialize('tiny')
|
||||||
|
|
||||||
|
h.children[0].die(1)
|
||||||
|
const send = vi.spyOn(internals, '_sendToSidecar').mockResolvedValue(okResult('재시작 후 전사'))
|
||||||
|
const result = await local.transcribe(Buffer.alloc(32000))
|
||||||
|
|
||||||
|
expect(result.text).toBe('재시작 후 전사')
|
||||||
|
expect(send).toHaveBeenCalledTimes(1)
|
||||||
|
expect(h.fetchCalls.filter((url) => url.endsWith('/load'))).toHaveLength(2)
|
||||||
|
expect(local.currentModelId).toBe('tiny')
|
||||||
|
await local.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -651,7 +651,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
|
||||||
expect(res.text).toBe('타임아웃 로컬 대체')
|
expect(res.text).toBe('타임아웃 로컬 대체')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('50. Provider returning empty text triggers fallback or error', async () => {
|
// 무음(빈 전사)은 로컬로 다시 돌려도 결과가 같으므로 폴백하지 않고 STTNoAudioData 로 끝난다.
|
||||||
|
it('50. Provider returning empty text ends with no-audio error and skips local fallback', async () => {
|
||||||
const mgr = getSTTManager()
|
const mgr = getSTTManager()
|
||||||
mgr.setProvider('openai')
|
mgr.setProvider('openai')
|
||||||
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||||||
|
|
@ -667,7 +668,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||||||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||||||
text: '빈값 감지 후 로컬 복구',
|
text: '빈값 감지 후 로컬 복구',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
duration: 1.0,
|
duration: 1.0,
|
||||||
|
|
@ -675,11 +676,11 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
|
||||||
segments: [],
|
segments: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData })
|
||||||
expect(res.text).toBe('빈값 감지 후 로컬 복구')
|
expect(localTranscribe).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('51. Provider returning null text triggers fallback', async () => {
|
it('51. Provider returning null text ends with no-audio error and skips local fallback', async () => {
|
||||||
const mgr = getSTTManager()
|
const mgr = getSTTManager()
|
||||||
mgr.setProvider('custom')
|
mgr.setProvider('custom')
|
||||||
mgr.setProviderConfig('custom', { baseUrl: 'http://custom:8000' })
|
mgr.setProviderConfig('custom', { baseUrl: 'http://custom:8000' })
|
||||||
|
|
@ -695,7 +696,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||||||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||||||
text: '널값 감지 로컬 복구',
|
text: '널값 감지 로컬 복구',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
duration: 1.0,
|
duration: 1.0,
|
||||||
|
|
@ -703,8 +704,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
|
||||||
segments: [],
|
segments: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData })
|
||||||
expect(res.text).toBe('널값 감지 로컬 복구')
|
expect(localTranscribe).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => {
|
it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue