Some checks failed
deploy-site / deploy (push) Failing after 1m15s
Auto-update could not work at all: the installer was 189 MB because it carried the local speech engine and ffmpeg, and the download feed rejects uploads over about 100 MiB, so update metadata could never be published. The installer now leaves those components out and the app fetches them the first time they are needed, verifying every part and the joined archive before installing. The installer is 90.6 MiB, the update feed is published again, and updates stay small because the engine is not re-sent on every release. The fetch is visible and recoverable: the download runs with progress, a failed install cleans up after itself, and Settings > STT shows the runtime status with a manual download action for when the automatic one cannot run.
1117 lines
33 KiB
TypeScript
1117 lines
33 KiB
TypeScript
// src/main/services/LocalSTTService.ts
|
||
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
|
||
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
|
||
// 상태 머신 및 이중 조건 플러시 패턴 적용.
|
||
|
||
import { EventEmitter } from 'events'
|
||
import { type ChildProcess, spawn } from 'child_process'
|
||
import { createServer } from 'net'
|
||
import { existsSync } from 'fs'
|
||
import { join } from 'path'
|
||
import { getLogger } from './LoggerService'
|
||
import { configGet } from './ConfigService'
|
||
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
||
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||
import type {
|
||
STTModel,
|
||
STTStatus,
|
||
STTEngineState,
|
||
DownloadProgressEvent,
|
||
} from '@d3ro/core/types'
|
||
|
||
// ── 내부 타입 정의 ────────────────────────────────────────
|
||
|
||
/** STT 엔진 상태 머신 */
|
||
const enum STTState {
|
||
Uninitialized = 'uninitialized',
|
||
Loading = 'loading',
|
||
Ready = 'ready',
|
||
Transcribing = 'transcribing',
|
||
Error = 'error',
|
||
}
|
||
|
||
/** 전사 결과 세그먼트 */
|
||
export interface TranscriptionSegment {
|
||
readonly text: string
|
||
readonly start: number
|
||
readonly end: number
|
||
readonly confidence: number
|
||
}
|
||
|
||
/** 전사 결과 */
|
||
export interface TranscriptionResult {
|
||
readonly text: string
|
||
readonly segments: TranscriptionSegment[]
|
||
readonly language: string
|
||
readonly duration: number
|
||
readonly processingTime: number
|
||
}
|
||
|
||
/** 전사 옵션 */
|
||
export interface TranscribeOptions {
|
||
language?: string
|
||
initialPrompt?: string
|
||
vadFilter?: boolean
|
||
/** 음 중 실시간 미리보기 요청 — 상태/이벤트를 건드리지 않고 greedy 디코딩을 사용한다. */
|
||
partial?: boolean
|
||
}
|
||
|
||
/** sidecar /health 응답 */
|
||
interface HealthResponse {
|
||
status: string
|
||
model: string | null
|
||
gpu: boolean
|
||
}
|
||
|
||
/** sidecar /load 응답 */
|
||
interface LoadResponse {
|
||
status: string
|
||
model_id: string
|
||
load_time_ms: number
|
||
}
|
||
|
||
/** sidecar /download/status 응답 */
|
||
interface DownloadStatusResponse {
|
||
status: 'idle' | 'downloading' | 'done' | 'cancelled' | 'error'
|
||
model_id: string | null
|
||
percent: number
|
||
downloaded_bytes: number
|
||
total_bytes: number
|
||
bytes_per_second: number
|
||
message: string | null
|
||
}
|
||
|
||
/** sidecar /transcribe 응답 */
|
||
interface TranscribeResponse {
|
||
text: string
|
||
segments: Array<{
|
||
text: string
|
||
start: number
|
||
end: number
|
||
avg_logprob: number
|
||
}>
|
||
language: string
|
||
duration: number
|
||
processing_time: number
|
||
}
|
||
|
||
/** 이벤트 페이로드 */
|
||
export interface LocalSTTEvents {
|
||
'transcription-delta': { text: string; isFinal: boolean }
|
||
'transcription-complete': { result: TranscriptionResult }
|
||
'model-loaded': { model: STTModel; loadTimeMs: number }
|
||
'download-progress': DownloadProgressEvent
|
||
/** 런타임(엔진/ffmpeg) 내려받기 진행률 — 필요할 때 자동 설치 */
|
||
'runtime-progress': {
|
||
component: string
|
||
phase: 'index' | 'downloading' | 'extracting' | 'done'
|
||
percent: number
|
||
downloadedBytes: number
|
||
totalBytes: number
|
||
bytesPerSecond: number
|
||
}
|
||
'error': { error: D3ROError }
|
||
}
|
||
|
||
// ── 상수 ──────────────────────────────────────────────────
|
||
|
||
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_PARTIAL_TIMEOUT_MS = 15000
|
||
|
||
/** 알려진 Whisper 모델 카탈로그 */
|
||
const MODEL_CATALOG: STTModel[] = [
|
||
{
|
||
id: 'tiny',
|
||
name: 'Tiny',
|
||
sizeBytes: 75_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 1,
|
||
speed: 5,
|
||
},
|
||
{
|
||
id: 'base',
|
||
name: 'Base',
|
||
sizeBytes: 141_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 2,
|
||
speed: 4,
|
||
},
|
||
{
|
||
id: 'small',
|
||
name: 'Small',
|
||
sizeBytes: 466_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 3,
|
||
speed: 3,
|
||
},
|
||
{
|
||
id: 'medium',
|
||
name: 'Medium',
|
||
sizeBytes: 1_500_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 4,
|
||
speed: 2,
|
||
},
|
||
{
|
||
id: 'large-v3',
|
||
name: 'Large V3',
|
||
sizeBytes: 3_100_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 5,
|
||
speed: 1,
|
||
},
|
||
{
|
||
id: 'large-v3-turbo',
|
||
name: 'Large V3 Turbo',
|
||
sizeBytes: 1_600_000_000,
|
||
downloaded: false,
|
||
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||
accuracy: 5,
|
||
speed: 3,
|
||
},
|
||
]
|
||
|
||
// ── 서비스 구현 ───────────────────────────────────────────
|
||
|
||
const logger = getLogger('LocalSTTService')
|
||
|
||
class LocalSTTService extends EventEmitter {
|
||
constructor() {
|
||
super()
|
||
// EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다
|
||
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
|
||
// 실제 로깅은 _emitError에서 수행.
|
||
this.on('error', () => { /* default sink */ })
|
||
// 런타임 내려받기 진행률을 그대로 중계한다 (IPC가 renderer로 전달)
|
||
getRuntimeProvisioner().on('progress', (payload) => {
|
||
this.emit('runtime-progress', payload)
|
||
})
|
||
}
|
||
|
||
private _state: STTState = STTState.Uninitialized
|
||
private _sidecarProcess: ChildProcess | null = null
|
||
private _port: number = SIDECAR_PORT
|
||
private _currentModelId: string | null = null
|
||
private _restartCount: number = 0
|
||
private _disposed: boolean = false
|
||
private _gpuAccelerated: boolean = false
|
||
|
||
// ── 이중 조건 플러시 (Speakly 패턴) ──
|
||
private _modelReady: boolean = false
|
||
private _audioBuffer: Buffer[] = []
|
||
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
|
||
private _pendingReject: ((error: D3ROError) => void) | null = null
|
||
// errorEmitted 플래그로 이벤트 중복 방지
|
||
private _errorEmitted: boolean = false
|
||
|
||
// ── 상태 접근자 ──
|
||
|
||
get state(): STTState {
|
||
return this._state
|
||
}
|
||
|
||
get currentModelId(): string | null {
|
||
return this._currentModelId
|
||
}
|
||
|
||
/** sidecar HTTP 기본 URL — IPv4 루프백 고정 (localhost는 ::1로 해석되어 실패) */
|
||
private get _baseUrl(): string {
|
||
return getSidecarBaseUrl(this._port)
|
||
}
|
||
|
||
// ── 공개 메서드 ──
|
||
|
||
/**
|
||
* Whisper sidecar 프로세스 시작 + 모델 로딩.
|
||
* 이미 로딩된 모델과 같으면 무시.
|
||
*/
|
||
async initialize(modelId?: string): Promise<void> {
|
||
const targetModel = modelId ?? configGet('sttModelId')
|
||
|
||
if (this._disposed) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTSidecarSpawnFailed,
|
||
'LocalSTTService가 이미 dispose되었습니다',
|
||
)
|
||
}
|
||
|
||
// 이미 같은 모델이 로딩된 상태면 무시
|
||
if (
|
||
this._state === STTState.Ready &&
|
||
this._currentModelId === targetModel
|
||
) {
|
||
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
|
||
return
|
||
}
|
||
|
||
this._setState(STTState.Loading)
|
||
this._errorEmitted = false
|
||
|
||
try {
|
||
// sidecar가 아직 실행 중이 아니면 시작
|
||
await this._ensureSidecarRunning()
|
||
|
||
// 모델 로딩
|
||
await this._loadModel(targetModel)
|
||
this._currentModelId = targetModel
|
||
this._modelReady = true
|
||
this._setState(STTState.Ready)
|
||
|
||
// 이중 조건 플러시 시도
|
||
this._tryFlushAll()
|
||
|
||
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
||
} catch (err) {
|
||
this._setState(STTState.Error)
|
||
const d3roErr =
|
||
err instanceof D3ROError
|
||
? err
|
||
: new D3ROError(
|
||
ErrorCode.STTModelLoadFailed,
|
||
`STT 초기화 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||
)
|
||
this._emitError(d3roErr)
|
||
throw d3roErr
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 오디오 버퍼를 전사.
|
||
* PCM16 16kHz mono 포맷이어야 한다.
|
||
* 이중 조건 플러시: 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
|
||
*/
|
||
async transcribe(
|
||
audioBuffer: Buffer,
|
||
options?: TranscribeOptions,
|
||
): Promise<TranscriptionResult> {
|
||
if (this._disposed) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTTranscriptionFailed,
|
||
'LocalSTTService가 이미 dispose되었습니다',
|
||
)
|
||
}
|
||
|
||
if (audioBuffer.length === 0) {
|
||
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
|
||
}
|
||
|
||
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
|
||
if (!this._modelReady) {
|
||
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
|
||
this._audioBuffer.push(audioBuffer)
|
||
|
||
return new Promise<TranscriptionResult>((resolve, reject) => {
|
||
this._pendingResolve = resolve
|
||
this._pendingReject = reject
|
||
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
|
||
this._tryFlushAll()
|
||
})
|
||
}
|
||
|
||
// 모델 준비 완료 상태: 직접 전사
|
||
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를 판정한다.
|
||
*/
|
||
getModels(): STTModel[] {
|
||
const modelsDir = getWhisperModelsDir()
|
||
return MODEL_CATALOG.map((m) => ({
|
||
...m,
|
||
downloaded:
|
||
m.id === this._currentModelId ||
|
||
existsSync(join(modelsDir, m.id, 'model.bin')),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* 모델을 사전 다운로드한다 (sidecar /download + 진행률 폴링).
|
||
* 진행 중 'download-progress' 이벤트를 emit하며, 완료 시 resolve.
|
||
*/
|
||
async downloadModel(modelId: string): Promise<void> {
|
||
if (this._disposed) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelDownloadFailed,
|
||
'LocalSTTService가 이미 dispose되었습니다',
|
||
)
|
||
}
|
||
|
||
await this._ensureSidecarRunning()
|
||
|
||
const startRes = await fetch(`${this._baseUrl}/download`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model_id: modelId }),
|
||
signal: AbortSignal.timeout(10000),
|
||
})
|
||
|
||
// 409 = 이미 다운로드 진행 중 — 실패가 아니라 기존 진행에 합류해 폴링
|
||
if (!startRes.ok && startRes.status !== 409) {
|
||
const text = await startRes.text()
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelDownloadFailed,
|
||
`다운로드 시작 실패 (HTTP ${startRes.status}): ${text}`,
|
||
)
|
||
}
|
||
|
||
if (startRes.ok) {
|
||
const started = (await startRes.json()) as { status: string }
|
||
if (started.status === 'done') {
|
||
this._emitDownloadProgress(modelId, 100, 0, 0, 0)
|
||
return
|
||
}
|
||
} else {
|
||
logger.info(`모델 다운로드 이미 진행 중 — 폴링에 합류: ${modelId}`)
|
||
}
|
||
|
||
// 진행률 폴링 — sidecar 통신 실패가 연속되면 중단
|
||
let consecutiveFailures = 0
|
||
for (;;) {
|
||
await this._sleep(500)
|
||
|
||
let status: DownloadStatusResponse
|
||
try {
|
||
const res = await fetch(`${this._baseUrl}/download/status`, {
|
||
signal: AbortSignal.timeout(3000),
|
||
})
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
status = (await res.json()) as DownloadStatusResponse
|
||
consecutiveFailures = 0
|
||
} catch (err) {
|
||
consecutiveFailures++
|
||
if (consecutiveFailures >= 5) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelDownloadFailed,
|
||
`다운로드 상태 조회 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||
)
|
||
}
|
||
continue
|
||
}
|
||
|
||
this._emitDownloadProgress(
|
||
modelId,
|
||
status.percent,
|
||
status.downloaded_bytes,
|
||
status.total_bytes,
|
||
status.bytes_per_second,
|
||
)
|
||
|
||
if (status.status === 'done') {
|
||
logger.info(`모델 다운로드 완료: ${modelId}`)
|
||
return
|
||
}
|
||
if (status.status === 'cancelled') {
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelDownloadCancelled,
|
||
`모델 다운로드 취소: ${modelId}`,
|
||
)
|
||
}
|
||
if (status.status === 'error') {
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelDownloadFailed,
|
||
`모델 다운로드 실패: ${status.message ?? 'unknown'}`,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 진행 중인 모델 다운로드 취소 요청 */
|
||
async cancelDownload(): Promise<void> {
|
||
try {
|
||
await fetch(`${this._baseUrl}/download/cancel`, {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(3000),
|
||
})
|
||
} catch {
|
||
// sidecar 미기동/통신 불가 — 취소할 다운로드가 없음
|
||
}
|
||
}
|
||
|
||
/** 현재 상태 조회 */
|
||
getStatus(): STTStatus {
|
||
const stateMap: Record<STTState, STTEngineState> = {
|
||
[STTState.Uninitialized]: 'not-installed' as STTEngineState,
|
||
[STTState.Loading]: 'loading' as STTEngineState,
|
||
[STTState.Ready]: 'ready' as STTEngineState,
|
||
[STTState.Transcribing]: 'processing' as STTEngineState,
|
||
[STTState.Error]: 'error' as STTEngineState,
|
||
}
|
||
|
||
return {
|
||
engineState: stateMap[this._state],
|
||
activeModel: this._currentModelId,
|
||
engineVersion: null,
|
||
gpuAccelerated: this._gpuAccelerated,
|
||
}
|
||
}
|
||
|
||
/** sidecar 프로세스 종료 및 리소스 정리 */
|
||
async dispose(): Promise<void> {
|
||
if (this._disposed) return
|
||
this._disposed = true
|
||
|
||
logger.info('LocalSTTService dispose 시작')
|
||
|
||
// pending promise를 reject
|
||
if (this._pendingReject) {
|
||
this._pendingReject(
|
||
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
|
||
)
|
||
this._pendingResolve = null
|
||
this._pendingReject = null
|
||
}
|
||
|
||
this._audioBuffer = []
|
||
this._modelReady = false
|
||
|
||
await this._shutdownSidecar()
|
||
this._setState(STTState.Uninitialized)
|
||
|
||
logger.info('LocalSTTService dispose 완료')
|
||
}
|
||
|
||
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
|
||
|
||
/**
|
||
* 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
|
||
* 설정 메시지(모델) 먼저, 오디오 데이터 후.
|
||
*/
|
||
private _tryFlushAll(): void {
|
||
if (!this._modelReady) return
|
||
if (this._audioBuffer.length === 0) return
|
||
if (!this._pendingResolve) return
|
||
|
||
const merged = Buffer.concat(this._audioBuffer)
|
||
this._audioBuffer = []
|
||
|
||
const resolve = this._pendingResolve
|
||
const reject = this._pendingReject
|
||
this._pendingResolve = null
|
||
this._pendingReject = null
|
||
|
||
this._sendToSidecar(merged)
|
||
.then(resolve)
|
||
.catch((err: unknown) => {
|
||
if (reject) {
|
||
reject(
|
||
err instanceof D3ROError
|
||
? err
|
||
: new D3ROError(
|
||
ErrorCode.STTTranscriptionFailed,
|
||
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||
),
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ── Sidecar 관리 ──
|
||
|
||
/** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */
|
||
private async _ensureSidecarRunning(): Promise<void> {
|
||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||
await this._spawnSidecar()
|
||
await this._waitForHealth()
|
||
}
|
||
}
|
||
|
||
private _emitDownloadProgress(
|
||
modelId: string,
|
||
percent: number,
|
||
downloadedBytes: number,
|
||
totalBytes: number,
|
||
bytesPerSecond: number,
|
||
): void {
|
||
this.emit('download-progress', {
|
||
modelId,
|
||
percent,
|
||
downloadedBytes,
|
||
totalBytes,
|
||
bytesPerSecond,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 시작 포트부터 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
|
||
|
||
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
|
||
}
|
||
|
||
logger.info('로컬 음성 엔진이 없습니다 — 자동 다운로드를 시작합니다')
|
||
await getRuntimeProvisioner().ensure('sidecar')
|
||
const launch = getSidecarCommand()
|
||
logger.info(`런타임 설치 후 사이드카 경로: ${launch.command} (${launch.source})`)
|
||
return launch
|
||
}
|
||
|
||
/** 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()
|
||
|
||
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)`,
|
||
)
|
||
}
|
||
|
||
private async _loadModel(modelId: string): Promise<void> {
|
||
logger.info(`모델 로딩 시작: ${modelId}`)
|
||
const startTime = Date.now()
|
||
|
||
const response = await fetch(`${this._baseUrl}/load`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model_id: modelId }),
|
||
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
throw new D3ROError(
|
||
ErrorCode.STTModelLoadFailed,
|
||
`모델 로딩 실패 (HTTP ${response.status}): ${errorText}`,
|
||
)
|
||
}
|
||
|
||
const data = (await response.json()) as LoadResponse
|
||
const loadTimeMs = Date.now() - startTime
|
||
|
||
const model = MODEL_CATALOG.find((m) => m.id === modelId)
|
||
if (model) {
|
||
this.emit('model-loaded', {
|
||
model: { ...model, downloaded: true },
|
||
loadTimeMs,
|
||
})
|
||
}
|
||
|
||
logger.info(
|
||
`모델 로딩 완료: ${data.model_id}, ${loadTimeMs}ms`,
|
||
)
|
||
}
|
||
|
||
// ── HTTP 전사 요청 ──
|
||
|
||
private async _sendToSidecar(
|
||
audioBuffer: Buffer,
|
||
options?: TranscribeOptions,
|
||
): Promise<TranscriptionResult> {
|
||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTSidecarCommunicationFailed,
|
||
'Sidecar 프로세스가 실행 중이 아닙니다',
|
||
)
|
||
}
|
||
|
||
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 ?? !isPartial
|
||
const initialPrompt = options?.initialPrompt ?? ''
|
||
|
||
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
|
||
const formData = new FormData()
|
||
// Buffer → ArrayBuffer 복사 후 Blob 생성 (Node/Electron 타입 호환)
|
||
const arrayBuf = audioBuffer.buffer.slice(
|
||
audioBuffer.byteOffset,
|
||
audioBuffer.byteOffset + audioBuffer.byteLength,
|
||
) as ArrayBuffer
|
||
formData.append(
|
||
'audio',
|
||
new Blob([arrayBuf], { type: 'application/octet-stream' }),
|
||
'audio.pcm',
|
||
)
|
||
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(`${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()
|
||
throw new D3ROError(
|
||
ErrorCode.STTTranscriptionFailed,
|
||
`전사 실패 (HTTP ${response.status}): ${errorText}`,
|
||
)
|
||
}
|
||
|
||
const data = (await response.json()) as TranscribeResponse
|
||
const processingTime = Date.now() - startTime
|
||
|
||
const result: TranscriptionResult = {
|
||
text: data.text,
|
||
segments: data.segments.map((seg) => ({
|
||
text: seg.text,
|
||
start: seg.start,
|
||
end: seg.end,
|
||
confidence: Math.exp(seg.avg_logprob),
|
||
})),
|
||
language: data.language,
|
||
duration: data.duration,
|
||
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 })
|
||
|
||
this._setState(STTState.Ready)
|
||
|
||
logger.info(
|
||
`전사 완료: "${result.text.substring(0, 50)}..." (${processingTime}ms, lang=${result.language})`,
|
||
)
|
||
|
||
return result
|
||
} catch (err) {
|
||
if (!isPartial) {
|
||
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
|
||
}
|
||
|
||
if (err instanceof D3ROError) {
|
||
throw err
|
||
}
|
||
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
|
||
// 타임아웃 구분
|
||
if (message.includes('abort') || message.includes('timeout')) {
|
||
throw new D3ROError(
|
||
ErrorCode.STTTranscriptionTimeout,
|
||
`전사 타임아웃: ${message}`,
|
||
)
|
||
}
|
||
|
||
throw new D3ROError(
|
||
ErrorCode.STTTranscriptionFailed,
|
||
`전사 실패: ${message}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// ── Sidecar Crash 처리 ──
|
||
|
||
private _handleSidecarCrash(): void {
|
||
if (this._disposed) return
|
||
|
||
this._restartCount++
|
||
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)
|
||
|
||
// pending promise reject
|
||
if (this._pendingReject) {
|
||
this._pendingReject(err)
|
||
this._pendingResolve = null
|
||
this._pendingReject = null
|
||
}
|
||
return
|
||
}
|
||
|
||
// 비동기 재시작
|
||
const modelToReload = this._currentModelId
|
||
this._currentModelId = null
|
||
this._modelReady = false
|
||
|
||
// setTimeout으로 이벤트 루프에 양보
|
||
setTimeout(() => {
|
||
if (this._disposed) return
|
||
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
|
||
logger.error(
|
||
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||
)
|
||
})
|
||
}, 1000 * this._restartCount) // 점진적 백오프
|
||
}
|
||
|
||
private async _shutdownSidecar(): Promise<void> {
|
||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||
this._sidecarProcess = null
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ── 내부 유틸 ──
|
||
|
||
private _setState(newState: STTState): void {
|
||
if (this._state === newState) return
|
||
const prev = this._state
|
||
this._state = newState
|
||
logger.debug(`STTState: ${prev} -> ${newState}`)
|
||
}
|
||
|
||
private _emitError(error: D3ROError): void {
|
||
if (this._errorEmitted) return
|
||
this._errorEmitted = true
|
||
this.emit('error', { error })
|
||
logger.error(`STT 에러: [${error.code}] ${error.message}`)
|
||
}
|
||
|
||
private _sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||
}
|
||
|
||
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
|
||
|
||
override emit<K extends keyof LocalSTTEvents>(
|
||
event: K,
|
||
payload: LocalSTTEvents[K],
|
||
): boolean {
|
||
return super.emit(event, payload)
|
||
}
|
||
|
||
override on<K extends keyof LocalSTTEvents>(
|
||
event: K,
|
||
listener: (payload: LocalSTTEvents[K]) => void,
|
||
): this {
|
||
return super.on(event, listener)
|
||
}
|
||
|
||
override off<K extends keyof LocalSTTEvents>(
|
||
event: K,
|
||
listener: (payload: LocalSTTEvents[K]) => void,
|
||
): this {
|
||
return super.off(event, listener)
|
||
}
|
||
}
|
||
|
||
// ── 싱글톤 ──
|
||
|
||
let instance: LocalSTTService | null = null
|
||
|
||
export function getLocalSTTService(): LocalSTTService {
|
||
if (!instance) {
|
||
instance = new LocalSTTService()
|
||
}
|
||
return instance
|
||
}
|
||
|
||
export function resetLocalSTTServiceForTests(): void {
|
||
if (instance) instance.removeAllListeners()
|
||
instance = null
|
||
}
|
||
|
||
export { LocalSTTService, STTState }
|