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
165
server/supabase/functions/_shared/stt-contract.ts
Normal file
165
server/supabase/functions/_shared/stt-contract.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
export const STT_PROVIDER_TIMEOUT_MS = 60_000
|
||||
|
||||
export interface NormalizedSttResult {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language_code: string
|
||||
duration_seconds: number
|
||||
provider: string
|
||||
}
|
||||
|
||||
export function normalizeSttResult(value: unknown): NormalizedSttResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('invalid_stt_result')
|
||||
}
|
||||
const result = value as Record<string, unknown>
|
||||
if (
|
||||
typeof result.transcript !== 'string'
|
||||
|| !result.transcript.trim()
|
||||
|| result.transcript.length > 1_000_000
|
||||
|| typeof result.confidence !== 'number'
|
||||
|| !Number.isFinite(result.confidence)
|
||||
|| result.confidence < 0
|
||||
|| result.confidence > 1
|
||||
|| typeof result.language_code !== 'string'
|
||||
|| !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/i.test(result.language_code)
|
||||
|| typeof result.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(result.duration_seconds)
|
||||
|| result.duration_seconds < 0
|
||||
|| result.duration_seconds > 24 * 60 * 60
|
||||
|| typeof result.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(result.provider)
|
||||
) {
|
||||
throw new Error('invalid_stt_result')
|
||||
}
|
||||
return {
|
||||
transcript: result.transcript.trim(),
|
||||
confidence: result.confidence,
|
||||
language_code: result.language_code.toLowerCase(),
|
||||
duration_seconds: result.duration_seconds,
|
||||
provider: result.provider,
|
||||
}
|
||||
}
|
||||
|
||||
export function createInternalSttGatewayUrl(baseUrl: string): string {
|
||||
const url = new URL('/api/stt/internal/transcribe', baseUrl)
|
||||
const localHttp = url.protocol === 'http:' && [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'host.docker.internal',
|
||||
'd3ro-api-server',
|
||||
].includes(url.hostname)
|
||||
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) {
|
||||
throw new Error('invalid_stt_gateway_url')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'audio/aac': 'aac',
|
||||
'audio/flac': 'flac',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/webm': 'webm',
|
||||
'audio/x-flac': 'flac',
|
||||
'audio/x-m4a': 'm4a',
|
||||
'audio/x-wav': 'wav',
|
||||
'application/ogg': 'ogg',
|
||||
'video/mp4': 'mp4',
|
||||
'video/quicktime': 'mov',
|
||||
'video/webm': 'webm',
|
||||
})
|
||||
|
||||
export class SttInputError extends Error {
|
||||
constructor(
|
||||
readonly code: 'audio_empty' | 'audio_too_large' | 'unsupported_audio_type' | 'invalid_language',
|
||||
readonly status: 400 | 413 | 415,
|
||||
) {
|
||||
super(code)
|
||||
this.name = 'SttInputError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface DictionaryHintRow {
|
||||
word?: unknown
|
||||
pronunciation?: unknown
|
||||
}
|
||||
|
||||
export interface DictionaryHints {
|
||||
keyterms: string[]
|
||||
prompt: string
|
||||
}
|
||||
|
||||
function compactText(value: unknown, maxLength: number): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
const withoutControls = Array.from(value, (character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code < 32 || code === 127 ? ' ' : character
|
||||
}).join('')
|
||||
return withoutControls.replace(/\s+/g, ' ').trim().slice(0, maxLength)
|
||||
}
|
||||
|
||||
export function validateSttAudio(audio: Blob, languageCode: string): {
|
||||
contentType: string
|
||||
fileName: string
|
||||
languageCode: string
|
||||
} {
|
||||
if (audio.size <= 0) throw new SttInputError('audio_empty', 400)
|
||||
if (audio.size > MAX_STT_AUDIO_BYTES) throw new SttInputError('audio_too_large', 413)
|
||||
|
||||
const contentType = audio.type.toLowerCase().split(';', 1)[0].trim()
|
||||
const extension = AUDIO_EXTENSIONS[contentType]
|
||||
if (!extension) throw new SttInputError('unsupported_audio_type', 415)
|
||||
|
||||
const language = languageCode.trim()
|
||||
if (!/^(auto|multi|[a-z]{2,3}(?:-[a-z0-9]{2,8})?)$/i.test(language)) {
|
||||
throw new SttInputError('invalid_language', 400)
|
||||
}
|
||||
|
||||
return {
|
||||
contentType,
|
||||
fileName: `audio.${extension}`,
|
||||
languageCode: language.toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDictionaryHints(rows: readonly DictionaryHintRow[]): DictionaryHints {
|
||||
const seen = new Set<string>()
|
||||
const keyterms: string[] = []
|
||||
const promptParts: string[] = []
|
||||
let promptLength = 0
|
||||
|
||||
for (const row of rows.slice(0, 100)) {
|
||||
const word = compactText(row.word, 120)
|
||||
if (!word) continue
|
||||
const identity = word.toLocaleLowerCase()
|
||||
if (seen.has(identity)) continue
|
||||
|
||||
const pronunciation = compactText(row.pronunciation, 120)
|
||||
const promptPart = pronunciation ? `${word} (${pronunciation})` : word
|
||||
const nextLength = promptLength + promptPart.length + (promptParts.length > 0 ? 2 : 0)
|
||||
if (nextLength > 900) break
|
||||
|
||||
seen.add(identity)
|
||||
keyterms.push(word)
|
||||
promptParts.push(promptPart)
|
||||
promptLength = nextLength
|
||||
if (keyterms.length >= 50) break
|
||||
}
|
||||
|
||||
return { keyterms, prompt: promptParts.join(', ') }
|
||||
}
|
||||
|
||||
export function createDeepgramSttUrl(languageCode: string, keyterms: readonly string[]): string {
|
||||
const url = new URL('https://api.deepgram.com/v1/listen')
|
||||
url.searchParams.set('model', 'nova-3')
|
||||
url.searchParams.set('language', languageCode === 'auto' ? 'multi' : languageCode)
|
||||
url.searchParams.set('smart_format', 'true')
|
||||
url.searchParams.set('punctuate', 'true')
|
||||
for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm)
|
||||
return url.toString()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue