d3ro-voice/apps/desktop/src/main/services/stt/STTManager.ts

359 lines
13 KiB
TypeScript

// 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'
const logger = getLogger('STTManager')
/**
* 클라우드 실패 → 로컬 폴백의 전체 상한(모델 로딩 포함).
* 로컬 엔진의 각 단계에도 타임아웃이 있지만, 폴백 경로가 어떤 이유로든 멈추면
* 받아쓰기 액션 큐 전체가 굳으므로 여기서 한 번 더 끊는다.
*/
export const LOCAL_FALLBACK_TIMEOUT_MS = 180_000
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | null = null
const timeout = new Promise<never>((_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<STTProviderType, ISTTDriver>()
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<STTProviderType, STTProviderConfig>
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<STTProviderType, STTProviderConfig>) }
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<TestSTTConnectionResult> {
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<TranscriptionResult> {
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) {
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), 여기서는 전체에 상한 시간을 두어
* 받아쓰기 액션 큐가 굳지 않게 한다.
*/
private _transcribeLocalFallback(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
return withTimeout(
getLocalSTTService().transcribe(audioBuffer, options),
LOCAL_FALLBACK_TIMEOUT_MS,
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
)
}
/**
* STTEngine 포트: 현재 공급자로 전사할 준비를 한다.
* 로컬이면 설정된 모델을 올리고(이미 올라가 있으면 즉시 반환), 클라우드면 할 일이 없다.
*/
async ensureReady(): Promise<void> {
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<string> {
if (this.getActiveProvider() !== 'local') return ''
return getLocalSTTService().transcribePartial(audioBuffer, options)
}
/**
* 로컬 STT 엔진(sidecar + 모델)을 백그라운드로 미리 데운다.
* 첫 받아쓰기 지연을 없애는 것이 목적이며 실패해도 조용히 넘어간다.
*/
async warmUpLocal(): Promise<boolean> {
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
}