// apps/desktop/src/main/services/stt/STTManager.ts // 통합 STT 매니저: Local Whisper 및 클라우드 AI 서비스 6종 디스패처 및 자동 폴백 import { EventEmitter } from 'events' import { getLogger } from '../LoggerService' import { configGet, configSet } from '../ConfigService' import { getLocalSTTService } from '../LocalSTTService' import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService' import type { STTProviderType, STTProviderInfo, STTProviderConfig, TestSTTConnectionParams, TestSTTConnectionResult, STTStatus, } from '@d3ro/core/types' import type { ISTTDriver, STTEngine } from './types' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { OpenAIDriver } from './drivers/OpenAIDriver' import { GroqDriver } from './drivers/GroqDriver' import { DeepgramDriver } from './drivers/DeepgramDriver' import { AssemblyAIDriver } from './drivers/AssemblyAIDriver' import { GoogleDriver } from './drivers/GoogleDriver' import { CustomDriver } from './drivers/CustomDriver' import { D3ROCloudDriver } from './drivers/D3ROCloudDriver' import { normalizeLoopbackUrl } from '../../utils/loopback' import { isRecoverableByLocalFallback } from './fallbackPolicy' const logger = getLogger('STTManager') /** * 클라우드 실패 → 로컬 폴백의 전체 상한(모델 로딩 포함). * 로컬 엔진의 각 단계에도 타임아웃이 있지만, 폴백 경로가 어떤 이유로든 멈추면 * 받아쓰기 액션 큐 전체가 굳으므로 여기서 한 번 더 끊는다. */ export const LOCAL_FALLBACK_TIMEOUT_MS = 180_000 function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timer: NodeJS.Timeout | null = null const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new D3ROError(ErrorCode.STTTranscriptionTimeout, message)), timeoutMs) }) return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer) }) } export const STT_PROVIDERS_META: STTProviderInfo[] = [ { id: 'local', name: 'Local Whisper', description: '100% 완전 오프라인, 무료, 프라이버시 보호', badge: 'Offline · Free', requiresApiKey: false, defaultModel: 'large-v3-turbo', models: ['tiny', 'base', 'small', 'medium', 'large-v3', 'large-v3-turbo'], isCloud: false, }, { id: 'd3ro-cloud', name: 'D3RO Cloud STT (Managed)', description: 'D3RO 클라우드 매니지드 전사 서비스 (키 설정 불필요)', badge: 'Cloud · Zero Config', requiresApiKey: false, defaultModel: 'default', defaultBaseUrl: 'http://127.0.0.1:5000', models: ['default', 'whisper-large-v3-turbo', 'nova-3', 'gemini-2.0-flash'], isCloud: true, }, { id: 'openai', name: 'OpenAI Whisper', description: '공식 OpenAI 고품질 다국어 음성 인식', badge: 'Standard · High Accuracy', requiresApiKey: true, defaultModel: 'whisper-1', defaultBaseUrl: 'https://api.openai.com/v1', models: ['whisper-1', 'gpt-4o-audio-preview'], isCloud: true, }, { id: 'groq', name: 'Groq Whisper LPU', description: 'LPU 가속 기반 초저지연(~200ms) 초고속 인식', badge: 'Ultra Fast (~200ms)', requiresApiKey: true, defaultModel: 'whisper-large-v3-turbo', defaultBaseUrl: 'https://api.groq.com/openai/v1', models: ['whisper-large-v3-turbo', 'whisper-large-v3', 'distil-whisper-large-v3-en'], isCloud: true, }, { id: 'deepgram', name: 'Deepgram Nova-3', description: '업계 최고 인식률, 스마트 문장 부호 및 포맷팅', badge: 'Industry Benchmark', requiresApiKey: true, defaultModel: 'nova-3', defaultBaseUrl: 'https://api.deepgram.com', models: ['nova-3', 'nova-2', 'nova-2-general', 'nova-2-meeting'], isCloud: true, }, { id: 'assemblyai', name: 'AssemblyAI Universal-2', description: '고도화된 음향 모델 및 문맥 인식 STT', badge: 'Advanced Acoustic', requiresApiKey: true, defaultModel: 'best', defaultBaseUrl: 'https://api.assemblyai.com/v2', models: ['best', 'nano'], isCloud: true, }, { id: 'google', name: 'Google Gemini 2.0 Flash / Cloud STT', description: 'Gemini 2.0 Flash 기반 한국어/다국어 인식', badge: 'Multilingual · Asian Languages', requiresApiKey: true, defaultModel: 'gemini-2.0-flash', defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta', models: ['gemini-2.0-flash', 'gemini-1.5-flash'], isCloud: true, }, { id: 'custom', name: 'Custom (OpenAI 호환)', description: 'vLLM, 사내 프라이빗 서버, Together 등 커스텀 엔드포인트', badge: 'Self-Hosted / Proxy', requiresApiKey: false, defaultModel: 'whisper-1', defaultBaseUrl: 'http://127.0.0.1:8000/v1', models: ['whisper-1', 'custom'], isCloud: true, }, ] export class STTManager extends EventEmitter implements STTEngine { private _drivers = new Map() constructor() { super() this._registerDrivers() } private _registerDrivers(): void { this._drivers.set('d3ro-cloud', new D3ROCloudDriver()) this._drivers.set('openai', new OpenAIDriver()) this._drivers.set('groq', new GroqDriver()) this._drivers.set('deepgram', new DeepgramDriver()) this._drivers.set('assemblyai', new AssemblyAIDriver()) this._drivers.set('google', new GoogleDriver()) this._drivers.set('custom', new CustomDriver()) } getProviders(): STTProviderInfo[] { return STT_PROVIDERS_META } getActiveProvider(): STTProviderType { const provider = configGet('sttProvider') as STTProviderType | undefined return provider || 'local' } setProvider(provider: STTProviderType): void { logger.info(`STT provider changed to: ${provider}`) configSet('sttProvider', provider) this.emit('provider-changed', { provider }) } getProviderConfig(provider: STTProviderType): STTProviderConfig { const configs = (configGet('sttProviderConfigs') || {}) as Record const specificConfig = configs[provider] || {} const meta = STT_PROVIDERS_META.find((p) => p.id === provider) return { apiKey: specificConfig.apiKey ?? '', // 저장된 값이 localhost일 수 있다(IPv6 해석 실패) → IPv4 루프백으로 정규화. baseUrl: normalizeLoopbackUrl(specificConfig.baseUrl ?? meta?.defaultBaseUrl ?? ''), modelId: specificConfig.modelId ?? meta?.defaultModel ?? '', temperature: specificConfig.temperature ?? 0, } } setProviderConfig(provider: STTProviderType, config: STTProviderConfig): void { const configs = { ...((configGet('sttProviderConfigs') || {}) as Record) } configs[provider] = { ...configs[provider], ...config, } configSet('sttProviderConfigs', configs) this.emit('config-changed', { provider, config: configs[provider] }) logger.info(`STT provider config updated for: ${provider}`) } async testConnection(params: TestSTTConnectionParams): Promise { const { provider, apiKey, baseUrl, modelId } = params if (provider === 'local') { const localStt = getLocalSTTService() const status = localStt.getStatus() return { success: true, latencyMs: 0, message: `로컬 Whisper 준비됨 (상태: ${status.engineState}, 모델: ${status.activeModel || '미선택'})`, } } const driver = this._drivers.get(provider) if (!driver) { return { success: false, latencyMs: 0, message: `지원되지 않는 STT 공급자입니다: ${provider}`, } } const currentConfig = this.getProviderConfig(provider) const effectiveConfig: STTProviderConfig = { ...currentConfig, apiKey: apiKey !== undefined ? apiKey : currentConfig.apiKey, baseUrl: baseUrl !== undefined ? baseUrl : currentConfig.baseUrl, modelId: modelId !== undefined ? modelId : currentConfig.modelId, } return driver.testConnection(effectiveConfig) } async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise { const provider = this.getActiveProvider() // 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다 if (provider === 'local') { return getLocalSTTService().transcribe(audioBuffer, options) } // 2. Cloud STT Driver const driver = this._drivers.get(provider) if (!driver) { logger.warn(`Driver not found for provider ${provider}, falling back to local`) return this._transcribeLocalFallback(audioBuffer, options) } const config = this.getProviderConfig(provider) const fallbackToLocal = configGet('sttFallbackToLocal') ?? true try { logger.info(`Transcribing with cloud STT provider: ${provider} (model: ${config.modelId})`) return await driver.transcribe(audioBuffer, options, config) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`) // 무음/취소처럼 로컬로 다시 해도 결과가 같은 실패는 폴백하지 않는다 if (fallbackToLocal && isRecoverableByLocalFallback(error)) { logger.info(`Auto-fallback: executing Local Whisper transcription...`) this.emit('fallback-to-local', { provider, reason: errorMsg }) try { return await this._transcribeLocalFallback(audioBuffer, options) } catch (localError) { logger.error(`Local STT fallback also failed: ${localError}`) throw error // 원래 에러 유지 } } throw error } } /** * 클라우드 공급자에서 로컬 엔진으로 넘어가는 경로. * 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가 * 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어 * 받아쓰기 액션 큐가 굳지 않게 한다. * requireInstalled: 모델·엔진이 이미 로컬에 있을 때만 쓴다. 없으면 폴백이 엔진(~100MB)과 * 모델(최대 수 GB)을 조용히 내려받기 시작하고 그동안 핫키 큐가 멈춘다. */ private _transcribeLocalFallback( audioBuffer: Buffer, options?: TranscribeOptions, ): Promise { return withTimeout( getLocalSTTService().transcribe(audioBuffer, { ...options, requireInstalled: true }), LOCAL_FALLBACK_TIMEOUT_MS, `Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`, ) } /** * STTEngine 포트: 현재 공급자로 전사할 준비를 한다. * 로컬이면 설정된 모델을 올리고(이미 올라가 있으면 즉시 반환), 클라우드면 할 일이 없다. */ async ensureReady(): Promise { if (this.getActiveProvider() !== 'local') return await getLocalSTTService().initialize(configGet('sttModelId')) } /** STTEngine 포트: 로컬 공급자에서 선택한 모델이 설치되지 않았으면 그 id를 돌려준다. */ getMissingModel(): string | null { if (this.getActiveProvider() !== 'local') return null const modelId = configGet('sttModelId') const model = getLocalSTTService().getModels().find((m) => m.id === modelId) return model && !model.downloaded ? modelId : null } /** STTEngine 포트: 실시간 미리보기는 로컬 Whisper에서만 지원한다. */ supportsPartial(): boolean { return this.getActiveProvider() === 'local' } /** * 녹음 중 실시간 미리보기 전사(최종 삽입과 무관). * 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다. * 실패는 빈 문자열로 흡수된다. */ async transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise { if (this.getActiveProvider() !== 'local') return '' return getLocalSTTService().transcribePartial(audioBuffer, options) } /** * 로컬 STT 엔진(sidecar + 모델)을 백그라운드로 미리 데운다. * 첫 받아쓰기 지연을 없애는 것이 목적이며 실패해도 조용히 넘어간다. */ async warmUpLocal(): Promise { if (this.getActiveProvider() !== 'local') return false return getLocalSTTService().warmUp() } getStatus(): STTStatus { const provider = this.getActiveProvider() if (provider === 'local') { return { ...getLocalSTTService().getStatus(), activeProvider: 'local', } } const config = this.getProviderConfig(provider) const meta = STT_PROVIDERS_META.find((p) => p.id === provider) return { engineState: 'ready' as import('@d3ro/core/types').STTEngineState, activeModel: config.modelId || meta?.defaultModel || null, engineVersion: meta?.name || null, gpuAccelerated: false, activeProvider: provider, } } } let _instance: STTManager | null = null export function getSTTManager(): STTManager { if (!_instance) { _instance = new STTManager() } return _instance } export function resetSTTManagerForTests(): void { if (_instance) _instance.removeAllListeners() _instance = null }