Private, offline speech-to-text that runs Whisper on the user's own device — free, no account, no per-minute fees. Replaces Otter.ai / Rev. - Pure, tested engine: chunking, overlap timestamp-stitching, exports (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests). - Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch. - Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated. - UI: library + search, import, live-progress transcription, synced click-to-seek editor, multi-format export; model picker + privacy in settings. - Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM; Docker (nginx) image + Traefik compose for wisp.briggen.dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 KiB
Wisp — Deployment Runbook
CI/CD for Wisp runs on Gitea Actions. A push to master will:
- Deploy the web app to the
briggen.devserver as a Docker container (nginx serving the Expo/Metro static export, with cross-origin isolation headers for multi-threaded WASM). - Build a signed Android APK and publish it both to the server
(
https://<wisp-host>/wisp.apk) and as a downloadable CI artifact.
Pipeline: .gitea/workflows/ci.yml.
1. Required Gitea secrets
Set these in Gitea → Wisp repo → Settings → Actions → Secrets.
| Secret | Used by | What it is |
|---|---|---|
SSH_HOST |
deploy-web, build-apk | Hostname/IP of the briggen.dev server (SSH target). |
SSH_USER |
deploy-web, build-apk | SSH user with permission to run docker and write /srv/wisp. |
SSH_KEY |
deploy-web, build-apk | Private SSH key (PEM) whose public half is in the server's authorized_keys. |
ANDROID_KEYSTORE_BASE64 |
build-apk | Base64 of the Wisp release keystore (see §3). |
ANDROID_KEYSTORE_PASSWORD |
build-apk | Keystore (store) password. |
ANDROID_KEY_ALIAS |
build-apk | Key alias — wisp if you used scripts/gen-keystore.sh defaults. |
ANDROID_KEY_PASSWORD |
build-apk | Key (alias) password (may equal the store password). |
The SSH key should be a dedicated deploy key with the minimum access needed (docker + write to
/srv/wisp), not your personal key.
2. One-time server setup (briggen.dev)
-
Install Docker + Compose plugin on the server (the runner uses
docker composeover SSH). -
Create the deploy directory and place the compose file:
sudo mkdir -p /srv/wisp/web # Copy this repo's docker-compose.yml to the server: scp docker-compose.yml <user>@<server>:/srv/wisp/docker-compose.yml/srv/wisp/docker-compose.yml— the service definition CI runs (docker compose -f /srv/wisp/docker-compose.yml up -d)./srv/wisp/web/wisp.apk— where CI drops the signed APK; it is mounted read-only into nginx's web root by the compose file. (It's fine if it doesn't exist before the first APK build — nginx 404s/wisp.apkuntil then.)
-
Create the proxy network the compose file attaches to (or reuse your existing reverse-proxy network and rename it in
docker-compose.yml):docker network create proxy -
Wire your reverse proxy + TLS for the Wisp host on
briggen.dev(e.g.wisp.briggen.dev). The container listens on port 8080 and is reachable aswisp-web:8080on theproxynetwork. Point your proxy at it and terminate TLS there. Examples:- Caddy:
wisp.briggen.dev { reverse_proxy wisp-web:8080 } - Traefik: add router/service labels for
wisp-web(host ruleHost(\wisp.briggen.dev`), service port8080`, your TLS cert resolver). - Host nginx (proxy not in Docker): use Option B in
docker-compose.yml(publish127.0.0.1:8080:8080) andproxy_pass http://127.0.0.1:8080;.
Do not strip or override the
Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policyheaders at the proxy — they must reach the browser intact (see §4). - Caddy:
-
Ensure the Gitea Actions runner host has Docker. The
deploy-webandbuild-apkjobs rundocker build,docker save/load,ssh, andscpon the runner. Thetestjob runs inside theoven/bun:1container.
The first push to master after this is set up will deploy.
3. Generate the Android release keystore
Run once on a trusted machine, then back it up forever:
./scripts/gen-keystore.sh
This creates a new RSA-2048 keystore (alias wisp, valid 10000 days) and prints
the exact base64 command plus the list of secrets to set. Then:
base64 -w0 wisp-release.keystore # Linux (macOS: base64 -b0 ...)
Paste that single line into ANDROID_KEYSTORE_BASE64, and set the password /
alias secrets to match what you chose.
Critical: the keystore + passwords are the only way to ship updates to an already-installed APK. Lose them and users must uninstall/reinstall. Back them up in a password manager / offline. Never commit them (
.gitignoreexcludes*.keystore/*.jks).
How signing is wired at build time: scripts/ci-android-sign.sh base64-decodes
the keystore into android/app/wisp.keystore, writes the passwords into
android/gradle.properties, and idempotently patches android/app/build.gradle
to add signingConfigs.release and point buildTypes.release at it.
4. How COOP/COEP cross-origin isolation works (and why it matters)
The web app runs Whisper as multi-threaded WASM, which needs
SharedArrayBuffer. Browsers only expose SharedArrayBuffer to pages that are
cross-origin isolated, which requires both response headers on the document:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
When both are present, self.crossOriginIsolated === true and threads/SAB work.
These headers are set in docker/nginx.conf and served by
our nginx container.
Why GitHub Pages can't host this
Cross-origin isolation requires setting custom response headers (COOP/COEP). GitHub Pages does not let you set arbitrary response headers, so you cannot make a Pages-hosted site cross-origin isolated, and threaded WASM will not run there. That's precisely why Wisp's web app is self-hosted behind our own nginx (which can send the headers) instead of on GitHub Pages.
Model weights from Hugging Face + COEP
The app fetches Whisper model weights at runtime from the Hugging Face Hub, a
cross-origin host. Under COEP: require-corp, every cross-origin subresource must
satisfy either a Cross-Origin-Resource-Policy header or valid CORS. HF
generally serves permissive CORS, so CORS-mode fetches work. If a future HF /
CDN response lacks the needed CORP/CORS, the download is blocked. The fallback is
Cross-Origin-Embedder-Policy: credentialless (keeps isolation, drops the hard
CORP requirement by sending no-CORS cross-origin requests without credentials).
A ready-to-enable commented alternative line is in docker/nginx.conf.
(Caveat: credentialless isn't supported by Safari, hence require-corp default.)
5. Android APK download
After a successful master build:
- Direct download:
https://<wisp-host>/wisp.apk(served by nginx from/srv/wisp/web/wisp.apk). - CI artifact: the workflow run's summary page → artifact
wisp-release-apk.
6. Build the APK locally via Docker
Reproduce the CI Android build on your machine (no Android Studio needed):
# 1. Build the toolchain image (JDK 17 + Android SDK 36 + Bun).
docker build -f docker/android.Dockerfile -t wisp-android:latest .
# 2. Run the build inside it, repo bind-mounted, secrets via env.
# (Use a LOCAL test keystore or your real secrets.)
docker run --rm \
-v "$PWD":/workspace -w /workspace \
-e ANDROID_KEYSTORE_BASE64="$(base64 -w0 wisp-release.keystore)" \
-e ANDROID_KEYSTORE_PASSWORD="<store-pass>" \
-e ANDROID_KEY_ALIAS="wisp" \
-e ANDROID_KEY_PASSWORD="<key-pass>" \
wisp-android:latest \
bash -lc '
bun install --frozen-lockfile
bunx expo prebuild --platform android --no-install
bash scripts/ci-android-sign.sh
cd android && ./gradlew assembleRelease --no-daemon
'
# 3. The signed APK is at:
# android/app/build/outputs/apk/release/app-release.apk
JDK version: the dev machine may have JDK 26, but AGP 8.x targets JDK 17. CI and
docker/android.Dockerfileboth pin JDK 17 so builds are reproducible and supported. Build through the Docker image to avoid local-JDK surprises; don't run./gradlewagainst a host JDK 26.
Pinned Android toolchain (Expo SDK 56 / RN 0.85)
From node_modules/react-native/gradle/libs.versions.toml:
compileSdk 36, targetSdk 36, minSdk 24, build-tools 36.0.0,
NDK 27.1.12297006, AGP 8.12.0, Kotlin 2.1.20, Gradle wrapper 9.3.1.
Heads-up:
app.jsondoes not setandroid.package. Vanillaexpo prebuildwill derive a package name (from thescheme/slug) — set an explicitexpo.android.package(e.g.dev.briggen.wisp) before the first store-bound build so the application ID is stable across releases.
7. Deploy flow at a glance
push master
└─ test (bun: typecheck + vitest)
├─ deploy-web docker build → save|gzip → ssh docker load → compose up
└─ build-apk docker build android image
→ prebuild → ci-android-sign.sh → gradlew assembleRelease
→ scp app-release.apk → /srv/wisp/web/wisp.apk
→ upload-artifact wisp-release-apk