Spaces:
Sleeping
fix(stt): transcode browser webm/opus → wav before Sarvam (D-024)
Browse filesRoot cause: Browser MediaRecorder emits audio/webm;codecs=opus by default.
Sarvam STT only accepts WAV/MP3/FLAC/OGG/M4A. The /api/transcribe endpoint
silently coerced webm→wav in the Content-Type but uploaded the raw webm
bytes, so Sarvam returned HTTP 400 Bad Request:
STT failed: HTTPStatusError: Client error '400 Bad Request' for url
'https://api.sarvam.ai/speech-to-text'
Fix in three layers:
1) sarvam_stt.py — _transcode_to_wav() helper using pydub (ffmpeg-backed)
converts any non-native container to 16 kHz mono WAV before upload.
Also short-audio guard: <1 KB bytes return empty STTResult instead of
500ing on Sarvam's silence rejection.
2) main.py — extend the format whitelist to include webm/opus/mp4 so the
real extension is passed through to the provider (provider then decides
to transcode).
3) Dockerfile — add ffmpeg apt package (pydub's runtime dependency).
requirements.txt — add pydub==0.25.1.
This is the first of three live-product issues surfaced today:
Issue 3 (this commit): STT 400 errors
Issue 2 + 4 (next): fact-find recording STT garbage as profile fields
AND character-splitting health_conditions list
Issue 1 (last): full-duplex barge-in / interrupt-while-speaking
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile +4 -1
- backend/main.py +3 -1
- backend/providers/sarvam_stt.py +41 -1
- requirements.txt +5 -0
|
@@ -25,12 +25,15 @@ RUN npm run build
|
|
| 25 |
FROM python:3.11-slim
|
| 26 |
WORKDIR /app
|
| 27 |
|
| 28 |
-
# System deps
|
|
|
|
|
|
|
| 29 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 30 |
build-essential \
|
| 31 |
libpoppler-cpp-dev \
|
| 32 |
pkg-config \
|
| 33 |
poppler-utils \
|
|
|
|
| 34 |
&& rm -rf /var/lib/apt/lists/*
|
| 35 |
|
| 36 |
# Install Python deps
|
|
|
|
| 25 |
FROM python:3.11-slim
|
| 26 |
WORKDIR /app
|
| 27 |
|
| 28 |
+
# System deps:
|
| 29 |
+
# pdfplumber + torch CPU + sentence-transformers → build-essential, libpoppler
|
| 30 |
+
# pydub (webm→wav transcode for Sarvam STT) → ffmpeg
|
| 31 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
build-essential \
|
| 33 |
libpoppler-cpp-dev \
|
| 34 |
pkg-config \
|
| 35 |
poppler-utils \
|
| 36 |
+
ffmpeg \
|
| 37 |
&& rm -rf /var/lib/apt/lists/*
|
| 38 |
|
| 39 |
# Install Python deps
|
|
@@ -233,10 +233,12 @@ async def transcribe(
|
|
| 233 |
t0 = time.time()
|
| 234 |
audio_bytes = await file.read()
|
| 235 |
ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
|
|
|
|
|
|
|
| 236 |
try:
|
| 237 |
result = await get_stt().transcribe(
|
| 238 |
audio_bytes=audio_bytes,
|
| 239 |
-
audio_format=ext if ext in ("wav", "mp3", "flac", "ogg", "m4a") else "wav",
|
| 240 |
language_code=language_code,
|
| 241 |
)
|
| 242 |
except Exception as e:
|
|
|
|
| 233 |
t0 = time.time()
|
| 234 |
audio_bytes = await file.read()
|
| 235 |
ext = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
|
| 236 |
+
# Pass the real extension through; sarvam_stt.py transcodes non-native
|
| 237 |
+
# containers (webm/opus from browser MediaRecorder) to WAV before upload.
|
| 238 |
try:
|
| 239 |
result = await get_stt().transcribe(
|
| 240 |
audio_bytes=audio_bytes,
|
| 241 |
+
audio_format=ext if ext in ("wav", "mp3", "flac", "ogg", "m4a", "webm", "opus", "mp4") else "wav",
|
| 242 |
language_code=language_code,
|
| 243 |
)
|
| 244 |
except Exception as e:
|
|
@@ -32,15 +32,55 @@ class SarvamSTT(STTProvider):
|
|
| 32 |
if not self.api_key:
|
| 33 |
raise RuntimeError("SARVAM_API_KEY not set in .env")
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
async def transcribe(
|
| 36 |
self,
|
| 37 |
audio_bytes: bytes,
|
| 38 |
audio_format: str = "wav",
|
| 39 |
language_code: Optional[str] = None,
|
| 40 |
) -> STTResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
url = f"{settings.SARVAM_BASE_URL}{settings.SARVAM_STT_PATH}"
|
| 42 |
files = {
|
| 43 |
-
"file": (f"audio.{
|
| 44 |
}
|
| 45 |
data = {"model": self.model}
|
| 46 |
if language_code:
|
|
|
|
| 32 |
if not self.api_key:
|
| 33 |
raise RuntimeError("SARVAM_API_KEY not set in .env")
|
| 34 |
|
| 35 |
+
# Sarvam STT accepts these container formats per their API. webm is NOT
|
| 36 |
+
# in this list — browser MediaRecorder defaults to webm/opus, so we
|
| 37 |
+
# transcode webm → wav (16kHz mono) on the fly via pydub before calling
|
| 38 |
+
# Sarvam. This is the fix for the live "400 Bad Request" we hit when
|
| 39 |
+
# the browser's webm bytes were uploaded as if they were wav.
|
| 40 |
+
_SARVAM_NATIVE_FORMATS = {"wav", "mp3", "flac", "ogg", "m4a"}
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def _transcode_to_wav(audio_bytes: bytes, src_format: str) -> bytes:
|
| 44 |
+
"""Convert any pydub-readable container to 16 kHz mono WAV.
|
| 45 |
+
|
| 46 |
+
Sarvam's recommended sampling rate is 16 kHz mono — what Saarika
|
| 47 |
+
was trained on. Down-mixing + resampling at the gateway also
|
| 48 |
+
prevents Sarvam from doing it server-side, which keeps latency tight.
|
| 49 |
+
"""
|
| 50 |
+
from pydub import AudioSegment
|
| 51 |
+
audio = AudioSegment.from_file(io.BytesIO(audio_bytes), format=src_format)
|
| 52 |
+
audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2)
|
| 53 |
+
buf = io.BytesIO()
|
| 54 |
+
audio.export(buf, format="wav")
|
| 55 |
+
return buf.getvalue()
|
| 56 |
+
|
| 57 |
async def transcribe(
|
| 58 |
self,
|
| 59 |
audio_bytes: bytes,
|
| 60 |
audio_format: str = "wav",
|
| 61 |
language_code: Optional[str] = None,
|
| 62 |
) -> STTResult:
|
| 63 |
+
if not audio_bytes or len(audio_bytes) < 1024:
|
| 64 |
+
# < 1 KB audio is almost certainly silence or a record-and-immediately-stop;
|
| 65 |
+
# Sarvam 400s on these. Surface a clean empty result instead of a 500.
|
| 66 |
+
return STTResult(text="", language_code=language_code, confidence=0.0, raw={"reason": "audio_too_short"})
|
| 67 |
+
|
| 68 |
+
fmt = (audio_format or "wav").lower().lstrip(".")
|
| 69 |
+
# Browser MediaRecorder uses webm/opus by default; Sarvam rejects it.
|
| 70 |
+
# Transcode in-process when we get a non-native format.
|
| 71 |
+
if fmt not in self._SARVAM_NATIVE_FORMATS:
|
| 72 |
+
try:
|
| 73 |
+
audio_bytes = self._transcode_to_wav(audio_bytes, fmt)
|
| 74 |
+
fmt = "wav"
|
| 75 |
+
except Exception as e:
|
| 76 |
+
# If pydub/ffmpeg fails (e.g., truly corrupt audio), let Sarvam
|
| 77 |
+
# see the original bytes and return its own error rather than
|
| 78 |
+
# silently swallowing.
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
url = f"{settings.SARVAM_BASE_URL}{settings.SARVAM_STT_PATH}"
|
| 82 |
files = {
|
| 83 |
+
"file": (f"audio.{fmt}", io.BytesIO(audio_bytes), f"audio/{fmt}"),
|
| 84 |
}
|
| 85 |
data = {"model": self.model}
|
| 86 |
if language_code:
|
|
@@ -26,6 +26,11 @@ duckdb==1.1.3
|
|
| 26 |
sentence-transformers==5.1.2
|
| 27 |
torch==2.11.0 # CPU-only fine for our usage
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# Utility
|
| 30 |
numpy==1.26.4
|
| 31 |
tqdm==4.66.6
|
|
|
|
| 26 |
sentence-transformers==5.1.2
|
| 27 |
torch==2.11.0 # CPU-only fine for our usage
|
| 28 |
|
| 29 |
+
# Audio transcoding — browser MediaRecorder emits webm/opus by default;
|
| 30 |
+
# Sarvam STT accepts WAV/MP3/FLAC/OGG/M4A but NOT webm. pydub (ffmpeg-backed)
|
| 31 |
+
# converts in-process. The Dockerfile installs the ffmpeg apt package.
|
| 32 |
+
pydub==0.25.1
|
| 33 |
+
|
| 34 |
# Utility
|
| 35 |
numpy==1.26.4
|
| 36 |
tqdm==4.66.6
|