Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
169 lines
8.2 KiB
Markdown
169 lines
8.2 KiB
Markdown
# 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.
|
|
|
|
### What to run in a clinic
|
|
|
|
| Need | Use |
|
|
|---|---|
|
|
| Server STT | LiteLLM, with a compliant upstream configured in the gateway. |
|
|
| Real-time draft preview | Browser Web Speech, only on explicit opt-in and with the privacy warning. |
|
|
|
|
```env
|
|
TRANSCRIBE_PROVIDER=litellm
|
|
LITELLM_API_BASE=https://your-litellm.example/v1
|
|
LITELLM_API_KEY=<key>
|
|
LITELLM_STT_MODEL=local-parakeet-v3
|
|
```
|
|
|
|
Direct Google, AWS Transcribe, OpenAI Whisper and local Whisper branches are
|
|
not part of the runtime. Choosing among those upstreams is a LiteLLM
|
|
configuration question, not an app one.
|
|
|
|
### Paths that must stay removed
|
|
|
|
Browser-local STT was removed deliberately. These should stay absent unless the
|
|
project reintroduces it with a fresh design review — and they should not be
|
|
offered in settings, docs, public workers, or model download scripts either:
|
|
|
|
- `public/js/browserWhisper.js`
|
|
- `public/js/whisperWorker.js`
|
|
- `public/js/whisperWorkerV2.js`
|
|
- `public/models/Xenova/*`
|
|
- Browser Whisper setup and troubleshooting docs
|
|
- Whisper model download scripts for public browser models
|
|
|
|
## 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 the `audio_backups.audio_data` column. 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_id` and `expires_at > NOW()` in the query, so an expired or
|
|
borrowed id reads as missing rather than as another person's recording.
|
|
- The expiry sweep (`cleanupExpired` in `src/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.
|
|
|
|
### Where object storage settings come from
|
|
|
|
`src/utils/objectStorage.js` resolves them for every purpose the same way, so
|
|
moving the app to a different MinIO — or to a real S3 — is one set of variables
|
|
rather than three schemes. For a purpose (`documents`, `generated-images`,
|
|
`audio-backups`) it reads, in order:
|
|
|
|
1. that purpose's own variables — `AUDIO_BACKUPS_S3_ENDPOINT`, `..._BUCKET`,
|
|
`..._REGION`, `..._ACCESS_KEY[_FILE]`, `..._SECRET_KEY[_FILE]`;
|
|
2. the shared ones — `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY[_FILE]`,
|
|
`S3_SECRET_KEY[_FILE]`;
|
|
3. a per-purpose bucket — `S3_BUCKET_AUDIO_BACKUPS`, `S3_BUCKET_GENERATED_IMAGES`.
|
|
|
|
So one endpoint plus three bucket names covers everything, while a purpose that
|
|
needs its own account overrides all of it. A `_FILE` variant always beats an
|
|
inline value, because a mounted secret should not be shadowed by an inherited
|
|
environment variable. No bucket means "not configured", which is never an error
|
|
— all three are optional.
|
|
|
|
Every name previously accepted still works, including `S3_ACCESS_KEY_ID`,
|
|
`S3_SECRET_ACCESS_KEY` and the `AWS_*` fallbacks for documents, and
|
|
`GENERATED_IMAGES_S3_*`. Path-style addressing keeps each purpose's old default
|
|
(off for documents, so Backblaze keeps working) unless `S3_FORCE_PATH_STYLE`
|
|
says otherwise; a custom endpoint turns it on where there was no older default,
|
|
because that is nearly always MinIO.
|
|
|
|
### 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.
|
|
|
|
1. Create an `audio-backups` bucket and a user with read/write/delete on it.
|
|
2. Give the bucket a 24-hour expiry lifecycle rule, as a backstop for objects
|
|
the sweep could not delete.
|
|
3. Set `AUDIO_BACKUPS_S3_ENDPOINT`, `AUDIO_BACKUPS_S3_BUCKET`,
|
|
`AUDIO_BACKUPS_S3_REGION`, and either `AUDIO_BACKUPS_S3_ACCESS_KEY_FILE` /
|
|
`AUDIO_BACKUPS_S3_SECRET_KEY_FILE` (preferred — credentials stay out of the
|
|
process environment) or `AUDIO_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.
|