feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -14,7 +14,6 @@ import type {
|
|||
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'
|
||||
|
|
|
|||
|
|
@ -1,147 +1,173 @@
|
|||
// 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 type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import { getCloudSyncService } from '../../CloudSyncService'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
import { configGet } from '../../ConfigService'
|
||||
import { pcmToWav } from '../audio-utils'
|
||||
import type { ISTTDriver } from '../types'
|
||||
|
||||
const logger = getLogger('D3ROCloudDriver')
|
||||
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
|
||||
interface CloudSttPayload {
|
||||
transcript?: unknown
|
||||
confidence?: unknown
|
||||
language_code?: unknown
|
||||
duration_seconds?: unknown
|
||||
provider?: unknown
|
||||
}
|
||||
|
||||
function parseCloudSttPayload(value: unknown): {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language: string
|
||||
duration: number
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
const payload = value as CloudSttPayload
|
||||
if (typeof payload.transcript === 'string' && !payload.transcript.trim()) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
|
||||
}
|
||||
if (
|
||||
typeof payload.transcript !== 'string'
|
||||
|| !payload.transcript.trim()
|
||||
|| payload.transcript.length > 1_000_000
|
||||
|| typeof payload.confidence !== 'number'
|
||||
|| !Number.isFinite(payload.confidence)
|
||||
|| payload.confidence < 0
|
||||
|| payload.confidence > 1
|
||||
|| typeof payload.language_code !== 'string'
|
||||
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|
||||
|| typeof payload.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(payload.duration_seconds)
|
||||
|| payload.duration_seconds < 0
|
||||
|| payload.duration_seconds > 24 * 60 * 60
|
||||
|| typeof payload.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
|
||||
) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
return {
|
||||
transcript: payload.transcript.trim(),
|
||||
confidence: payload.confidence,
|
||||
language: payload.language_code,
|
||||
duration: payload.duration_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
function edgeEndpoint(supabaseUrl: string): string {
|
||||
try {
|
||||
const endpoint = new URL('/functions/v1/stt-proxy', supabaseUrl)
|
||||
const localHttp = endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname)
|
||||
if ((endpoint.protocol !== 'https:' && !localHttp) || endpoint.username || endpoint.password) {
|
||||
throw new Error('invalid endpoint')
|
||||
}
|
||||
return endpoint.toString()
|
||||
} catch {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 서버 설정이 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
export class D3ROCloudDriver implements ISTTDriver {
|
||||
readonly id = 'd3ro-cloud' as const
|
||||
readonly name = 'D3RO Cloud STT (Managed)'
|
||||
|
||||
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
options?: TranscribeOptions,
|
||||
config?: STTProviderConfig
|
||||
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()
|
||||
void config
|
||||
if (!Buffer.isBuffer(audioBuffer) || audioBuffer.length < 1) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '전사할 오디오가 없습니다.')
|
||||
}
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT는 로그인과 Supabase 설정이 필요합니다.')
|
||||
}
|
||||
|
||||
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
|
||||
const arrayBuf = wavBuffer.buffer.slice(
|
||||
const arrayBuffer = wavBuffer.buffer.slice(
|
||||
wavBuffer.byteOffset,
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength,
|
||||
) as ArrayBuffer
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav')
|
||||
|
||||
formData.append('audio', new Blob([arrayBuffer], { 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}`
|
||||
formData.append('language_code', options.language)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
|
||||
const response = await fetch(endpoint, {
|
||||
logger.info('Sending audio to authenticated D3RO Cloud STT Edge gateway')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: anonKey,
|
||||
},
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 요청 실패 (${response.status})`)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const result = parseCloudSttPayload(await response.json().catch(() => null))
|
||||
return {
|
||||
text: rawText,
|
||||
language: data.language || options?.language || 'ko',
|
||||
duration,
|
||||
processingTime,
|
||||
segments: [
|
||||
{
|
||||
text: rawText,
|
||||
start: 0,
|
||||
end: duration,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
text: result.transcript,
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
processingTime: Date.now() - startedAt,
|
||||
segments: [{
|
||||
text: result.transcript,
|
||||
start: 0,
|
||||
end: result.duration,
|
||||
confidence: result.confidence,
|
||||
}],
|
||||
}
|
||||
} 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}`)
|
||||
} catch (error) {
|
||||
if (error instanceof D3ROError) throw error
|
||||
logger.error('D3RO Cloud driver transcribe error')
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 전사에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
async testConnection(_config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) throw new Error('로그인 또는 Supabase 설정이 없습니다.')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: { Authorization: `Bearer ${token}`, apikey: anonKey },
|
||||
body: new FormData(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
|
||||
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 latencyMs = Date.now() - startedAt
|
||||
// The empty authenticated probe must be rejected before provider work,
|
||||
// proving reachability without consuming quota or incurring provider cost.
|
||||
if ([400, 413, 415].includes(response.status)) {
|
||||
return { success: true, latencyMs, message: `D3RO Cloud STT 인증 경로 준비됨 (${latencyMs}ms)` }
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
return { success: false, latencyMs, message: `D3RO Cloud 연결 실패 (HTTP ${response.status})` }
|
||||
} catch {
|
||||
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)}`,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
message: 'D3RO Cloud 연결 또는 인증에 실패했습니다.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue