The MAUI client transcribes speech on the device with Whisper.net — see On-device voice. That's perfect for a phone (offline, private), but it means the Swiss-German model is downloaded and run per client, and a browser can't do it at all. This page describes the inversion: run the same fine-tuned Swiss-German Whisper model once, as a container, and expose transcription to every client — the Blazor portal, React Native, and MAUI — from one place the portal configures.
Blazor chat mic ┐ POST /api/speech/transcribe
React Native ┼── record WAV ──────────▶ (portal endpoint — "expose from everywhere")
MAUI ┘ │
▼ ISpeechTranscriber (mesh, HTTP IIoPool)
WhisperContainerTranscriber
│ POST /inference (multipart)
configure ───────▶ whisper.cpp CONTAINER + ggml-swiss-german-turbo-q5_0
(SpeechConfiguration: Endpoint, Language, Enabled — set in the portal)
The pieces
| Piece | Where | Status |
|---|---|---|
The container — whisper.cpp server + the Swiss-German model baked in (see Voice model distribution) |
deploy/whisper/ (Dockerfile + compose + helm + README) |
built; not runtime-verified in CI (no Docker in the sandbox) |
SpeechConfiguration — endpoint, language, enabled; the portal-settable config |
src/MeshWeaver.Speech/SpeechConfiguration.cs |
done |
ISpeechTranscriber / WhisperContainerTranscriber — the centralized client; POST /inference on the HTTP IIoPool, cold IObservable |
src/MeshWeaver.Speech/ |
done + unit tests against the real /inference contract |
Portal endpoint — POST /api/speech/transcribe the clients call |
memex/Memex.Portal.Shared/Api/SpeechEndpoints.cs (MapSpeechApi, wired in MemexConfiguration) |
done |
Mic UI — a record button in ThreadChatView (browser MediaRecorder) → transcript into the composer |
MeshWeaver.Blazor.Portal/Chat |
done |
| React / React Native — record → same endpoint | clients/react (ops.transcribe), MeshWeaver.Plugins/app/react-native/src/speech/ |
done |
| MAUI | memex/Memex.Client/Voice/ |
still the on-device Whisper.net path — see On-device voice |
Why a container (vs. on-device or a cloud STT)
- On-device (the MAUI path) is great for the phone but impossible in a browser and wasteful to replicate everywhere; the model is 547 MB per client.
- A public cloud STT (Azure/OpenAI) does not do real Swiss-German dialect — it maps to
de-CHStandard German at best. Our fine-tune (trained partly on Bernese) is the reason this works at all, so we must host our model. - One container keeps the model, the GPU, and the config in a single place:
dein, Standard German out; swap the model file to trade accuracy for size; add CUDA/Vulkan for throughput without touching clients.
Config — the async + mutation rules
- The transcriber reads
SpeechConfigurationlive (IOptionsMonitorvia a delegate), so the portal can repoint the endpoint at runtime without recreating the service. - The HTTP round-trip runs on the HTTP
IIoPool(pool.Invoke(ct => …)), never on a hub/circuit thread — see Controlled I/O pooling. The public surface is a coldIObservable<SpeechTranscript>; nothing happens until subscribe. Noasync/awaitleaks onto a hub.
Security
Audio is posted to the portal endpoint under the caller's session; the portal forwards it to the (typically
cluster-internal) Whisper container. The container endpoint is not exposed to clients directly — they only
ever see /api/speech/transcribe, so the model host stays behind the portal's auth. Specifically, the endpoint:
- requires the same Bearer policy as the rest of the REST surface (
/api/mesh/*), with antiforgery disabled because a Bearer multipart post carries no antiforgery token (identical to/api/mesh/upload); - caps a single upload at 25 MB — generous for speech, and well under the 200 MB multipart ceiling
AddMeshApiraises, so it cannot be turned into a large-allocation DoS; - returns 503 with a plain message when transcription is unconfigured or disabled, rather than a 500.
The mic UI also checks
IsConfiguredand stays hidden, so this is belt-and-suspenders; - normalizes the container's reply to JSON
{"text": …, "language": …}— a client-supplied whisper.cppresponse_formatpart is accepted for compatibility but ignored.
Status
Shipped: the container definition (deploy/whisper), MeshWeaver.Speech (config + transcriber) with unit
tests driving WhisperContainerTranscriber against an in-process server mimicking whisper.cpp's /inference
(transcribe, language forward + per-call override, unconfigured error, server-error propagation), the portal
POST /api/speech/transcribe endpoint, the ThreadChatView mic button, and the React / React Native record
paths.
Not done: MAUI still transcribes on-device rather than through this endpoint, and there has been no end-to-end run against a live container (the one thing that needs Docker + the model).