From e70e639838eaa98466d9860c411006dad7ba5553 Mon Sep 17 00:00:00 2001 From: Ivan Corsetti Date: Mon, 27 Jul 2026 08:31:27 +0200 Subject: [PATCH] fix(transcription): transcode uploads to WAV before STT (#957) The /transcribe endpoint passed the raw uploaded file straight to the STT backend (mlx_audio.stt -> miniaudio), which only decodes WAV/FLAC/MP3/Vorbis. Browser recordings arrive as WebM/Opus (Chrome/Firefox MediaRecorder), so web-mode dictation failed with 500 "unsupported file format". The Tauri app was unaffected because WebKit produces MP4. librosa already fully decodes the upload to compute duration (falling back to audioread/ffmpeg for exotic containers), so re-encode that PCM to a temp WAV and hand it to Whisper. WAV inputs pass through unchanged; the temp file is cleaned up in the finally block. Co-authored-by: Claude Opus 4.8 --- backend/routes/transcription.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/routes/transcription.py b/backend/routes/transcription.py index 861bc36f..e0c7bddd 100644 --- a/backend/routes/transcription.py +++ b/backend/routes/transcription.py @@ -15,6 +15,10 @@ router = APIRouter() UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB +# Same set profiles.py accepts for voice samples. librosa picks its decoder from the +# file extension, so the temp file has to keep the uploaded one. +ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"} + @router.post("/transcribe", response_model=models.TranscriptionResponse) async def transcribe_audio( @@ -23,18 +27,33 @@ async def transcribe_audio( model: str | None = Form(None), ): """Transcribe audio file to text.""" - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + uploaded_ext = Path(file.filename or "").suffix.lower() + file_suffix = uploaded_ext if uploaded_ext in ALLOWED_AUDIO_EXTS else ".wav" + + with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp: while chunk := await file.read(UPLOAD_CHUNK_SIZE): tmp.write(chunk) tmp_path = tmp.name + stt_path = tmp_path try: + from ..utils.audio import load_audio, save_audio from ..backends import WHISPER_HF_REPOS - from ..utils.audio import load_audio audio, sr = await asyncio.to_thread(load_audio, tmp_path) duration = len(audio) / sr + # The STT backend (mlx_audio.stt -> miniaudio) only decodes + # WAV/FLAC/MP3/Vorbis, so browser recordings uploaded as WebM/Opus + # fail with "unsupported file format" (issue: web-mode dictation). + # librosa already decoded the file above (it falls back to + # audioread/ffmpeg for exotic containers), so re-encode that PCM to a + # temp WAV and hand *that* to Whisper. WAV inputs pass through + # unchanged. + if file_suffix != ".wav": + stt_path = f"{tmp_path}.stt.wav" + await asyncio.to_thread(save_audio, audio, stt_path, sr) + whisper_model = transcribe.get_whisper_model() model_size = model if model else whisper_model.model_size @@ -69,7 +88,7 @@ async def transcribe_audio( }, ) - text = await whisper_model.transcribe(tmp_path, language, model_size) + text = await whisper_model.transcribe(stt_path, language, model_size) return models.TranscriptionResponse( text=text, @@ -82,3 +101,5 @@ async def transcribe_audio( raise HTTPException(status_code=500, detail=str(e)) finally: Path(tmp_path).unlink(missing_ok=True) + if stt_path != tmp_path: + Path(stt_path).unlink(missing_ok=True)