- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError) - electron-builder.yml (NSIS, asarUnpack, extraResources) - .gitlab-ci.yml (lint, typecheck, test, build, release) - SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동 - AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화 - TextInsertService: 간이 삽입 검증 (EditMonitor 경량) - 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸 - AudioCaptureService/LocalSTTService: 번들 경로 자동 감지 - 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템) - 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings - 효과음 WAV 생성: recording-start, recording-stop, error - EPIPE 에러 핸들링 추가
722 lines
20 KiB
TypeScript
722 lines
20 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 { getLogger } from './LoggerService'
|
|
import { configGet } from './ConfigService'
|
|
import { getSidecarCommand } from '../utils/paths'
|
|
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 async _spawnSidecar(): Promise<void> {
|
|
const { command, args } = getSidecarCommand()
|
|
const fullArgs = [...args, '--port', String(this._port)]
|
|
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
try {
|
|
this._sidecarProcess = spawn(
|
|
command,
|
|
fullArgs,
|
|
{
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
},
|
|
)
|
|
} catch (err) {
|
|
const d3roErr = new D3ROError(
|
|
ErrorCode.STTSidecarSpawnFailed,
|
|
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
|
)
|
|
reject(d3roErr)
|
|
return
|
|
}
|
|
|
|
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 }
|