// 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 { 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 { 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와 모델을 미리 데운다. * 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로 * 실패는 조용히 경고로만 남기고 예외를 던지지 않는다. */ async warmUp(): Promise { 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 { 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 { 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 { try { await fetch(`${this._baseUrl}/download/cancel`, { method: 'POST', signal: AbortSignal.timeout(3000), }) } catch { // sidecar 미기동/통신 불가 — 취소할 다운로드가 없음 } } /** 현재 상태 조회 */ 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 관리 ── /** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */ private async _ensureSidecarRunning(): Promise { 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 { return new Promise((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 { // 포트 점유 시 동적으로 다음 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((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, ): 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 { 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 { 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 { 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 { 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 { 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 function resetLocalSTTServiceForTests(): void { if (instance) instance.removeAllListeners() instance = null } export { LocalSTTService, STTState }