165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
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()
|
|
}
|