feat(calls): Phase 3 - self-hosted server-side STT provider (LC-393) #407

Merged
longjacksonle merged 1 commit from feat/lc-393-phase3-server-stt into main 2026-06-20 08:00:27 +02:00

Phase 3 of LC-393: an operator-selectable self-hosted server-side STT engine for call transcription. Phases 1-2 (browser Web Speech) are merged.

Architecture

Rather than bundle whisper.cpp (a large native dep + model file) into the single binary, lets-chat forwards audio to an operator-configured OpenAI-compatible /v1/audio/transcriptions endpoint (whisper.cpp server, faster-whisper, LocalAI, ...). The binary stays lean, the operator runs/chooses the engine - on localhost if they like. Benefits over the browser engine: browser-agnostic (works in Firefox/Safari, which lack SpeechRecognition) and keeps audio off third-party clouds (Chrome's Web Speech ships audio to Google).

What's here

  • New stt module (mirrors the push_client pattern): SttConfig::from_env (LETS_CHAT_STT_URL enables it; _API_KEY, _MODEL optional), an SttClient trait with ReqwestSttClient (multipart POST) + MockSttClient (tests). AppState gains stt_client: Option<...>.
  • New endpoint POST /call/transcript/{id}/audio: participant-gated, forwards the clip to the STT endpoint, records the returned text as a segment + live caption via the shared record_and_broadcast path (same persistence/broadcast as the browser-text segment). 400 when STT is unconfigured.
  • Trusted-endpoint HTTP path: the STT endpoint is operator-trusted (SMTP/IMAP class) and must reach localhost/internal, so it is NOT public-IP SSRF-filtered. Routed through one new blessed http_client::outbound_trusted_post helper that lives inside the LC-152 chokepoint module, so the grep-ban stays a single-file allowance (the existing lc152_no_raw_reqwest_in_src test passes).
  • Engine selection: GET /call/config advertises sttServer; transcribe.js reads it and, when server STT is on, captures fixed-length MediaRecorder clips and POSTs them instead of using Web Speech. Toggle-disable + the engine pick key on whichever browser API the chosen engine needs.
  • reqwest gains the multipart feature; CLAUDE.md documents the three env vars.

When LETS_CHAT_STT_URL is unset, behavior is unchanged (in-browser Web Speech).

Testing

  • tests/transcripts.rs extended to 7 (all green): server_stt_audio_records_segment (mock engine -> segment stored + attributed), audio_rejected_when_server_stt_disabled (400), plus the existing Phase 1/2 gating + the voice-participant test.
  • just check, just test, just test-saas all green (exit 0). The 88 test AppState literals updated with stt_client: None.
  • Manual (browser API): the MediaRecorder clip capture + a live STT endpoint. The server forward is covered via the mock client.

Operator-action

Optional: set LETS_CHAT_STT_URL (+ optional _API_KEY / _MODEL) to an OpenAI-compatible transcription endpoint to switch call transcription to self-hosted server-side STT; leave unset to keep the in-browser engine. The endpoint is trusted/unfiltered - point it only at an operator-run service.

Note for review

Worth /code-review ultra given the size + the new trusted-HTTP path: the one security-relevant decision is outbound_trusted_post deliberately skipping the SSRF filter for an operator-config URL (same posture as SMTP/IMAP), kept inside the chokepoint module.

Phase 3 of LC-393: an operator-selectable **self-hosted server-side STT engine** for call transcription. Phases 1-2 (browser Web Speech) are merged. ## Architecture Rather than bundle whisper.cpp (a large native dep + model file) into the single binary, lets-chat forwards audio to an **operator-configured OpenAI-compatible `/v1/audio/transcriptions` endpoint** (whisper.cpp server, faster-whisper, LocalAI, ...). The binary stays lean, the operator runs/chooses the engine - on localhost if they like. Benefits over the browser engine: browser-agnostic (works in Firefox/Safari, which lack SpeechRecognition) and keeps audio off third-party clouds (Chrome's Web Speech ships audio to Google). ## What's here - **New `stt` module** (mirrors the `push_client` pattern): `SttConfig::from_env` (`LETS_CHAT_STT_URL` enables it; `_API_KEY`, `_MODEL` optional), an `SttClient` trait with `ReqwestSttClient` (multipart POST) + `MockSttClient` (tests). `AppState` gains `stt_client: Option<...>`. - **New endpoint `POST /call/transcript/{id}/audio`**: participant-gated, forwards the clip to the STT endpoint, records the returned text as a segment + live caption via the shared `record_and_broadcast` path (same persistence/broadcast as the browser-text `segment`). 400 when STT is unconfigured. - **Trusted-endpoint HTTP path**: the STT endpoint is operator-trusted (SMTP/IMAP class) and must reach localhost/internal, so it is NOT public-IP SSRF-filtered. Routed through one new blessed `http_client::outbound_trusted_post` helper that lives **inside** the LC-152 chokepoint module, so the grep-ban stays a single-file allowance (the existing `lc152_no_raw_reqwest_in_src` test passes). - **Engine selection**: `GET /call/config` advertises `sttServer`; `transcribe.js` reads it and, when server STT is on, captures fixed-length `MediaRecorder` clips and POSTs them instead of using Web Speech. Toggle-disable + the engine pick key on whichever browser API the chosen engine needs. - `reqwest` gains the `multipart` feature; CLAUDE.md documents the three env vars. When `LETS_CHAT_STT_URL` is unset, behavior is unchanged (in-browser Web Speech). ## Testing - `tests/transcripts.rs` extended to 7 (all green): `server_stt_audio_records_segment` (mock engine -> segment stored + attributed), `audio_rejected_when_server_stt_disabled` (400), plus the existing Phase 1/2 gating + the voice-participant test. - `just check`, `just test`, `just test-saas` all green (exit 0). The 88 test `AppState` literals updated with `stt_client: None`. - Manual (browser API): the MediaRecorder clip capture + a live STT endpoint. The server forward is covered via the mock client. ## Operator-action Optional: set `LETS_CHAT_STT_URL` (+ optional `_API_KEY` / `_MODEL`) to an OpenAI-compatible transcription endpoint to switch call transcription to self-hosted server-side STT; leave unset to keep the in-browser engine. The endpoint is trusted/unfiltered - point it only at an operator-run service. ## Note for review Worth `/code-review ultra` given the size + the new trusted-HTTP path: the one security-relevant decision is `outbound_trusted_post` deliberately skipping the SSRF filter for an operator-config URL (same posture as SMTP/IMAP), kept inside the chokepoint module.
feat(calls): Phase 3 - self-hosted server-side STT provider [operator-action] (LC-393)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 5m28s
0e2f7ad85a
Add an operator-selectable server-side speech-to-text engine for call transcription, so it works browser-agnostically (Firefox/Safari) and keeps audio off third-party clouds.

Architecture: rather than bundle whisper.cpp (huge native dep + model) into the single binary, lets-chat forwards audio to an operator-configured OpenAI-compatible /v1/audio/transcriptions endpoint (whisper.cpp server, faster-whisper, LocalAI, ...). The binary stays lean and the operator runs/chooses the engine, on localhost if they like.

- New `stt` module: SttConfig::from_env (LETS_CHAT_STT_URL enables it; _API_KEY, _MODEL optional), an SttClient trait with ReqwestSttClient (multipart POST) + MockSttClient (tests), mirroring the push_client pattern. AppState gains `stt_client: Option<...>`.
- New endpoint POST /call/transcript/{id}/audio: participant-gated, forwards the clip to the STT endpoint, records the returned text as a segment + live caption via the shared record_and_broadcast path. 400 when STT is unconfigured.
- The STT endpoint is operator-trusted (SMTP/IMAP class), so it is NOT public-IP SSRF-filtered (must reach localhost/internal). Routed through a single new blessed http_client::outbound_trusted_post helper that lives inside the LC-152 chokepoint module, so the grep-ban stays a single-file allowance.
- GET /call/config advertises the engine (`sttServer`); transcribe.js reads it and, when server STT is on, captures fixed-length MediaRecorder clips and POSTs them instead of using the Web Speech API. Toggle-disable + the engine pick key on whichever API the chosen engine needs.
- reqwest gains the `multipart` feature. CLAUDE.md documents the three env vars.

When LETS_CHAT_STT_URL is unset, behavior is unchanged (in-browser Web Speech, Phases 1-2). All 88 test AppState literals updated; tests cover the audio path via the mock engine + the disabled-400 + participant gating.

Operator-Action: optional - set LETS_CHAT_STT_URL (+ optional _API_KEY / _MODEL) to an OpenAI-compatible transcription endpoint to switch call transcription to self-hosted server-side STT; leave unset to keep the in-browser engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-393-phase3-server-stt 2026-06-20 08:00:27 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!407
No description provided.