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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# docker/android.Dockerfile
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reproducible Android build environment for Wisp (Expo SDK 56 / RN 0.85).
|
||||
#
|
||||
# This image contains everything needed to run:
|
||||
# bunx expo prebuild --platform android --no-install
|
||||
# cd android && ./gradlew assembleRelease
|
||||
#
|
||||
# It is used both in CI (.gitea/workflows/ci.yml -> build-apk) and locally
|
||||
# (see docs/DEPLOY.md "Build the APK locally") so the APK is built identically
|
||||
# everywhere. The repository itself is mounted in at run time; this image only
|
||||
# provides the toolchain (JDK + Android SDK + Bun), not the source.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinned tool versions (Expo SDK 56 / React Native 0.85 era).
|
||||
# Sourced from node_modules/react-native/gradle/libs.versions.toml:
|
||||
# compileSdk = 36 targetSdk = 36 minSdk = 24
|
||||
# buildTools = 36.0.0
|
||||
# ndkVersion = 27.1.12297006 (installed lazily by Gradle if a module needs it)
|
||||
# AGP = 8.12.0 Kotlin = 2.1.20 Gradle wrapper = 9.3.1
|
||||
#
|
||||
# IMPORTANT: pin to JDK 17. AGP 8.x targets JDK 17; the dev machine may have a
|
||||
# newer JDK (e.g. JDK 26), but CI/Docker MUST use 17 for reproducible, supported
|
||||
# builds. See docs/DEPLOY.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM eclipse-temurin:17-jdk
|
||||
|
||||
# ---- Build-time version arguments (override with --build-arg if needed) ----
|
||||
# Android compile/target SDK platform required by Expo SDK 56 / RN 0.85.
|
||||
ARG ANDROID_PLATFORM_VERSION=36
|
||||
# Android build-tools version matching libs.versions.toml (buildTools = 36.0.0).
|
||||
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
|
||||
# Google's commandline-tools package. "latest" via the published zip; pinned to a
|
||||
# known build number for reproducibility. Update from:
|
||||
# https://developer.android.com/studio#command-line-tools-only
|
||||
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
|
||||
|
||||
# ---- Android SDK locations (both vars set for tool compatibility) ----------
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
# Put the SDK tools on PATH. cmdline-tools live under .../cmdline-tools/latest/bin
|
||||
# (we deliberately install them into a directory literally named "latest", which
|
||||
# is what sdkmanager expects).
|
||||
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools
|
||||
|
||||
# Non-interactive apt for clean CI logs.
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ---- OS packages -----------------------------------------------------------
|
||||
# - unzip/curl: fetch + extract the cmdline-tools zip
|
||||
# - git: expo prebuild / some gradle tasks shell out to git
|
||||
# - bash: scripts/ci-android-sign.sh is bash (the default sh would do, but be explicit)
|
||||
# - ca-certificates: TLS for downloads (sdkmanager, gradle, HF, etc.)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
unzip \
|
||||
curl \
|
||||
git \
|
||||
bash \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- Install Bun (JS package manager / task runner) ------------------------
|
||||
# Wisp uses Bun, not npm/yarn. The official installer drops Bun into ~/.bun.
|
||||
# We run as root in this image, so HOME=/root.
|
||||
ENV BUN_INSTALL=/root/.bun
|
||||
ENV PATH=${BUN_INSTALL}/bin:${PATH}
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& bun --version
|
||||
|
||||
# ---- Install the Android SDK commandline tools -----------------------------
|
||||
# Download the Linux cmdline-tools zip and unpack it into the directory layout
|
||||
# sdkmanager requires: $ANDROID_HOME/cmdline-tools/latest/...
|
||||
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
|
||||
&& curl -fsSL -o /tmp/cmdline-tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
|
||||
&& unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools \
|
||||
# The zip extracts to a top-level "cmdline-tools" dir; move it to "latest".
|
||||
&& mv /tmp/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
|
||||
&& rm -rf /tmp/cmdline-tools.zip /tmp/cmdline-tools
|
||||
|
||||
# ---- Accept licenses and install SDK packages ------------------------------
|
||||
# `yes |` auto-accepts every interactive license prompt. We install:
|
||||
# - platform-tools (adb, etc.)
|
||||
# - platforms;android-36 (compileSdk/targetSdk = 36)
|
||||
# - build-tools;36.0.0 (aapt2, zipalign, apksigner, ...)
|
||||
# The NDK (27.1.12297006) is intentionally NOT pre-installed: most JS-only Expo
|
||||
# apps don't need it, and Gradle will download the exact required NDK on demand.
|
||||
# If your native deps require it at build time, add:
|
||||
# "ndk;27.1.12297006"
|
||||
# to the sdkmanager line below to bake it in (saves time but enlarges the image).
|
||||
RUN yes | sdkmanager --licenses > /dev/null \
|
||||
&& sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
|
||||
&& yes | sdkmanager --licenses > /dev/null
|
||||
|
||||
# Gradle home lives outside the mounted repo so the daemon cache can persist via
|
||||
# a Docker volume between builds (see docs/DEPLOY.md for the local-build command).
|
||||
ENV GRADLE_USER_HOME=/opt/gradle
|
||||
|
||||
# The repository is mounted here at run time (e.g. `-v "$PWD":/workspace`).
|
||||
WORKDIR /workspace
|
||||
|
||||
# Default to an interactive shell; CI overrides this with an explicit build
|
||||
# command (`bash -lc "..."`). Documented usage is in docs/DEPLOY.md.
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# docker/web.Dockerfile
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-stage build for the Wisp web app.
|
||||
#
|
||||
# Stage 1 (builder): Uses the official Bun image to install dependencies and
|
||||
# run the Expo/Metro static web export. The output is a
|
||||
# fully static SPA in /app/dist.
|
||||
#
|
||||
# Stage 2 (runtime): A tiny nginx:alpine image that serves the exported
|
||||
# static files. nginx is configured (docker/nginx.conf)
|
||||
# to send the cross-origin isolation headers that the
|
||||
# multi-threaded WASM Whisper backend requires, namely:
|
||||
# Cross-Origin-Opener-Policy: same-origin
|
||||
# Cross-Origin-Embedder-Policy: require-corp
|
||||
#
|
||||
# Build: docker build -f docker/web.Dockerfile -t wisp-web:latest .
|
||||
# Run: docker run --rm -p 8080:8080 wisp-web:latest
|
||||
#
|
||||
# NOTE: build context must be the repository root (so the whole repo + lockfile
|
||||
# are available to the builder).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: build the static site with Bun + Expo export
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pin to the Bun 1.x line. `oven/bun:1` tracks the latest 1.x patch release;
|
||||
# pin to an exact digest/tag in production if you want fully reproducible builds.
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifest + lockfile first so Docker can cache the (slow) dependency
|
||||
# install layer independently of source changes. `bun.lock` is Bun's lockfile.
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
# Install EXACTLY what the lockfile specifies. --frozen-lockfile fails the build
|
||||
# if package.json and bun.lock have drifted, which keeps CI deterministic.
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Now copy the rest of the repository (app code, assets, config, etc.).
|
||||
# Anything matched by .dockerignore (node_modules, dist, .git ...) is skipped.
|
||||
COPY . .
|
||||
|
||||
# Produce the static web bundle. `bun run export:web` maps to
|
||||
# `expo export --platform web`, which writes the static site to ./dist
|
||||
# (Expo's default --output-dir). app.json sets web.output = "static", so this
|
||||
# emits prerendered HTML + the JS/WASM assets for the SPA.
|
||||
RUN bun run export:web
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: serve the static site with nginx
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM nginx:alpine AS runtime
|
||||
|
||||
# Drop the stock nginx site config and install ours. Our config:
|
||||
# - listens on 8080 (non-privileged port; matches docker-compose + reverse proxy)
|
||||
# - SPA fallback (try_files ... /index.html)
|
||||
# - COOP/COEP/CORP cross-origin isolation headers (for threaded WASM)
|
||||
# - gzip + long-lived caching for hashed static assets
|
||||
# - serves /wisp.apk if an APK has been dropped into the web root
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy the exported static site from the builder stage into nginx's web root.
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Document the port the container listens on (informational; publish with -p).
|
||||
EXPOSE 8080
|
||||
|
||||
# nginx:alpine's default entrypoint already runs nginx in the foreground
|
||||
# ("daemon off") via /docker-entrypoint.sh, so no CMD override is needed.
|
||||
Reference in New Issue
Block a user