diff --git a/.env.example b/.env.example index 396ea50d..d5640124 100644 --- a/.env.example +++ b/.env.example @@ -181,3 +181,19 @@ EMBEDDING_DIMENSIONS=768 # ============================================================ DATABASE_URL=postgresql://pedscribe:@postgres:5432/pedscribe DB_PASSWORD=pedscribe_secret_change_me + +# ── Audio backups (optional) ──────────────────────────────────────────────── +# Every recording is kept for 24 hours, whether its transcription succeeded or +# not. Unset, the audio lives in the audio_backups table; set, it goes to a +# bucket instead and only metadata stays in Postgres. The generated-images key +# is scoped to that bucket and cannot be reused here — create a bucket and a +# user of its own, and give the bucket a 24h expiry rule as a backstop. +# Audio is gzipped and AES-256-GCM encrypted before it is stored, either way. +# AUDIO_BACKUPS_S3_ENDPOINT=http://assets:9000 +# AUDIO_BACKUPS_S3_BUCKET=audio-backups +# AUDIO_BACKUPS_S3_REGION=us-east-1 +# Prefer the _FILE variants: credentials then stay out of the process environment. +# AUDIO_BACKUPS_S3_ACCESS_KEY_FILE=/run/secrets/audio-backups-access-key +# AUDIO_BACKUPS_S3_SECRET_KEY_FILE=/run/secrets/audio-backups-secret-key +# AUDIO_BACKUPS_S3_ACCESS_KEY= +# AUDIO_BACKUPS_S3_SECRET_KEY= diff --git a/docs/api-reference.md b/docs/api-reference.md index 653e7fe5..e3c79ead 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -607,15 +607,23 @@ Transcribe an audio file to text. Accepts multipart form data with the audio fil | Field | Type | Description | |---------|------|--------------------| | `audio` | file | Audio file to transcribe | + | `module` | string | Optional. What produced the recording; recorded on the backup. Defaults to `recording`. | - **Response:** ```json { "success": true, "text": "string", "provider": "string", - "duration": "number (seconds)" + "duration": "number (seconds)", + "backupId": "number | null" } ``` +- The audio is kept for 24 hours whether or not transcription succeeds, so this + endpoint does not need a second upload to `/api/audio-backups`. `backupId` is + `null` when the copy could not be stored — the transcription still returns, + because losing the transcript would be worse than losing the copy. +- The model is the caller's `users.stt_model`, then the `stt.model` setting, + then `LITELLM_STT_MODEL`. --- @@ -886,7 +894,14 @@ generation prompts. `custom` rows and legacy `correction_*` rows are excluded. ## Audio Backups -Temporary encrypted audio backup storage with automatic 24-hour expiry. +Encrypted 24-hour storage for recordings. Every recording made through +`/api/transcribe` is kept automatically; this group is for browser-held copies +(saved when the server could not be reached at all), and for listing, +downloading and deleting. + +Payload lives in object storage when `AUDIO_BACKUPS_S3_*` is configured and in +the `audio_backups` column otherwise; metadata is always in Postgres, so these +endpoints behave identically either way. See `docs/speech.md`. ### POST /api/audio-backups diff --git a/docs/configuration.md b/docs/configuration.md index cadc0a48..5564d99e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,8 @@ keys): | `APP_URL` | Public base URL. Enables production mode — fail-closed CORS, HSTS, secure cookies. | | `JWT_SECRET` | HMAC key for JWT signing and OIDC state. Server refuses to start without it in production. | | `DATA_ENCRYPTION_KEY` | AES-256-GCM key for PHI at rest (Nextcloud tokens, audio backups). 64 hex chars (`openssl rand -hex 32`). Refuses to start without it in production. | +| `AUDIO_BACKUPS_S3_ENDPOINT`, `_BUCKET`, `_REGION` | Optional. Keeps the 24-hour recording copies in object storage instead of the `audio_backups` column; metadata stays in Postgres either way. See `docs/speech.md`. | +| `AUDIO_BACKUPS_S3_ACCESS_KEY_FILE`, `_SECRET_KEY_FILE` | Credentials for the above, read from files so they never appear in the process environment. `AUDIO_BACKUPS_S3_ACCESS_KEY` / `_SECRET_KEY` are the inline fallback. | | `DB_PASSWORD` / `DATABASE_URL` | Postgres password or full connection string. | | `PORT` | HTTP listen port (default 3000). | | `NODE_ENV` | `production` forces prod-only guards on even without `APP_URL`. | diff --git a/docs/speech.md b/docs/speech.md index 1c509a4d..fdf706c3 100644 --- a/docs/speech.md +++ b/docs/speech.md @@ -6,6 +6,14 @@ 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. | @@ -26,13 +34,79 @@ Browser-native Web Speech can show interim text when the user explicitly enables 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 -Failed transcription submissions can be stored for retry instead of being silently lost. +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. -- Audio backups are compressed and encrypted before storage. -- Backups expire automatically. -- The Settings audio backup UI can retry or delete saved items. -- Browser fallback storage is used only when the server cannot save the failed audio. +`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//-`, 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. + +### 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. diff --git a/docs/transcription-options.md b/docs/transcription-options.md index 1fa73323..af69ea4f 100644 --- a/docs/transcription-options.md +++ b/docs/transcription-options.md @@ -24,7 +24,8 @@ LITELLM_STT_MODEL=local-parakeet-v3 ## Failure Handling -- Server transcription failures can create encrypted audio backups for retry. +- Every recording is kept, encrypted, for 24 hours — not only the ones whose + transcription failed — and can be downloaded from Settings. - Users can retry or delete failed backups from Settings. - Web Speech interim text is not a substitute for a server transcription response.