359 lines
13 KiB
TypeScript
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
|
|
}
|