Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
729
src/main/services/LocalSTTService.ts
Normal file
729
src/main/services/LocalSTTService.ts
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
// src/main/services/LocalSTTService.ts
|
||||
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
|
||||
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
|
||||
// 상태 머신 및 이중 조건 플러시 패턴 적용.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { type ChildProcess, spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type { STTModel, STTStatus, STTEngineState } from '@shared/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
|
||||
}
|
||||
|
||||
/** 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 /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 }
|
||||
'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
|
||||
|
||||
/** 알려진 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,
|
||||
},
|
||||
]
|
||||
|
||||
// ── 서비스 구현 ───────────────────────────────────────────
|
||||
|
||||
const logger = getLogger('LocalSTTService')
|
||||
|
||||
class LocalSTTService extends EventEmitter {
|
||||
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
|
||||
}
|
||||
|
||||
// ── 공개 메서드 ──
|
||||
|
||||
/**
|
||||
* 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가 아직 실행 중이 아니면 시작
|
||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||||
await this._spawnSidecar()
|
||||
await this._waitForHealth()
|
||||
}
|
||||
|
||||
// 모델 로딩
|
||||
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에 위임 (faster-whisper가 자동 다운로드).
|
||||
*/
|
||||
getModels(): STTModel[] {
|
||||
return MODEL_CATALOG.map((m) => ({
|
||||
...m,
|
||||
// 현재 로딩된 모델은 downloaded=true로 표시
|
||||
downloaded: m.id === this._currentModelId ? true : m.downloaded,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 현재 상태 조회 */
|
||||
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 관리 ──
|
||||
|
||||
private _getSidecarPath(): string {
|
||||
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
|
||||
return path.join(basePath, 'sidecar', 'main.py')
|
||||
}
|
||||
|
||||
private async _spawnSidecar(): Promise<void> {
|
||||
const sidecarPath = this._getSidecarPath()
|
||||
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||
|
||||
try {
|
||||
this._sidecarProcess = spawn(
|
||||
pythonCmd,
|
||||
[sidecarPath, '--port', String(this._port)],
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
const d3roErr = new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
reject(d3roErr)
|
||||
return
|
||||
}
|
||||
|
||||
const sidecarLogger = getLogger('sidecar')
|
||||
|
||||
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const text = data.toString().trim()
|
||||
if (text) {
|
||||
sidecarLogger.info(text)
|
||||
}
|
||||
})
|
||||
|
||||
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const text = data.toString().trim()
|
||||
if (text) {
|
||||
sidecarLogger.warn(text)
|
||||
}
|
||||
})
|
||||
|
||||
this._sidecarProcess.on('error', (err: Error) => {
|
||||
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
|
||||
reject(
|
||||
new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`Sidecar 프로세스 에러: ${err.message}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
|
||||
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
|
||||
this._sidecarProcess = null
|
||||
this._modelReady = false
|
||||
|
||||
if (!this._disposed) {
|
||||
this._handleSidecarCrash()
|
||||
}
|
||||
})
|
||||
|
||||
// spawn 자체는 비동기적이므로 즉시 resolve
|
||||
// 실제 준비는 _waitForHealth에서 확인
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
private async _waitForHealth(): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${this._port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as HealthResponse
|
||||
this._gpuAccelerated = data.gpu
|
||||
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(`http://localhost:${this._port}/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 프로세스가 실행 중이 아닙니다',
|
||||
)
|
||||
}
|
||||
|
||||
this._setState(STTState.Transcribing)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const language = options?.language ?? configGet('sttLanguage')
|
||||
const vadFilter = options?.vadFilter ?? true
|
||||
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))
|
||||
if (initialPrompt) {
|
||||
formData.append('initial_prompt', initialPrompt)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`http://localhost:${this._port}/transcribe`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// 중간 결과 이벤트 (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) {
|
||||
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(`http://localhost:${this._port}/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 { LocalSTTService, STTState }
|
||||
Loading…
Add table
Add a link
Reference in a new issue