PyWhispr Web - Flask application with Docker support
955
A phone-sized web front end for PyWhisprโ . Tap a button, talk, and your words appear in an editor you can copy out of โ the same dictation the desktop app gives you, from any phone or tablet on your network.
PyWhispr keeps a speech-to-text model warm on a desktop machine and exposes it over HTTP. This app is the client: it records audio in the browser, relays it to whichever of your PyWhispr machines is currently switched on, and puts the text on screen.
api_enabled = true in PyWhispr's config.toml; it listens on port 9149 by default)Clone the repository
git clone https://github.com/benstaniford/pywhispr-web.git
cd pywhispr-web
Start the application
docker-compose up --build -d
Add a server
http://localhost:5000192.168.1.10 is enough, the scheme and port 9149 are assumedDictate
Browsers only grant microphone access in a secure context: HTTPS, or localhost.
Over plain HTTP from another device the record button will fail no matter what
permissions you grant, and the app will tell you so.
So the container serves HTTPS itself. On first start it generates its own certificate authority and server certificate into the data volume, and listens on two ports:
| Port | Scheme | For |
|---|---|---|
| 5443 | HTTPS | Everything. This is the address to use from a phone |
| 5000 | HTTP | Fetching the certificate before HTTPS is trusted, and the health check |
| 9149 | HTTP | The proxied PyWhispr API, for clients other than this app's own |
Set PYWHISPR_TLS_HOSTS to every name or address a client will use to reach the app.
The certificate is only valid for what is listed there:
environment:
- PYWHISPR_TLS_HOSTS=moria,moria.local,192.168.1.10
Prefer a DNS or .local name over a bare IP โ a new DHCP lease invalidates an IP.
Open http://<host>:5000/cert in Safari. That page carries these steps and the
fingerprint to check against.
https://<host>:5443, confirm there is no warning, and add it to your home
screen.Step 3 is not optional. iOS installs a certificate without trusting it, so skipping it leaves Safari warning and the microphone blocked โ exactly the symptom you are trying to fix. And bookmark the HTTPS address: adding the plain-HTTP one to your home screen gives you an app that can never record.
The authority is valid for ten years and lives on the pywhispr-data volume, so it
survives upgrades and redeploys โ each device only ever does this once. The server
certificate is short-lived and reissues itself automatically, including when you change
PYWHISPR_TLS_HOSTS. Every client needs the certificate, not just the phone: a laptop
will warn until it trusts it too.
On macOS open the downloaded file and set it to Always Trust in Keychain Access;
on Android it is Settings โ Security โ Encryption & credentials โ Install a
certificate; on Linux copy it into /usr/local/share/ca-certificates/ and run
update-ca-certificates. To AirDrop it, download it on a Mac or iPad and share from
there.
If something in front of this app already terminates TLS โ Caddy, nginx, Traefik, a
Cloudflare Tunnel, or a Tailscale/Let's Encrypt certificate you manage yourself โ set
PYWHISPR_TLS=off. The container then serves plain HTTP on 5000 only, and /cert
becomes a 404.
For quick local testing, a port forward also counts as secure:
ssh -L 5000:localhost:5000 host.
Note that none of this changes why the app proxies audio rather than having the browser call PyWhispr directly: an HTTPS page is not allowed to fetch a plain-HTTP address, which every PyWhispr server is.
# Install dependencies
pip install -r requirements.txt
# Run the development server (localhost counts as secure, so recording works)
PYWHISPR_CONFIG_PATH=./config.json python app.py
| Variable | Default | Description |
|---|---|---|
PYWHISPR_CONFIG_PATH | /data/config.json | Where the server list and cache are stored |
PYWHISPR_SERVERS | unset | Optional first-run seed, name=url,name=url |
PYWHISPR_TLS_HOSTS | unset | Comma-separated names and addresses the certificate must cover |
PYWHISPR_TLS | on | off disables HTTPS, for when a proxy already terminates it |
PYWHISPR_CERT_DIR | /data/certs | Where the generated CA and server certificate live |
PYWHISPR_API | on | off stops serving the proxied PyWhispr API |
PYWHISPR_API_PORT | 9149 | Port for the proxied API. Change it if a real PyWhispr already has 9149 on this host |
APP_VERSION | dev | Shown on the settings screen |
The server list is edited in the app, not in the environment. PYWHISPR_SERVERS only
seeds it when no config file exists yet:
environment:
- PYWHISPR_SERVERS=desktop=192.168.1.10:9149,laptop=192.168.1.22:9149
The server list and the cached liveness decision live in /data/config.json, and the
generated certificates in /data/certs, both kept in the pywhispr-data volume.
Deleting that volume means every device has to trust a new certificate authority, so it
is worth keeping. The container runs as a non-root user, so if you replace the named
volume with a host directory you must make it writable by that user:
sudo chown -R 1000:1000 ./data
Servers are tried from the top of the list down. The first one that answers
GET /v1/health with status: "ready" is used, and that decision is cached โ so
normal use costs no extra round trips.
The cache is dropped and the list re-walked when:
502/503/504When a request fails, the app immediately re-probes and retries once on a different
server, so a machine being switched off mid-session costs you one retry rather than an
error. A 400 is never retried โ audio the server cannot read will not read any better
elsewhere.
The cache lifetime only governs how quickly a recovered higher-priority server is preferred again. Failing over away from a dead server never waits for it.
A server that is reachable but still loading its model is reported as such rather than treated as available, and the editor retries once it is warm.
Everything above is available to the browser client. Port 9149 offers the same thing to everything else: it serves PyWhispr's own REST API, unchanged, and relays each call to whichever server is alive.
So any existing PyWhispr client gains failover by changing one address:
# before: one machine, and it had better be awake
curl --data-binary @clip.raw -H 'Content-Type: application/octet-stream' \
'http://desktop:9149/v1/transcribe?sample_rate=16000&channels=1&format=s16le'
# after: the same call, now with failover behind it
curl --data-binary @clip.raw -H 'Content-Type: application/octet-stream' \
'http://pywhispr-web:9149/v1/transcribe?sample_rate=16000&channels=1&format=s16le'
The response is upstream's, verbatim โ no extra fields, and GET /v1/health reports the
real queue_depth and limits of the server that would do the work. Failures a client
could not otherwise interpret are translated into codes PyWhispr itself uses: if no
server is ready you get 503 model_unavailable with a Retry-After, not an error of
this app's invention.
Three things worth knowing:
PYWHISPR_API=off turns it off entirely.PYWHISPR_API_PORT, or remap the published port./v1/* answers there. The editor, the settings screen and /cert stay on
5000 and 5443, so the API port looks like nothing but a PyWhispr server. Listing this
app as one of its own upstream servers is refused with a 508 rather than recursing.# Everything: import checks, unit tests, and Docker container tests
./scripts/test-all
# Unit tests only
python -m pytest tests/ -v
# Import verification
python tests/test_imports.py
# Docker container tests only
./test-docker/test-container.sh
scripts/fake-pywhispr.py speaks the same two endpoints as the real thing and needs
no model or GPU:
# Two stand-in servers
python scripts/fake-pywhispr.py --port 9149 --name desktop &
python scripts/fake-pywhispr.py --port 9150 --name laptop &
# Add both in the settings screen, then try:
# - recording (the first is used)
# - killing the first and recording again (fails over to the second)
# - restarting the first (preferred again once the cache expires)
# Or pretend a model is still warming up
python scripts/fake-pywhispr.py --port 9151 --status loading
phone โโโโโโHTTPS :5443โโโ
health/bootstrap HTTP :5000โโโผโ> pywhispr-web โโHTTPโโ> PyWhispr :9149
other clients โโHTTP :9149โโโ โ
(the PyWhispr API, proxied) โโโ /data/config.json servers, TTL, cached choice
โโโ /data/certs CA and server certificate
app.py โ routes and the transcription proxypywhispr_client.py โ the server registry, health probing, failover and the
liveness cache. No Flask imports, so it is testable on its owntls_certs.py โ generates and renews the self-signed CA and server certificate.
Also no Flask importsrun.py โ generates the certificates, then starts one Gunicorn master per listener.
Gunicorn applies TLS process-wide, so serving both schemes takes two of them, plus a
third for the plain-HTTP API portstatic/js/recorder.js โ microphone capture via Web Audiotemplates/ โ a base.html shell plus the editor, settings and certificate screensPyWhispr decodes WAV and headerless PCM only โ no Opus, MP3 or M4A. MediaRecorder
produces WebM/Opus, which it cannot read without ffmpeg. So the browser captures raw
samples through an AudioWorklet, resamples them to 16 kHz mono, and posts signed
16-bit PCM. That also keeps uploads small: about 32 KB per second of speech, roughly a
twelfth of raw 48 kHz float32.
| Endpoint | Purpose |
|---|---|
GET / | The editor |
GET /settings | Server configuration |
GET /api/servers | Configured servers, in priority order |
PUT /api/servers | Replace the server list and cache lifetime |
GET /api/servers/status | Live probe of every server |
GET /api/ready | Whether we can transcribe, and the recording limits |
POST /api/transcribe | Relay audio to PyWhispr, with failover |
GET /v1/health | PyWhispr's API: the live server's health, verbatim |
POST /v1/transcribe | PyWhispr's API: transcribe, with failover |
GET /cert | Certificate download and trust instructions |
GET /cert/pywhispr-ca.crt | The CA certificate to trust |
GET /health | Container health check |
โโโ app.py # Routes, transcription proxy
โโโ pywhispr_client.py # Server registry, probing, failover, cache
โโโ tls_certs.py # Self-signed CA and server certificate generation
โโโ run.py # Entry point: certificates, then one server per listener
โโโ requirements.txt # Python dependencies
โโโ Dockerfile # Multi-stage Docker build
โโโ docker-compose.yml # Development compose file
โโโ gunicorn.conf.py # Production server configuration
โโโ templates/
โ โโโ base.html # Shared shell (PWA metadata, full-screen setup)
โ โโโ index.html # The editor
โ โโโ settings.html # Server configuration
โ โโโ cert.html # Certificate download and trust instructions
โโโ static/
โ โโโ css/app.css
โ โโโ js/recorder.js # Web Audio capture
โ โโโ js/capture-worklet.js
โ โโโ js/app.js # Editor screen
โ โโโ js/settings.js # Settings screen
โ โโโ manifest.webmanifest
โโโ tests/ # Unit tests
โโโ scripts/ # Automation, plus fake-pywhispr.py
โโโ test-docker/ # Container testing
Note that gunicorn.conf.py sets a 180-second worker timeout: a transcription request
can legitimately block for minutes if it queues behind a dictation on the host machine.
5000 and 5443, and set PYWHISPR_TLS_HOSTS to the names clients will
use โ or set PYWHISPR_TLS=off if a reverse proxy already terminates TLS. Publish
9149 too if you want the proxied APIPYWHISPR_CONFIG_PATH and PYWHISPR_CERT_DIR at a persistent volume โ losing
the certificate directory means re-trusting the app on every device| Symptom | Cause |
|---|---|
| Record button disabled, "No server" | No server configured, or none reachable โ check Test all in settings |
| "Microphone access needs HTTPS" | You are on the plain-HTTP port. Use https://<host>:5443 โ see Trusting the certificateโ |
| Safari still warns after installing the certificate | Full trust was not enabled: Settings โ General โ About โ Certificate Trust Settings |
| "This certificate is not valid for <host>" | The name you are browsing by is not in PYWHISPR_TLS_HOSTS; add it and restart |
| "The server is still loading its model" | PyWhispr is warming up; first run may download the model |
| "The server is busy" | PyWhispr is at its concurrency limit; try again shortly |
| Settings will not save | The /data volume is not writable by the container user |
See LICENSEโ .
Content type
Image
Digest
sha256:7fbe44f78โฆ
Size
50.9 MB
Last updated
3 months ago
docker pull nerwander/pywhispr-web