d3ro-voice/packages/api-client/src/transcribe.ts
2026-08-29 18:33:45 +09:00

122 lines
4.6 KiB
TypeScript

// D3RO Cloud STT client. User-facing transcription always passes through the
// Supabase Edge gateway that owns authentication, quota and usage accounting.
export interface TranscribeAudioParams {
audio: Blob | ArrayBuffer | Uint8Array | string
language?: string
supabaseUrl?: string
anonKey?: string
token?: string
}
export interface TranscribeAudioResult {
text: string
confidence: number
language: string
durationSeconds: number
provider: string
latencyMs: number
modelId?: string
cost?: number
}
function decodeBase64(value: string): Uint8Array {
const normalized = value.replace(/^data:[^,]*,/, '').replace(/\s+/g, '')
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new Error('Unsupported audio payload format.')
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const output: number[] = []
for (let offset = 0; offset < normalized.length; offset += 4) {
const chars = normalized.slice(offset, offset + 4)
const values = [...chars].map((char) => char === '=' ? 0 : alphabet.indexOf(char))
if (values.some((item) => item < 0)) throw new Error('Unsupported audio payload format.')
const combined = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3]
output.push((combined >>> 16) & 0xff)
if (chars[2] !== '=') output.push((combined >>> 8) & 0xff)
if (chars[3] !== '=') output.push(combined & 0xff)
}
return Uint8Array.from(output)
}
function gatewayUrl(value: string): string {
try {
const url = new URL('/functions/v1/stt-proxy', value)
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) throw new Error('invalid')
return url.toString()
} catch {
throw new Error('STT gateway configuration is invalid.')
}
}
function parseResult(value: unknown, latencyMs: number): TranscribeAudioResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('STT gateway returned an invalid response.')
}
const payload = value as Record<string, unknown>
if (
typeof payload.transcript !== 'string'
|| !payload.transcript.trim()
|| 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
|| typeof payload.provider !== 'string'
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
) {
throw new Error('STT gateway returned an invalid response.')
}
return {
text: payload.transcript.trim(),
confidence: payload.confidence,
language: payload.language_code,
durationSeconds: payload.duration_seconds,
provider: payload.provider,
latencyMs,
}
}
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
const supabaseUrl = params.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const anonKey = params.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
const token = params.token ?? ''
if (!supabaseUrl || !anonKey || !token) {
throw new Error('STT gateway requires an authenticated Supabase session.')
}
let audio: Blob
const makeBlob = Blob as unknown as new (parts: unknown[], options?: { type?: string }) => Blob
if (params.audio instanceof Blob) {
audio = params.audio
} else if (params.audio instanceof ArrayBuffer) {
audio = new makeBlob([params.audio], { type: 'audio/wav' })
} else if (params.audio instanceof Uint8Array) {
audio = new makeBlob([params.audio.slice().buffer], { type: 'audio/wav' })
} else if (typeof params.audio === 'string') {
audio = new makeBlob([decodeBase64(params.audio).buffer], { type: 'audio/wav' })
} else {
throw new Error('Unsupported audio payload format.')
}
const formData = new FormData()
formData.append('audio', audio)
if (params.language && params.language !== 'auto') formData.append('language_code', params.language)
const startedAt = Date.now()
const response = await fetch(gatewayUrl(supabaseUrl), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
},
body: formData,
})
if (!response.ok) throw new Error(`STT gateway request failed (${response.status}).`)
return parseResult(await response.json().catch(() => null), Date.now() - startedAt)
}