122 lines
4.6 KiB
TypeScript
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)
|
|
}
|