fix: transcription that returned nothing, and one Registration card
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Forgejo Docker Build / Build Docker image (push) Successful in 29s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Forgejo Docker Build / Build Docker image (push) Successful in 29s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
8d0dc968b3
commit
59c6ca6296
2 changed files with 54 additions and 39 deletions
|
|
@ -10,12 +10,45 @@
|
|||
<div class="stat-card"><div class="stat-value" id="stat-api-today">—</div><div class="stat-label">API Calls Today</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Registration -->
|
||||
<!-- Registration ─────────────────────────────────────────────
|
||||
One card, read top to bottom: open registration at all, then decide
|
||||
whether it needs an invitation, then hand out the codes. These were
|
||||
two cards far apart, which made "enabled but invite-only" look like
|
||||
two unrelated settings instead of one decision. -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></div>
|
||||
<div style="padding:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
||||
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
|
||||
<button id="btn-toggle-reg" class="btn-sm btn-primary">Toggle</button>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
||||
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
|
||||
<button id="btn-toggle-reg" class="btn-sm btn-primary">Toggle</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Invite only</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="admin-invite-only">
|
||||
Require an invitation code to register
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Create an invitation</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-invite-note" placeholder="Who is this for? (optional)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<label style="font-size:12px;color:var(--g600);display:flex;align-items:center;gap:6px;">Valid for
|
||||
<input type="number" id="admin-invite-days" value="7" min="1" max="90" style="width:70px;font-size:13px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;"> days
|
||||
</label>
|
||||
<button id="btn-create-invite" class="btn-sm btn-primary" type="button"><i class="fas fa-plus"></i> Create</button>
|
||||
</div>
|
||||
<div id="admin-invite-new" style="margin-top:10px;"></div>
|
||||
<p style="font-size:12px;color:var(--g500);margin:6px 0 0;">The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.</p>
|
||||
</div>
|
||||
|
||||
<div id="admin-invites-list" style="display:flex;flex-direction:column;gap:4px;max-height:340px;overflow-y:auto;"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -385,40 +418,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Registration invites ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-envelope-open-text"></i> Registration Invitations</h3>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Invite only</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="admin-invite-only">
|
||||
Require an invitation code to register
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Create an invitation</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-invite-note" placeholder="Who is this for? (optional)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<label style="font-size:12px;color:var(--g600);display:flex;align-items:center;gap:6px;">Valid for
|
||||
<input type="number" id="admin-invite-days" value="7" min="1" max="90" style="width:70px;font-size:13px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;"> days
|
||||
</label>
|
||||
<button id="btn-create-invite" class="btn-sm btn-primary" type="button"><i class="fas fa-plus"></i> Create</button>
|
||||
</div>
|
||||
<div id="admin-invite-new" style="margin-top:10px;"></div>
|
||||
<p style="font-size:12px;color:var(--g500);margin:6px 0 0;">The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.</p>
|
||||
</div>
|
||||
|
||||
<div id="admin-invites-list" style="display:flex;flex-direction:column;gap:4px;max-height:340px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TTS Model Management ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue