feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -0,0 +1,282 @@
// 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 { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from './types'
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'
const logger = getLogger('STTManager')
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://localhost: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://localhost:8000/v1',
models: ['whisper-1', 'custom'],
isCloud: true,
},
]
export class STTManager extends EventEmitter {
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 ?? '',
baseUrl: 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
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 getLocalSTTService().transcribe(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 getLocalSTTService().transcribe(audioBuffer, options)
} catch (localError) {
logger.error(`Local STT fallback also failed: ${localError}`)
throw error // 원래 에러 유지
}
}
throw error
}
}
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
}

View file

@ -0,0 +1,77 @@
// apps/desktop/src/main/services/stt/audio-utils.ts
// PCM16 -> WAV 헤더 부착 및 오디오 변환 유틸리티
/**
* 16kHz 16-bit Mono PCM RIFF WAV .
* JS ffmpeg .
*/
export function pcmToWav(
pcmBuffer: Buffer,
sampleRate: number = 16000,
numChannels: number = 1,
bitDepth: number = 16
): Buffer {
const bytesPerSample = bitDepth / 8
const blockAlign = numChannels * bytesPerSample
const byteRate = sampleRate * blockAlign
const dataSize = pcmBuffer.length
const headerSize = 44
const totalSize = headerSize + dataSize
const wavBuffer = Buffer.alloc(totalSize)
// RIFF identifier
wavBuffer.write('RIFF', 0)
// RIFF chunk size (file size - 8)
wavBuffer.writeUInt32LE(totalSize - 8, 4)
// WAVE identifier
wavBuffer.write('WAVE', 8)
// "fmt " sub-chunk identifier
wavBuffer.write('fmt ', 12)
// Sub-chunk size (16 for PCM)
wavBuffer.writeUInt32LE(16, 16)
// Audio format (1 = PCM)
wavBuffer.writeUInt16LE(1, 20)
// Number of channels
wavBuffer.writeUInt16LE(numChannels, 22)
// Sample rate
wavBuffer.writeUInt32LE(sampleRate, 24)
// Byte rate (SampleRate * NumChannels * BitsPerSample/8)
wavBuffer.writeUInt32LE(byteRate, 28)
// Block align (NumChannels * BitsPerSample/8)
wavBuffer.writeUInt16LE(blockAlign, 32)
// Bits per sample
wavBuffer.writeUInt16LE(bitDepth, 34)
// "data" sub-chunk identifier
wavBuffer.write('data', 36)
// Sub-chunk data size
wavBuffer.writeUInt32LE(dataSize, 40)
// Copy raw PCM audio data
pcmBuffer.copy(wavBuffer, headerSize)
return wavBuffer
}
/**
* 0.5 (Sine wave 440Hz) PCM/WAV .
* API Key .
*/
export function createProbeWav(durationMs: number = 500, sampleRate: number = 16000): Buffer {
const numSamples = Math.floor((sampleRate * durationMs) / 1000)
const pcmBuffer = Buffer.alloc(numSamples * 2)
// 440Hz A4 음색 사인파 생성
const frequency = 440
const amplitude = 8000 // 적절한 중간 볼륨
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate
const sample = Math.sin(2 * Math.PI * frequency * t) * amplitude
pcmBuffer.writeInt16LE(Math.max(-32768, Math.min(32767, Math.floor(sample))), i * 2)
}
return pcmToWav(pcmBuffer, sampleRate, 1, 16)
}

View file

@ -0,0 +1,199 @@
// apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts
// AssemblyAI (Universal-2 / Conformer-2) STT API 드라이버
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('AssemblyAIDriver')
export class AssemblyAIDriver implements ISTTDriver {
readonly id = 'assemblyai' as const
readonly name = 'AssemblyAI (Universal-2)'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiKey = config?.apiKey?.trim()
if (!apiKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'AssemblyAI API Key가 설정되지 않았습니다.')
}
const baseUrl = (config?.baseUrl?.trim() || 'https://api.assemblyai.com/v2').replace(/\/+$/, '')
const speechModel = config?.modelId?.trim() || 'best' // 'best' (Universal-2) or 'nano'
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
try {
// 1. Upload audio
const uploadRes = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: {
Authorization: apiKey,
'Content-Type': 'application/octet-stream',
},
body: wavBuffer,
signal: AbortSignal.timeout(20000),
})
if (!uploadRes.ok) {
const errText = await uploadRes.text()
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `AssemblyAI 업로드 실패 (${uploadRes.status}): ${errText}`)
}
const uploadData = (await uploadRes.json()) as { upload_url: string }
if (!uploadData.upload_url) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'AssemblyAI 업로드 URL 획득 실패')
}
// 2. Submit transcription job
const transcriptParams: Record<string, unknown> = {
audio_url: uploadData.upload_url,
speech_model: speechModel,
punctuate: true,
format_text: true,
}
if (options?.language && options.language !== 'auto') {
transcriptParams.language_code = options.language
} else {
transcriptParams.language_detection = true
}
if (options?.initialPrompt) {
transcriptParams.word_boost = options.initialPrompt.split(/\s+/).slice(0, 100)
}
const jobRes = await fetch(`${baseUrl}/transcript`, {
method: 'POST',
headers: {
Authorization: apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(transcriptParams),
signal: AbortSignal.timeout(15000),
})
if (!jobRes.ok) {
const errText = await jobRes.text()
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `AssemblyAI 전사 요청 실패 (${jobRes.status}): ${errText}`)
}
const jobData = (await jobRes.json()) as { id: string; status: string; error?: string }
const transcriptId = jobData.id
// 3. Poll for result (최대 30초 대기)
let attempts = 0
let fullResult: {
status: string
text?: string
words?: Array<{ text: string; start: number; end: number; confidence: number }>
language_code?: string
audio_duration?: number
error?: string
} | null = null
while (attempts < 30) {
await new Promise((r) => setTimeout(r, 600))
attempts++
const pollRes = await fetch(`${baseUrl}/transcript/${transcriptId}`, {
headers: { Authorization: apiKey },
signal: AbortSignal.timeout(10000),
})
if (!pollRes.ok) continue
const pollData = (await pollRes.json()) as typeof fullResult
if (pollData && (pollData.status === 'completed' || pollData.status === 'error')) {
fullResult = pollData
break
}
}
if (!fullResult || fullResult.status !== 'completed') {
const errorMsg = fullResult?.error || 'AssemblyAI 처리 타임아웃'
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `AssemblyAI 전사 실패: ${errorMsg}`)
}
const rawText = fullResult.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
return {
text: rawText,
language: fullResult.language_code || options?.language || 'ko',
duration: fullResult.audio_duration || Math.round(audioBuffer.length / 2 / 16000),
processingTime,
segments: (fullResult.words || []).map((w) => ({
text: w.text,
start: w.start / 1000,
end: w.end / 1000,
confidence: w.confidence,
})),
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('AssemblyAI transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `AssemblyAI STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiKey = config.apiKey?.trim()
if (!apiKey) {
return { success: false, latencyMs: 0, message: 'AssemblyAI API Key를 입력해주세요.' }
}
const baseUrl = (config.baseUrl?.trim() || 'https://api.assemblyai.com/v2').replace(/\/+$/, '')
const probe = createProbeWav(300, 16000)
const startTime = Date.now()
try {
// 헬스체크: 0.3초 probe 업로드 테스트
const uploadRes = await fetch(`${baseUrl}/upload`, {
method: 'POST',
headers: {
Authorization: apiKey,
'Content-Type': 'application/octet-stream',
},
body: probe,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (uploadRes.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (지연 시간: ${latencyMs}ms, 모델: ${config.modelId || 'best'})`,
}
}
const errText = await uploadRes.text()
return {
success: false,
latencyMs,
message: `연결 실패 (HTTP ${uploadRes.status}): ${errText}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,165 @@
// apps/desktop/src/main/services/stt/drivers/CustomDriver.ts
// 커스텀 OpenAI 호환 엔드포인트 드라이버 (vLLM, 사내 프라이빗 서버, Together 등)
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('CustomDriver')
export class CustomDriver implements ISTTDriver {
readonly id = 'custom' as const
readonly name = 'Custom (OpenAI-Compatible)'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const rawBaseUrl = config?.baseUrl?.trim()
if (!rawBaseUrl) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, '커스텀 엔드포인트 URL이 설정되지 않았습니다.')
}
const baseUrl = rawBaseUrl.replace(/\/+$/, '')
const endpoint = baseUrl.endsWith('/audio/transcriptions') ? baseUrl : `${baseUrl}/audio/transcriptions`
const model = config?.modelId?.trim() || 'whisper-1'
const apiKey = config?.apiKey?.trim()
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const formData = new FormData()
const arrayBuf = wavBuffer.buffer.slice(
wavBuffer.byteOffset,
wavBuffer.byteOffset + wavBuffer.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'audio.wav')
formData.append('model', model)
formData.append('response_format', 'json')
if (options?.language && options.language !== 'auto') {
formData.append('language', options.language)
}
if (options?.initialPrompt) {
formData.append('prompt', options.initialPrompt)
}
if (typeof config?.temperature === 'number') {
formData.append('temperature', String(config.temperature))
}
const headers: Record<string, string> = {}
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: formData,
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorBody = await response.text()
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `커스텀 엔드포인트 오류 (${response.status}): ${errorBody}`)
}
const data = (await response.json()) as { text?: string; language?: string; duration?: number }
const rawText = data.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
const duration = data.duration || Math.round(audioBuffer.length / 2 / 16000)
return {
text: rawText,
language: data.language || options?.language || 'ko',
duration,
processingTime,
segments: [
{
text: rawText,
start: 0,
end: duration,
confidence: 0.95,
},
],
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('Custom driver transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `커스텀 STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const rawBaseUrl = config.baseUrl?.trim()
if (!rawBaseUrl) {
return { success: false, latencyMs: 0, message: '엔드포인트 URL을 입력해주세요.' }
}
const baseUrl = rawBaseUrl.replace(/\/+$/, '')
const endpoint = baseUrl.endsWith('/audio/transcriptions') ? baseUrl : `${baseUrl}/audio/transcriptions`
const model = config.modelId?.trim() || 'whisper-1'
const apiKey = config.apiKey?.trim()
const probe = createProbeWav(300, 16000)
const formData = new FormData()
const arrayBuf = probe.buffer.slice(
probe.byteOffset,
probe.byteOffset + probe.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'probe.wav')
formData.append('model', model)
const headers: Record<string, string> = {}
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`
}
const startTime = Date.now()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: formData,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (지연 시간: ${latencyMs}ms, 모델: ${model})`,
}
}
const text = await response.text()
return {
success: false,
latencyMs,
message: `연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,148 @@
// apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
// D3RO Voice Cloud STT Gateway 드라이버
// 사용자는 별도 API 키 설정 없이 D3RO 클라우드 서비스를 통해 관리자 설정 프로바이더로 전사 처리
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
import { configGet } from '../../ConfigService'
const logger = getLogger('D3ROCloudDriver')
export class D3ROCloudDriver implements ISTTDriver {
readonly id = 'd3ro-cloud' as const
readonly name = 'D3RO Cloud STT (Managed)'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiBase = (config?.baseUrl || configGet('cloudApiUrl') || process.env.D3RO_API_URL || 'http://localhost:5000').replace(/\/+$/, '')
const endpoint = `${apiBase}/api/stt/transcribe`
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const arrayBuf = wavBuffer.buffer.slice(
wavBuffer.byteOffset,
wavBuffer.byteOffset + wavBuffer.byteLength
) as ArrayBuffer
const formData = new FormData()
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav')
if (options?.language && options.language !== 'auto') {
formData.append('language', options.language)
}
if (options?.initialPrompt) {
formData.append('prompt', options.initialPrompt)
}
if (config?.modelId && config.modelId !== 'default') {
formData.append('model', config.modelId)
}
const headers: Record<string, string> = {}
const token = config?.apiKey || (configGet('cloudAuthToken') as string | undefined)
if (token) {
headers.Authorization = `Bearer ${token}`
}
try {
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: formData,
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorBody = await response.text()
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
}
const data = (await response.json()) as {
text?: string
language?: string
durationSeconds?: number
provider?: string
latencyMs?: number
}
const rawText = data.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
const duration = data.durationSeconds || Math.round(audioBuffer.length / 2 / 16000)
return {
text: rawText,
language: data.language || options?.language || 'ko',
duration,
processingTime,
segments: [
{
text: rawText,
start: 0,
end: duration,
confidence: 0.98,
},
],
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('D3RO Cloud driver transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiBase = (config.baseUrl || configGet('cloudApiUrl') || 'http://localhost:5000').replace(/\/+$/, '')
const endpoint = `${apiBase}/api/stt/test`
const startTime = Date.now()
try {
const headers: Record<string, string> = {}
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
const data = await response.json()
return {
success: true,
latencyMs: data.latencyMs || latencyMs,
message: data.message || `D3RO Cloud STT 연결 성공 (${latencyMs}ms)`,
}
}
const text = await response.text()
return {
success: false,
latencyMs,
message: `D3RO Cloud 연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
return {
success: false,
latencyMs,
message: `D3RO Cloud 연결 실패: ${err instanceof Error ? err.message : String(err)}`,
}
}
}
}

View file

@ -0,0 +1,178 @@
// apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts
// Deepgram Nova-3 / Nova-2 최고 정확도 STT API 드라이버
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('DeepgramDriver')
export class DeepgramDriver implements ISTTDriver {
readonly id = 'deepgram' as const
readonly name = 'Deepgram (Nova-3 최고 정확도)'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiKey = config?.apiKey?.trim()
if (!apiKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'Deepgram API Key가 설정되지 않았습니다.')
}
const baseUrl = (config?.baseUrl?.trim() || 'https://api.deepgram.com').replace(/\/+$/, '')
const model = config?.modelId?.trim() || 'nova-3'
const searchParams = new URLSearchParams({
model,
smart_format: 'true',
punctuate: 'true',
})
if (options?.language && options.language !== 'auto') {
searchParams.set('language', options.language)
} else {
searchParams.set('detect_language', 'true')
}
if (options?.initialPrompt) {
searchParams.set('keywords', options.initialPrompt.slice(0, 200))
}
const endpoint = `${baseUrl}/v1/listen?${searchParams.toString()}`
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': 'audio/wav',
},
body: wavBuffer,
signal: AbortSignal.timeout(25000),
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `Deepgram API 오류 (${response.status})`
try {
const parsed = JSON.parse(errorBody)
if (parsed.err_msg || parsed.message) {
errorMessage = parsed.err_msg || parsed.message
}
} catch {
errorMessage = errorBody || errorMessage
}
if (response.status === 401 || response.status === 403) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `인증 실패: Deepgram API Key를 확인하세요 (${errorMessage})`)
}
throw new D3ROError(ErrorCode.STTTranscriptionFailed, errorMessage)
}
const data = (await response.json()) as {
metadata?: { duration?: number }
results?: {
channels?: Array<{
alternatives?: Array<{
transcript?: string
confidence?: number
words?: Array<{ word: string; start: number; end: number; confidence: number }>
detected_language?: string
}>
}>
}
}
const alt = data.results?.channels?.[0]?.alternatives?.[0]
const rawText = alt?.transcript?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
return {
text: rawText,
language: alt?.detected_language || options?.language || 'ko',
duration: data.metadata?.duration || Math.round(audioBuffer.length / 2 / 16000),
processingTime,
segments: (alt?.words || []).map((w) => ({
text: w.word,
start: w.start,
end: w.end,
confidence: w.confidence || alt?.confidence || 0.98,
})),
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('Deepgram transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `Deepgram STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiKey = config.apiKey?.trim()
if (!apiKey) {
return { success: false, latencyMs: 0, message: 'Deepgram API Key를 입력해주세요.' }
}
const baseUrl = (config.baseUrl?.trim() || 'https://api.deepgram.com').replace(/\/+$/, '')
const model = config.modelId?.trim() || 'nova-3'
const endpoint = `${baseUrl}/v1/listen?model=${model}&punctuate=true`
const probe = createProbeWav(300, 16000)
const startTime = Date.now()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': 'audio/wav',
},
body: probe,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (지연 시간: ${latencyMs}ms, 모델: ${model})`,
}
}
const text = await response.text()
let msg = `HTTP ${response.status}`
try {
const json = JSON.parse(text)
if (json.err_msg || json.message) msg = json.err_msg || json.message
} catch {
msg = text || msg
}
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,210 @@
// apps/desktop/src/main/services/stt/drivers/GoogleDriver.ts
// Google Gemini 2.0 Flash Audio / Cloud STT 드라이버
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('GoogleDriver')
export class GoogleDriver implements ISTTDriver {
readonly id = 'google' as const
readonly name = 'Google Gemini 2.0 Flash / Cloud STT'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiKey = config?.apiKey?.trim()
if (!apiKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'Google API Key가 설정되지 않았습니다.')
}
const model = config?.modelId?.trim() || 'gemini-2.0-flash'
const baseUrl = (config?.baseUrl?.trim() || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '')
const endpoint = `${baseUrl}/models/${model}:generateContent?key=${apiKey}`
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const base64Audio = wavBuffer.toString('base64')
const promptText = options?.language && options.language !== 'auto'
? `Transcribe the audio accurately into ${options.language}. Output ONLY the transcribed text.`
: 'Transcribe the audio accurately. Output ONLY the transcribed speech verbatim, without any commentary.'
const requestBody = {
systemInstruction: {
parts: [
{
text: 'You are an accurate, verbatim speech-to-text audio transcriber. Transcribe all spoken words exactly. Do not add quotes, commentary, notes, or intros.',
},
],
},
contents: [
{
role: 'user',
parts: [
{
inlineData: {
mimeType: 'audio/wav',
data: base64Audio,
},
},
{
text: promptText + (options?.initialPrompt ? ` Context hints: ${options.initialPrompt}` : ''),
},
],
},
],
generationConfig: {
temperature: config?.temperature ?? 0.0,
},
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000),
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `Google API 오류 (${response.status})`
try {
const parsed = JSON.parse(errorBody)
if (parsed.error?.message) {
errorMessage = parsed.error.message
}
} catch {
errorMessage = errorBody || errorMessage
}
if (response.status === 400 || response.status === 403) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `인증/요청 실패: Google API Key를 확인하세요 (${errorMessage})`)
}
throw new D3ROError(ErrorCode.STTTranscriptionFailed, errorMessage)
}
const data = (await response.json()) as {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>
}
}>
}
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
const duration = Math.round(audioBuffer.length / 2 / 16000)
return {
text: rawText,
language: options?.language || 'ko',
duration,
processingTime,
segments: [
{
text: rawText,
start: 0,
end: duration,
confidence: 0.99,
},
],
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('Google transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `Google STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiKey = config.apiKey?.trim()
if (!apiKey) {
return { success: false, latencyMs: 0, message: 'Google API Key를 입력해주세요.' }
}
const model = config.modelId?.trim() || 'gemini-2.0-flash'
const baseUrl = (config.baseUrl?.trim() || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '')
const endpoint = `${baseUrl}/models/${model}:generateContent?key=${apiKey}`
const probe = createProbeWav(200, 16000)
const base64Audio = probe.toString('base64')
const requestBody = {
contents: [
{
role: 'user',
parts: [
{
inlineData: {
mimeType: 'audio/wav',
data: base64Audio,
},
},
{
text: 'Respond with "OK".',
},
],
},
],
generationConfig: { maxOutputTokens: 5 },
}
const startTime = Date.now()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (지연 시간: ${latencyMs}ms, 모델: ${model})`,
}
}
const text = await response.text()
let msg = `HTTP ${response.status}`
try {
const json = JSON.parse(text)
if (json.error?.message) msg = json.error.message
} catch {
msg = text || msg
}
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,183 @@
// apps/desktop/src/main/services/stt/drivers/GroqDriver.ts
// Groq Whisper LPU 초고속 STT API 드라이버 (~200ms)
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('GroqDriver')
export class GroqDriver implements ISTTDriver {
readonly id = 'groq' as const
readonly name = 'Groq Whisper (LPU 초고속)'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiKey = config?.apiKey?.trim()
if (!apiKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'Groq API Key가 설정되지 않았습니다.')
}
const baseUrl = (config?.baseUrl?.trim() || 'https://api.groq.com/openai/v1').replace(/\/+$/, '')
const model = config?.modelId?.trim() || 'whisper-large-v3-turbo'
const endpoint = `${baseUrl}/audio/transcriptions`
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const formData = new FormData()
const arrayBuf = wavBuffer.buffer.slice(
wavBuffer.byteOffset,
wavBuffer.byteOffset + wavBuffer.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'audio.wav')
formData.append('model', model)
formData.append('response_format', 'verbose_json')
if (options?.language && options.language !== 'auto') {
formData.append('language', options.language)
}
if (options?.initialPrompt) {
formData.append('prompt', options.initialPrompt)
}
if (typeof config?.temperature === 'number') {
formData.append('temperature', String(config.temperature))
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
signal: AbortSignal.timeout(20000),
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `Groq API 오류 (${response.status})`
try {
const parsed = JSON.parse(errorBody)
if (parsed.error?.message) {
errorMessage = parsed.error.message
}
} catch {
errorMessage = errorBody || errorMessage
}
if (response.status === 401) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `인증 실패: Groq API Key를 확인하세요 (${errorMessage})`)
}
if (response.status === 429) {
throw new D3ROError(ErrorCode.QuotaExceeded, `Groq 요청 한도 초과 (${errorMessage})`)
}
throw new D3ROError(ErrorCode.STTTranscriptionFailed, errorMessage)
}
const data = (await response.json()) as {
text?: string
language?: string
duration?: number
segments?: Array<{ text: string; start: number; end: number; avg_logprob?: number }>
}
const rawText = data.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
return {
text: rawText,
language: data.language || options?.language || 'ko',
duration: data.duration || Math.round(audioBuffer.length / 2 / 16000),
processingTime,
segments: (data.segments || []).map((seg) => ({
text: seg.text,
start: seg.start,
end: seg.end,
confidence: seg.avg_logprob !== undefined ? Math.exp(seg.avg_logprob) : 0.98,
})),
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('Groq transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `Groq STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiKey = config.apiKey?.trim()
if (!apiKey) {
return { success: false, latencyMs: 0, message: 'Groq API Key를 입력해주세요.' }
}
const baseUrl = (config.baseUrl?.trim() || 'https://api.groq.com/openai/v1').replace(/\/+$/, '')
const model = config.modelId?.trim() || 'whisper-large-v3-turbo'
const endpoint = `${baseUrl}/audio/transcriptions`
const probe = createProbeWav(300, 16000)
const formData = new FormData()
const arrayBuf = probe.buffer.slice(
probe.byteOffset,
probe.byteOffset + probe.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'probe.wav')
formData.append('model', model)
const startTime = Date.now()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (초고속 LPU 지연: ${latencyMs}ms, 모델: ${model})`,
}
}
const text = await response.text()
let msg = `HTTP ${response.status}`
try {
const json = JSON.parse(text)
if (json.error?.message) msg = json.error.message
} catch {
msg = text || msg
}
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,183 @@
// apps/desktop/src/main/services/stt/drivers/OpenAIDriver.ts
// OpenAI Whisper STT API 드라이버
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ISTTDriver } from '../types'
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
import type { STTProviderConfig } from '@d3ro/core/types'
import { pcmToWav, createProbeWav } from '../audio-utils'
import { getLogger } from '../../LoggerService'
const logger = getLogger('OpenAIDriver')
export class OpenAIDriver implements ISTTDriver {
readonly id = 'openai' as const
readonly name = 'OpenAI Whisper'
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult> {
const apiKey = config?.apiKey?.trim()
if (!apiKey) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'OpenAI API Key가 설정되지 않았습니다.')
}
const baseUrl = (config?.baseUrl?.trim() || 'https://api.openai.com/v1').replace(/\/+$/, '')
const model = config?.modelId?.trim() || 'whisper-1'
const endpoint = `${baseUrl}/audio/transcriptions`
const startTime = Date.now()
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
const formData = new FormData()
const arrayBuf = wavBuffer.buffer.slice(
wavBuffer.byteOffset,
wavBuffer.byteOffset + wavBuffer.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'audio.wav')
formData.append('model', model)
formData.append('response_format', 'verbose_json')
if (options?.language && options.language !== 'auto') {
formData.append('language', options.language)
}
if (options?.initialPrompt) {
formData.append('prompt', options.initialPrompt)
}
if (typeof config?.temperature === 'number') {
formData.append('temperature', String(config.temperature))
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `OpenAI API 오류 (${response.status})`
try {
const parsed = JSON.parse(errorBody)
if (parsed.error?.message) {
errorMessage = parsed.error.message
}
} catch {
errorMessage = errorBody || errorMessage
}
if (response.status === 401) {
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `인증 실패: API Key를 확인하세요 (${errorMessage})`)
}
if (response.status === 429) {
throw new D3ROError(ErrorCode.QuotaExceeded, `사용량 한도 초과 또는 요청 제한 (${errorMessage})`)
}
throw new D3ROError(ErrorCode.STTTranscriptionFailed, errorMessage)
}
const data = (await response.json()) as {
text?: string
language?: string
duration?: number
segments?: Array<{ text: string; start: number; end: number; avg_logprob?: number; no_speech_prob?: number }>
}
const rawText = data.text?.trim() ?? ''
if (!rawText) {
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
}
const processingTime = Date.now() - startTime
return {
text: rawText,
language: data.language || options?.language || 'ko',
duration: data.duration || Math.round(audioBuffer.length / 2 / 16000),
processingTime,
segments: (data.segments || []).map((seg) => ({
text: seg.text,
start: seg.start,
end: seg.end,
confidence: seg.avg_logprob !== undefined ? Math.exp(seg.avg_logprob) : 0.95,
})),
}
} catch (err) {
if (err instanceof D3ROError) throw err
const msg = err instanceof Error ? err.message : String(err)
logger.error('OpenAI transcribe error:', msg)
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `OpenAI STT 전사 실패: ${msg}`)
}
}
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
const apiKey = config.apiKey?.trim()
if (!apiKey) {
return { success: false, latencyMs: 0, message: 'API Key를 입력해주세요.' }
}
const baseUrl = (config.baseUrl?.trim() || 'https://api.openai.com/v1').replace(/\/+$/, '')
const model = config.modelId?.trim() || 'whisper-1'
const endpoint = `${baseUrl}/audio/transcriptions`
const probe = createProbeWav(300, 16000)
const formData = new FormData()
const arrayBuf = probe.buffer.slice(
probe.byteOffset,
probe.byteOffset + probe.byteLength
) as ArrayBuffer
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'probe.wav')
formData.append('model', model)
const startTime = Date.now()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
signal: AbortSignal.timeout(10000),
})
const latencyMs = Date.now() - startTime
if (response.ok) {
return {
success: true,
latencyMs,
message: `연결 성공 (지연 시간: ${latencyMs}ms, 모델: ${model})`,
}
}
const text = await response.text()
let msg = `HTTP ${response.status}`
try {
const json = JSON.parse(text)
if (json.error?.message) msg = json.error.message
} catch {
msg = text || msg
}
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
} catch (err) {
const latencyMs = Date.now() - startTime
const msg = err instanceof Error ? err.message : String(err)
return {
success: false,
latencyMs,
message: `연결 실패: ${msg}`,
}
}
}
}

View file

@ -0,0 +1,20 @@
// apps/desktop/src/main/services/stt/types.ts
// STT 드라이버 공통 인터페이스 및 타입 정의
import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService'
import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types'
export interface ISTTDriver {
readonly id: STTProviderType
readonly name: string
transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
config?: STTProviderConfig
): Promise<TranscriptionResult>
testConnection(config: STTProviderConfig): Promise<{
success: boolean
latencyMs: number
message: string
}>
}