// 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 { 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 { 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((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.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 { 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 { const { command, args } = getSidecarCommand() const fullArgs = [...args, '--port', String(this._port)] logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`) return new Promise((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 { 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 { 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 { 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 { 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 { return new Promise((resolve) => setTimeout(resolve, ms)) } // ── 타입 안전한 이벤트 메서드 오버라이드 ── override emit( event: K, payload: LocalSTTEvents[K], ): boolean { return super.emit(event, payload) } override on( event: K, listener: (payload: LocalSTTEvents[K]) => void, ): this { return super.on(event, listener) } override off( 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 }