From 59c6ca62965a81d0c346cc5fd4b75cc42b2cea7e Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 00:41:11 +0200 Subject: [PATCH] fix: transcription that returned nothing, and one Registration card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recordings were never the problem. Six stored recordings were pulled back out of object storage and examined: valid webm/opus, 3-5 seconds, 1.5-2s of continuous speech-shaped audio each. Every one came back from mistral-voxtral-mini-transcribe as an empty string, while the same model transcribed synthesised speech perfectly — including a one-word clip, and including that speech attenuated to the same level, so neither length nor loudness explains it. Re-encoding to wav, mp3, flac, ogg and a remuxed webm changed nothing; groq-whisper-large-v3-turbo transcribed all six. stt.model is set to that now, and the real recording round-trips through /api/transcribe as "Hello." instead of "". So the server now says something when a model answers 200 with no words for a non-trivial amount of audio. That silence is what made this look like lost recordings; the log names the backup id, so the kept audio can be tried against another model directly instead of suspecting the microphone. Also: browsers report "audio/webm;codecs=opus", and deriving the extension by splitting on "/" alone named the upload "audio.webm;codecs=opus". This gateway tolerates it. A provider dispatching on extension would not. And registration is one card again: enable it, decide whether it needs an invitation, hand out codes — top to bottom. The invite-only switch sat in a separate card far below the enable/disable toggle, which made one decision look like two unrelated settings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/components/admin.html | 75 ++++++++++++++++++------------------ src/routes/transcribe.js | 18 ++++++++- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index 564a0b7e..9a13858c 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -10,12 +10,45 @@
API Calls Today
- +

Registration

-
- Loading... - +
+
+ Loading... + +
+ +
+ Invite only +
+ +

Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.

+
+
+ +
+ +
+ + + +
+
+

The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.

+
+ +
+
@@ -385,40 +418,6 @@
- -
-
-

Registration Invitations

-
-
-
- Invite only -
- -

Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.

-
-
- -
- -
- - - -
-
-

The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.

-
- -
-
-
-
diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index a2b4c387..8743cf56 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -48,7 +48,10 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var sttModel = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || ''; if (!sttModel) return res.status(400).json({ error: 'No LiteLLM STT model configured.' }); var mimeType = req.file.mimetype || 'audio/webm'; - var ext = mimeType.split('/')[1] || 'webm'; + // Browsers report "audio/webm;codecs=opus", and splitting on "/" alone + // produced the filename "audio.webm;codecs=opus". This gateway tolerates it, + // but a provider that dispatches on file extension would not. + var ext = (mimeType.split('/')[1] || 'webm').split(';')[0].trim() || 'webm'; // Every recording is kept for 24 hours, not only the ones that fail. The // audio is already here, so this costs no extra upload — and it means a @@ -78,6 +81,19 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var data = await sttResp.json(); var text = (data && data.text) ? String(data.text).trim() : ''; console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms'); + // A model can answer 200 with no words at all for audio that plainly + // contains speech — mistral-voxtral-mini-transcribe did exactly that for + // every recording from one microphone while transcribing clean synthetic + // speech perfectly. That looked like "recording lost" from the outside and + // took a day of digging to attribute. Anything larger than a moment of + // audio coming back empty is a fact about the model, so say so here. + if (!text && fileSize > 8 * 1024) { + console.warn('[Transcribe] ' + sttModel + ' returned no text for ' + + (fileSize / 1024).toFixed(0) + 'KB of ' + mimeType + + ' — if this repeats, the audio is probably fine and the model is dropping it. ' + + 'The recording is kept for 24 hours (backup id ' + (backupId == null ? 'none' : backupId) + + '); try another stt.model against it before suspecting the microphone.'); + } logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' }); return res.json({ success: true, text: text, provider: 'litellm/' + sttModel, duration: Date.now() - startTime, backupId: backupId });