refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -14,7 +14,8 @@ import type {
TestSTTConnectionResult,
STTStatus,
} from '@d3ro/core/types'
import type { ISTTDriver } from './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'
@ -26,6 +27,23 @@ 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',
@ -116,7 +134,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [
},
]
export class STTManager extends EventEmitter {
export class STTManager extends EventEmitter implements STTEngine {
private _drivers = new Map<STTProviderType, ISTTDriver>()
constructor() {
@ -210,7 +228,7 @@ export class STTManager extends EventEmitter {
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
const provider = this.getActiveProvider()
// 1. Local STT
// 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다
if (provider === 'local') {
return getLocalSTTService().transcribe(audioBuffer, options)
}
@ -219,7 +237,7 @@ export class STTManager extends EventEmitter {
const driver = this._drivers.get(provider)
if (!driver) {
logger.warn(`Driver not found for provider ${provider}, falling back to local`)
return getLocalSTTService().transcribe(audioBuffer, options)
return this._transcribeLocalFallback(audioBuffer, options)
}
const config = this.getProviderConfig(provider)
@ -236,7 +254,7 @@ export class STTManager extends EventEmitter {
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
this.emit('fallback-to-local', { provider, reason: errorMsg })
try {
return await getLocalSTTService().transcribe(audioBuffer, options)
return await this._transcribeLocalFallback(audioBuffer, options)
} catch (localError) {
logger.error(`Local STT fallback also failed: ${localError}`)
throw error // 원래 에러 유지
@ -247,6 +265,45 @@ export class STTManager extends EventEmitter {
}
}
/**
* 클라우드 공급자에서 로컬 엔진으로 넘어가는 경로.
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — 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에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.