feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -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'

View file

@ -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 연결 또는 인증에 실패했습니다.',
}
}
}