Files
NilsBriggen 9f42ee2460
CI / test (push) Has been cancelled
CI / deploy-web (push) Has been cancelled
CI / build-apk (push) Has been cancelled
Build Wisp: on-device transcription studio (web + native, one codebase)
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>
2026-06-13 17:54:21 +02:00

143 lines
7.3 KiB
Nginx Configuration File

# docker/nginx.conf
# ---------------------------------------------------------------------------
# nginx server block for the Wisp static web app (used by docker/web.Dockerfile).
#
# Responsibilities:
# 1. Serve the Expo/Metro static SPA, falling back to /index.html for client
# routes (expo-router uses HTML5 history routing).
# 2. Emit the cross-origin isolation headers required for multi-threaded WASM
# (SharedArrayBuffer). Without these, `crossOriginIsolated` is false in the
# browser and the threaded Whisper WASM backend cannot start.
# 3. gzip responses and cache immutable hashed assets aggressively.
# 4. Serve the signed Android APK at /wisp.apk when one is present.
#
# This file is copied to /etc/nginx/conf.d/default.conf, replacing the stock
# nginx site, so it is a `server { }` block (the surrounding http{}/events{}
# come from the base image's /etc/nginx/nginx.conf).
# ---------------------------------------------------------------------------
# Map the request to a Cache-Control value: hashed build assets get a 1-year
# immutable cache; everything else (HTML, the service worker, the APK) is
# revalidated so deploys take effect immediately.
map $uri $wisp_cache_control {
default "no-cache";
# Expo emits content-hashed files under /_expo/ and /assets/. These are safe
# to cache forever because the hash changes whenever the content changes.
~*^/_expo/ "public, max-age=31536000, immutable";
~*^/assets/ "public, max-age=31536000, immutable";
~*\.(?:js|css|woff2?|ttf|otf|png|jpg|jpeg|gif|svg|webp|wasm)$ "public, max-age=31536000, immutable";
}
server {
# Non-privileged port; docker-compose maps/exposes 8080 and the host's
# reverse proxy forwards the wisp host/path here.
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---- Compression --------------------------------------------------------
gzip on;
gzip_vary on; # vary on Accept-Encoding so proxies cache correctly
gzip_comp_level 6;
gzip_min_length 1024; # don't bother compressing tiny responses
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/wasm
image/svg+xml
font/ttf
font/otf;
# NOTE: nginx cannot brotli-compress without the (non-default) ngx_brotli
# module. The large .wasm model-runtime files benefit most from brotli; if
# you need it, switch to an nginx image that bundles ngx_brotli and add
# `brotli on; brotli_static on;` here.
# ---- Cross-origin isolation (REQUIRED for threaded WASM) ----------------
# These two headers make the document "cross-origin isolated", which is what
# unlocks SharedArrayBuffer and therefore multi-threaded WASM. They are set
# on EVERY response (`always`) so they apply even to error responses.
#
# COOP same-origin -> process-isolate this page from cross-origin openers
# COEP require-corp -> every subresource must explicitly opt in (via CORP
# or CORS) to being embedded here
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# Our own assets are same-origin; declare CORP so they remain loadable under
# COEP and can also be used by other isolated origins if ever needed.
add_header Cross-Origin-Resource-Policy "same-origin" always;
# ----------------------------------------------------------------------
# IMPORTANT — Hugging Face model weights & CORS/CORP interaction
# ----------------------------------------------------------------------
# The app downloads Whisper model weights at runtime from the Hugging Face
# Hub (a cross-origin host). Under `Cross-Origin-Embedder-Policy: require-corp`
# every cross-origin subresource must be served with either:
# Cross-Origin-Resource-Policy: cross-origin (a CORP header), or
# valid CORS headers AND be fetched with crossorigin/CORS mode.
#
# The HF Hub / its CDN generally DO send permissive CORS (Access-Control-
# Allow-Origin) headers, so CORS-mode fetches usually work under require-corp.
# However, if HF (or a future CDN) ever omits CORP/CORS for a given asset,
# require-corp will BLOCK the download and the model load will fail.
#
# The robust fallback is COEP "credentialless": it keeps the page cross-origin
# isolated (SharedArrayBuffer stays available) but lets no-CORS cross-origin
# resources load by sending them WITHOUT credentials, removing the hard CORP
# requirement. To switch, comment out the `require-corp` line above and
# enable the line below instead:
#
# add_header Cross-Origin-Embedder-Policy "credentialless" always;
#
# (Browser support: credentialless is supported in modern Chromium/Firefox;
# Safari only supports require-corp, so require-corp is the safer default.)
# ---- SPA routing --------------------------------------------------------
location / {
# Serve the file if it exists, else a matching directory, else fall back
# to index.html so client-side (expo-router) routes resolve. This is the
# canonical SPA fallback.
try_files $uri $uri/ /index.html;
# Apply the computed cache policy (see the map{} above). The header is
# re-asserted here because `add_header` does not inherit into locations
# once a location defines its own add_header directives.
add_header Cache-Control $wisp_cache_control;
# Re-assert the isolation headers inside this location (add_header in an
# outer scope is dropped as soon as a location sets ANY add_header).
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
}
# ---- Android APK download ----------------------------------------------
# The CI build-apk job scps the signed APK to the server, where it is mounted
# into the web root as /usr/share/nginx/html/wisp.apk (see docker-compose.yml).
# If no APK is present yet, this 404s cleanly rather than falling through to
# the SPA index.html (which would download an HTML file named wisp.apk).
location = /wisp.apk {
# Don't cache the APK aggressively so a freshly deployed build is served.
add_header Cache-Control "no-cache" always;
# CORP must still be present on this response under COEP.
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Force a download with a sensible filename and correct MIME type.
types { } # clear inherited type map
default_type application/vnd.android.package-archive;
add_header Content-Disposition 'attachment; filename="wisp.apk"' always;
try_files /wisp.apk =404;
}
# Health check endpoint for the reverse proxy / container orchestrator.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
}