- docs/speech.md: a new Recording section (wake lock and why it is re-taken on visibility, idempotent start, what happens when a recording ends by itself, what signing out does), the retention rules, and step-by-step instructions for moving audio backups to MinIO. - Records that the model for a transcription is the user's choice first, which is why the picker must only ever offer models the gateway has, and that LITELLM_STT_MODELS is a fallback rather than a list known to work. - api-reference: /api/transcribe takes `module` and returns `backupId`, and the Audio Backups group is no longer failure-only. - configuration.md and .env.example document AUDIO_BACKUPS_S3_*, preferring the _FILE credential variants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
5.8 KiB
Speech: STT, TTS, Audio Backup
Transcription
POST /api/transcribe accepts multipart/form-data with one audio file up to 25 MB. Server STT is routed through LiteLLM.
Set TRANSCRIBE_PROVIDER=litellm, LITELLM_API_BASE, and LITELLM_STT_MODEL. Auto mode also uses LiteLLM when the gateway is configured.
The model for a request is users.stt_model (the person's own choice), then
the stt.model setting, then LITELLM_STT_MODEL. A user's choice wins, so the
Settings picker must never offer a model the gateway does not have: it lists
what /model/info advertises as audio_transcription
(discoverSTTModels() in src/utils/sttProvider.js, cached five minutes).
LITELLM_STT_MODELS is a fallback for when discovery fails and is not a list
of models known to work — its ids do not resolve on every gateway.
| Provider | Notes | HIPAA posture |
|---|---|---|
| LiteLLM | Sends audio through the configured LiteLLM /audio/transcriptions backend. |
Depends on the selected upstream. |
Browser Whisper and browser-local Whisper workers are not part of the runtime. Do not add browser model downloads or Transformers.js STT back into the public app.
Web Speech Preview
Browser-native Web Speech can show interim text when the user explicitly enables it. It is browser/vendor dependent, may send audio to browser-provider cloud services, and should not be treated as the final clinical transcript.
Text To Speech
POST /api/text-to-speech returns audio from LiteLLM /audio/speech. The X-TTS-Provider response header identifies the LiteLLM model used. Requests are limited to 5000 characters.
| Provider | Notes |
|---|---|
| LiteLLM | Uses LITELLM_TTS_MODEL and LITELLM_TTS_VOICE. |
The admin/user voice pickers read available LiteLLM-compatible voices from LITELLM_TTS_VOICES.
Recording
A recording holds a screen wake lock for as long as it runs, so the screen going to sleep cannot suspend it. Browsers release the lock whenever the page is hidden, so it is requested again when the page becomes visible; a lock is never requested while hidden, because that request is rejected. The count is shared, so two recorders cannot release each other's lock. A browser that denies or lacks the API keeps recording without one.
Recording continues while the user moves around the workspace — the app is a single page and switching tabs does not touch the recorder. Signing out stops it and releases the lock; nothing is sent, because the session that owned the audio is gone.
AudioRecorder.start() on a running recorder is a no-op. Calling it again
would replace the MediaRecorder and drop everything captured so far.
A recording can stop without anyone pressing Stop: the recorder can error, and
the microphone can be claimed by another app, unplugged, or revoked. Both are
reported once and dispatch audio-recorder-failed, which runs the same path as
Stop — so the audio is transcribed and stored rather than left in a tab that
still claims to be recording. Whatever was captured before the failure is kept.
Audio Backup
Every recording is kept for 24 hours, whether its transcription succeeded or
not. POST /api/transcribe already holds the audio, so keeping it costs no
second upload; a storage failure there is logged and the transcription still
returns, because losing the transcript someone is waiting for would be worse
than losing the copy.
src/utils/audioBackupStore.js is the only place that knows how a recording is
kept, shared by /api/transcribe and /api/audio-backups so the two cannot
drift apart.
- Audio is gzipped, then AES-256-GCM encrypted (
DATA_ENCRYPTION_KEY), before it is stored — in either backend. - Object storage is used when
AUDIO_BACKUPS_S3_*is set; otherwise the payload goes in theaudio_backups.audio_datacolumn. Metadata (owner, module, sizes, expiry) is always in Postgres, so listing, ownership and expiry behave the same either way. - Object keys are
recordings/<user id>/<timestamp>-<random>, scoped to their owner, so a leaked id cannot address someone else's audio. - Reads carry
user_idandexpires_at > NOW()in the query, so an expired or borrowed id reads as missing rather than as another person's recording. - The expiry sweep (
cleanupExpiredinsrc/db/database.js) deletes each object with its row, so audio cannot outlive its 24 hours in the bucket. - Rows written before encryption was added are passed through unencrypted on read, so old backups still play.
- The Settings list can retry, download or delete a recording. Download is how a copy leaves the app — onto a phone's Files, a shared drive, an external recorder. It works for both server-side and browser-fallback copies, and a browser copy is only handed to the account that owns it.
- Browser fallback storage (IndexedDB) is used only when the server cannot be reached at all.
Treat audio backups as sensitive clinical data even when encrypted.
Switching audio backups to MinIO
Storing every recording, rather than only the failures, makes object storage
the better home. It is off by default because it needs a bucket and its own
credentials; the existing generated-images key is scoped to that bucket and
cannot reach another.
- Create an
audio-backupsbucket and a user with read/write/delete on it. - Give the bucket a 24-hour expiry lifecycle rule, as a backstop for objects the sweep could not delete.
- Set
AUDIO_BACKUPS_S3_ENDPOINT,AUDIO_BACKUPS_S3_BUCKET,AUDIO_BACKUPS_S3_REGION, and eitherAUDIO_BACKUPS_S3_ACCESS_KEY_FILE/AUDIO_BACKUPS_S3_SECRET_KEY_FILE(preferred — credentials stay out of the process environment) orAUDIO_BACKUPS_S3_ACCESS_KEY/AUDIO_BACKUPS_S3_SECRET_KEY.
New recordings then go to the bucket. Rows already in Postgres keep working:
a row without storage_key is read from the column.