fix(edge): stop short clips waiting a minute on the NAS and make the OpenAI STT fallback usable
All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s

A five-second phone recording took over a minute: stt-proxy waited up to
60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU
Whisper needs 30-90 s per clip. With a direct provider configured the gateway
now gets 5 s plus the clip length (30 s cap).

The direct OpenAI fallback never produced a result. The production key held
characters that are not valid in an HTTP header, so every request threw while
being built; provider keys are now stripped of BOM/zero-width characters and a
still-invalid key counts as not configured. whisper-1 verbose_json reports the
language by name, which the result contract rejected; names now map to codes.
Fail-closed responses list each provider's failure (status or error class,
no secrets) so an outage can be diagnosed without log access.
This commit is contained in:
Yun Chan 2026-09-27 18:02:14 +09:00
parent c90946ce16
commit 0273c6abaa
11 changed files with 139 additions and 20 deletions

View file

@ -1,6 +1,47 @@
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
export const STT_PROVIDER_TIMEOUT_MS = 60_000
/** 16 kHz 16-bit mono PCM — the most common upload; compressed audio only shortens the estimate. */
const PCM_BYTES_PER_SECOND = 32_000
const GATEWAY_BASE_MS = 5_000
const GATEWAY_MAX_WITH_FALLBACK_MS = 30_000
/**
* How long to wait for the self-hosted gateway before a direct provider takes over.
*
* The gateway tries a GPU box that may be off and a CPU-only NAS Whisper that
* needed 50–90 s for a 3 s clip (2 cores under load, 2026-09-27). Waiting the
* full provider timeout turned a short recording into a minute-long wait, and
* the phone gave up to its tiny local model. With a direct provider configured,
* give the gateway 5 s plus the clip length (enough for a GPU, far too little
* for the NAS) and fall through; without one, keep the full timeout.
*/
const LANGUAGE_NAME_TO_CODE: Readonly<Record<string, string>> = {
korean: 'ko', english: 'en', japanese: 'ja', chinese: 'zh', spanish: 'es', french: 'fr',
german: 'de', portuguese: 'pt', russian: 'ru', vietnamese: 'vi', thai: 'th', italian: 'it',
dutch: 'nl', indonesian: 'id', turkish: 'tr', arabic: 'ar', hindi: 'hi', polish: 'pl',
}
/**
* Whisper verbose_json (OpenAI, Groq) reports the language by name ("korean"), while the
* STT contract carries a code. A name used to fail normalizeSttResult, so every direct
* OpenAI/Groq fallback was thrown away as an invalid result. Accept a code, map a name,
* otherwise use what the client asked for.
*/
export function providerLanguageCode(reported: unknown, requested: string): string {
const fallback = requested === 'auto' || requested === 'multi' ? 'und' : requested
if (typeof reported !== 'string') return fallback
const value = reported.trim().toLowerCase()
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(value)) return value
return LANGUAGE_NAME_TO_CODE[value] ?? fallback
}
export function gatewayDeadlineMs(audioBytes: number, hasDirectFallback: boolean): number {
if (!hasDirectFallback) return STT_PROVIDER_TIMEOUT_MS
const estimatedSeconds = Math.max(0, audioBytes) / PCM_BYTES_PER_SECOND
return Math.min(GATEWAY_MAX_WITH_FALLBACK_MS, Math.round(GATEWAY_BASE_MS + estimatedSeconds * 1_000))
}
export interface NormalizedSttResult {
transcript: string
confidence: number