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 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> = 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() 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() }