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
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:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
282
apps/desktop/src/main/services/stt/STTManager.ts
Normal file
282
apps/desktop/src/main/services/stt/STTManager.ts
Normal 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
|
||||
}
|
||||
77
apps/desktop/src/main/services/stt/audio-utils.ts
Normal file
77
apps/desktop/src/main/services/stt/audio-utils.ts
Normal 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)
|
||||
}
|
||||
199
apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts
Normal file
199
apps/desktop/src/main/services/stt/drivers/AssemblyAIDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
apps/desktop/src/main/services/stt/drivers/CustomDriver.ts
Normal file
165
apps/desktop/src/main/services/stt/drivers/CustomDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
Normal file
148
apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
Normal 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)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts
Normal file
178
apps/desktop/src/main/services/stt/drivers/DeepgramDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
apps/desktop/src/main/services/stt/drivers/GoogleDriver.ts
Normal file
210
apps/desktop/src/main/services/stt/drivers/GoogleDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
apps/desktop/src/main/services/stt/drivers/GroqDriver.ts
Normal file
183
apps/desktop/src/main/services/stt/drivers/GroqDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
apps/desktop/src/main/services/stt/drivers/OpenAIDriver.ts
Normal file
183
apps/desktop/src/main/services/stt/drivers/OpenAIDriver.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/desktop/src/main/services/stt/types.ts
Normal file
20
apps/desktop/src/main/services/stt/types.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue