Build Wisp: on-device transcription studio (web + native, one codebase)
CI / test (push) Has been cancelled
CI / deploy-web (push) Has been cancelled
CI / build-apk (push) Has been cancelled

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:
2026-06-13 17:54:21 +02:00
parent 97996c9846
commit 9f42ee2460
72 changed files with 7293 additions and 421 deletions
+46
View File
@@ -0,0 +1,46 @@
# .dockerignore
# ---------------------------------------------------------------------------
# Keeps the Docker build context small and the builds reproducible. Without this,
# `COPY . .` in docker/web.Dockerfile would copy the host's node_modules, build
# outputs, and native folders into the image — slow, large, and non-deterministic
# (the builder runs its OWN `bun install` / prebuild). It also avoids leaking any
# local secrets/keystores into the build context.
# ---------------------------------------------------------------------------
# Dependencies — the builder installs these itself from the lockfile.
node_modules
# Build outputs — regenerated inside the image.
dist
web-build
.expo
.metro-health-check*
# Generated native projects — `expo prebuild` regenerates android/ in the
# android build; nothing in the web image needs them.
android
ios
# VCS / CI / editor metadata.
.git
.gitea
.github
.vscode
# Local env + anything secret-ish (keystores, certs). Never bake into images.
.env*
*.keystore
*.jks
*.p12
*.p8
*.key
*.pem
*.mobileprovision
# Docs / OS cruft.
*.md
docs
.DS_Store
# TypeScript build info.
*.tsbuildinfo
+220
View File
@@ -0,0 +1,220 @@
# .gitea/workflows/ci.yml
# ---------------------------------------------------------------------------
# Wisp CI/CD for Gitea Actions (GitHub-Actions-compatible syntax).
#
# Triggers:
# - push to master -> test, then (on success) deploy-web + build-apk
# - pull_request -> test only (no deploys)
#
# Jobs:
# test Lint-gate: typecheck + vitest in the Bun container.
# deploy-web Build the nginx web image and ship it to briggen.dev WITHOUT a
# registry (docker save | gzip | scp | docker load | compose up).
# build-apk Build the Android toolchain image, prebuild + sign + assemble a
# release APK, scp it to the server as /srv/wisp/web/wisp.apk, and
# upload it as a CI artifact.
#
# Required repo secrets (Settings -> Actions -> Secrets):
# SSH_HOST, SSH_USER, SSH_KEY (deploy + apk upload)
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD,
# ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD (apk signing)
# See docs/DEPLOY.md and scripts/gen-keystore.sh.
#
# Runner assumptions: a Gitea Actions runner with Docker available on the host
# (the deploy/apk jobs run docker build/save/load and ssh/scp). The `test` job
# runs inside the oven/bun:1 container.
# ---------------------------------------------------------------------------
name: CI
on:
push:
branches: [master]
pull_request:
# Cancel superseded runs on the same ref to save runner time.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# =========================================================================
# test — typecheck + unit tests. Gates both deploy jobs.
# =========================================================================
test:
runs-on: ubuntu-latest
# Run the test job directly inside the Bun image so bun is preinstalled and
# the environment matches the web build's builder stage.
container:
image: oven/bun:1
steps:
# Check out the repository at the triggering commit.
- name: Checkout
uses: actions/checkout@v4
# Install dependencies exactly as locked (fails on lockfile drift).
- name: Install dependencies
run: bun install --frozen-lockfile
# TypeScript strict typecheck (tsc --noEmit).
- name: Typecheck
run: bun run typecheck
# Vitest unit/property tests (vitest run).
- name: Test
run: bun run test
# =========================================================================
# deploy-web — build image, transfer registry-free, compose up on server.
# Runs only on push to master, after `test` passes.
# =========================================================================
deploy-web:
needs: test
# Guard: only deploy for pushes to master (never on PRs or other branches).
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Build the production web image on the runner. Build context is the repo
# root so the Dockerfile can install deps and run the Expo export.
- name: Build web image
run: docker build -f docker/web.Dockerfile -t wisp-web:latest .
# Make the server's host key known so the subsequent ssh/scp don't fail on
# host-key verification and we are not vulnerable to a blind MITM. We key
# off SSH_HOST; this writes the server's public host keys into known_hosts.
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
# Install the deploy SSH private key from the secret into the agent.
- name: Configure SSH key
run: |
install -m 600 /dev/null ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Registry-free image transfer: stream `docker save | gzip` straight over
# ssh to `gunzip | docker load` on the server. Avoids writing a big tarball
# to disk and avoids needing a Docker registry entirely.
- name: Ship image to server (save | gzip | ssh docker load)
run: |
docker save wisp-web:latest | gzip \
| ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'gunzip | docker load'
# Recreate the service from the freshly loaded image. The compose file must
# already exist at /srv/wisp/docker-compose.yml (one-time server setup).
# `pull_policy: never` in the compose file keeps it from trying a registry pull.
- name: Restart service via docker compose
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'docker compose -f /srv/wisp/docker-compose.yml up -d --remove-orphans'
# Optional: prune dangling images left behind by repeated loads so the
# server disk doesn't fill up with old wisp-web layers.
- name: Prune dangling images on server
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'docker image prune -f'
# =========================================================================
# build-apk — reproducible signed release APK, shipped to server + artifact.
# Runs only on push to master, after `test` passes.
# =========================================================================
build-apk:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Build the Android toolchain image (JDK 17 + Android SDK + Bun). This is
# the reproducible environment described in docker/android.Dockerfile.
- name: Build Android builder image
run: docker build -f docker/android.Dockerfile -t wisp-android:latest .
# Run the full Android build INSIDE the toolchain container with the repo
# bind-mounted at /workspace. All secrets are passed via -e so they live
# only in the container's environment (not on the command line / git).
#
# Steps inside the container:
# 1. bun install (frozen) — JS deps for prebuild + autolinking.
# 2. expo prebuild --platform android --no-install — generate android/.
# 3. scripts/ci-android-sign.sh — decode keystore + patch build.gradle.
# 4. ./gradlew assembleRelease — produce the signed release APK.
#
# The bind mount means the generated android/ + APK are visible on the
# runner afterwards (for scp + artifact upload below).
- name: Build signed release APK
run: |
docker run --rm \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
-e ANDROID_KEYSTORE_BASE64="${{ secrets.ANDROID_KEYSTORE_BASE64 }}" \
-e ANDROID_KEYSTORE_PASSWORD="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
-e ANDROID_KEY_ALIAS="${{ secrets.ANDROID_KEY_ALIAS }}" \
-e ANDROID_KEY_PASSWORD="${{ secrets.ANDROID_KEY_PASSWORD }}" \
wisp-android:latest \
bash -lc '
set -euo pipefail
bun install --frozen-lockfile
bunx expo prebuild --platform android --no-install
bash scripts/ci-android-sign.sh
cd android
./gradlew assembleRelease --no-daemon
'
# Locate the assembled APK. The default Gradle output for the release variant
# is android/app/build/outputs/apk/release/app-release.apk. Capture the path
# for the next steps and fail loudly if it's missing.
- name: Locate APK
id: apk
run: |
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
if [[ ! -f "$APK_PATH" ]]; then
echo "APK not found at $APK_PATH" >&2
find android/app/build/outputs -name '*.apk' -print >&2 || true
exit 1
fi
echo "path=$APK_PATH" >> "$GITHUB_OUTPUT"
# Known_hosts + SSH key for the scp upload (same pattern as deploy-web).
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Configure SSH key
run: |
install -m 600 /dev/null ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Copy the signed APK to the server where nginx serves it at /wisp.apk.
# docker-compose mounts /srv/wisp/web/wisp.apk into the container's web root.
# Ensure the target dir exists, then scp the APK into place.
- name: Upload APK to server (/srv/wisp/web/wisp.apk)
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'mkdir -p /srv/wisp/web'
scp -i ~/.ssh/id_deploy \
"${{ steps.apk.outputs.path }}" \
"${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/srv/wisp/web/wisp.apk"
# Also publish the APK as a CI artifact so it can be downloaded from the
# run's summary page even without server access.
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: wisp-release-apk
path: ${{ steps.apk.outputs.path }}
if-no-files-found: error
+1 -1
View File
@@ -20,7 +20,7 @@
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"output": "single",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
+1530
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
# Self-contained compose project for wisp.briggen.dev.
# Deploy: get the repo onto the server at /root/wisp, then:
# cd /root/wisp && docker compose -f deploy/wisp.compose.yml --project-directory . up -d --build
# Joins the EXISTING external `proxy` network — the shared Traefik stack is untouched.
# Wildcard *.briggen.dev DNS + Traefik's letsencrypt resolver issue TLS automatically.
name: wisp
services:
wisp:
# Build the static web app (Bun + Expo export) into an nginx image that
# serves it with the cross-origin isolation headers threaded WASM needs.
build:
context: .
dockerfile: docker/web.Dockerfile
container_name: wisp
restart: unless-stopped
security_opt:
- no-new-privileges:true
# Static nginx; tiny footprint. Caps keep the blast radius off the shared host.
mem_limit: 256m
memswap_limit: 256m
pids_limit: 200
cpus: 1.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.wisp.rule=Host(`wisp.briggen.dev`)"
- "traefik.http.routers.wisp.entrypoints=websecure"
- "traefik.http.routers.wisp.tls.certresolver=letsencrypt"
- "traefik.http.services.wisp.loadbalancer.server.port=8080"
networks:
- proxy
networks:
proxy:
external: true
+110
View File
@@ -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"]
+142
View File
@@ -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";
}
}
+72
View File
@@ -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.
+220
View File
@@ -0,0 +1,220 @@
# Wisp — Deployment Runbook
CI/CD for Wisp runs on **Gitea Actions**. A push to `master` will:
1. **Deploy the web app** to the `briggen.dev` server as a Docker container
(nginx serving the Expo/Metro static export, with cross-origin isolation
headers for multi-threaded WASM).
2. **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`](../.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)
1. **Install Docker + Compose plugin** on the server (the runner uses
`docker compose` over SSH).
2. **Create the deploy directory and place the compose file:**
```bash
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.apk` until then.)
3. **Create the proxy network** the compose file attaches to (or reuse your
existing reverse-proxy network and rename it in `docker-compose.yml`):
```bash
docker network create proxy
```
4. **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 as `wisp-web:8080` on the `proxy` network. 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 rule
`Host(\`wisp.briggen.dev\`)`, service port `8080`, your TLS cert resolver).
- **Host nginx (proxy not in Docker):** use **Option B** in
`docker-compose.yml` (publish `127.0.0.1:8080:8080`) and
`proxy_pass http://127.0.0.1:8080;`.
> **Do not strip or override** the `Cross-Origin-Opener-Policy` /
> `Cross-Origin-Embedder-Policy` headers at the proxy — they must reach the
> browser intact (see §4).
5. **Ensure the Gitea Actions runner host has Docker.** The `deploy-web` and
`build-apk` jobs run `docker build`, `docker save/load`, `ssh`, and `scp` on
the runner. The `test` job runs inside the `oven/bun:1` container.
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:
```bash
./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:
```bash
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 (`.gitignore` excludes
> `*.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-origin`
- `Cross-Origin-Embedder-Policy: require-corp`
When both are present, `self.crossOriginIsolated === true` and threads/SAB work.
These headers are set in [`docker/nginx.conf`](../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):
```bash
# 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.Dockerfile` both pin **JDK 17** so builds are
> reproducible and supported. Build through the Docker image to avoid local-JDK
> surprises; don't run `./gradlew` against 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.json` does not set `android.package`. Vanilla
> `expo prebuild` will derive a package name (from the `scheme`/slug) — set an
> explicit `expo.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
```
+18 -3
View File
@@ -4,15 +4,19 @@
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~56.0.17",
"@huggingface/transformers": "^4.2.0",
"dexie": "^4.4.3",
"expo": "~56.0.11",
"expo-constants": "~56.0.18",
"expo-device": "~56.0.4",
"expo-document-picker": "^56.0.4",
"expo-font": "~56.0.6",
"expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.11",
"expo-linking": "~56.0.14",
"expo-router": "~56.2.10",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "^56.0.5",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-system-ui": "~56.0.5",
@@ -25,18 +29,29 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3"
"react-native-worklets": "0.8.3",
"whisper.rn": "^0.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
"fake-indexeddb": "^6.2.5",
"fast-check": "^4.8.0",
"typescript": "~6.0.3",
"vitest": "^4.1.8"
},
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"export:web": "expo export --platform web",
"prebuild:android": "expo prebuild --platform android --no-install",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "expo lint"
},
"private": true
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
#
# scripts/ci-android-sign.sh
# ---------------------------------------------------------------------------
# Wire a RELEASE signing config into a freshly-prebuilt Android project.
#
# Run this AFTER `expo prebuild --platform android` has generated ./android,
# and BEFORE `./gradlew assembleRelease`.
#
# It does two things, both IDEMPOTENTLY (safe to re-run):
#
# (a) Decodes the base64 keystore from $ANDROID_KEYSTORE_BASE64 into
# android/app/wisp.keystore.
#
# (b) Patches android/app/build.gradle to:
# - add a `signingConfigs.release { ... }` block that reads the keystore
# path + passwords from Gradle properties (which we source from env),
# - point `buildTypes.release.signingConfig` at it.
# The patch is marker-based: it only injects once, and re-running detects
# the marker and skips.
#
# Secrets / env consumed (provided by CI as repository secrets, exported into
# the build container's environment):
# ANDROID_KEYSTORE_BASE64 base64 of the .keystore file
# ANDROID_KEYSTORE_PASSWORD keystore (store) password
# ANDROID_KEY_ALIAS key alias (we generate keystores with alias 'wisp')
# ANDROID_KEY_PASSWORD key (alias) password
#
# Generate the keystore + learn the exact secret-setting commands with
# scripts/gen-keystore.sh. Full runbook: docs/DEPLOY.md.
# ---------------------------------------------------------------------------
# Strict mode: exit on error, error on unset vars, fail pipelines on any stage.
set -euo pipefail
# --- Resolve paths relative to the repo root (this script lives in scripts/) ---
# So the script works regardless of the caller's current working directory.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)"
ANDROID_DIR="${REPO_ROOT}/android"
APP_DIR="${ANDROID_DIR}/app"
GRADLE_FILE="${APP_DIR}/build.gradle"
# Keep the keystore filename in sync with the signingConfigs block we inject.
KEYSTORE_FILE="${APP_DIR}/wisp.keystore"
# Unique marker so we can detect a prior injection and stay idempotent.
MARKER="// WISP_RELEASE_SIGNING (managed by scripts/ci-android-sign.sh)"
# --- Small logging helpers --------------------------------------------------
log() { printf '[ci-android-sign] %s\n' "$*"; }
fail() { printf '[ci-android-sign] ERROR: %s\n' "$*" >&2; exit 1; }
# --- Preconditions ----------------------------------------------------------
# The android project must already exist (run `expo prebuild` first).
[[ -d "${ANDROID_DIR}" ]] || fail "android/ not found. Run 'expo prebuild --platform android' first."
[[ -f "${GRADLE_FILE}" ]] || fail "Not found: ${GRADLE_FILE}"
# Validate required secrets are present (don't print their values).
: "${ANDROID_KEYSTORE_BASE64:?ANDROID_KEYSTORE_BASE64 is required (base64 of the keystore)}"
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD is required}"
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS is required}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD is required}"
# ---------------------------------------------------------------------------
# (a) Decode the keystore from base64 into android/app/wisp.keystore
# ---------------------------------------------------------------------------
log "Decoding keystore -> ${KEYSTORE_FILE}"
# `base64 -d` reads the secret from stdin. We feed it via printf to avoid an
# "Argument list too long" / arg-leak from putting the blob on the command line.
# Tolerate base64 with or without newlines/whitespace (-w0 producers vs. wrapped).
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 -d > "${KEYSTORE_FILE}" \
|| fail "Failed to base64-decode ANDROID_KEYSTORE_BASE64"
# Sanity check: a real JKS/PKCS12 keystore is more than a few bytes.
if [[ ! -s "${KEYSTORE_FILE}" ]] || [[ "$(wc -c < "${KEYSTORE_FILE}")" -lt 256 ]]; then
fail "Decoded keystore looks too small — is ANDROID_KEYSTORE_BASE64 correct?"
fi
# Lock down the keystore file permissions (best effort).
chmod 600 "${KEYSTORE_FILE}" || true
log "Keystore decoded ($(wc -c < "${KEYSTORE_FILE}") bytes)."
# ---------------------------------------------------------------------------
# (b1) Make the signing credentials available to Gradle.
# ---------------------------------------------------------------------------
# We DON'T hardcode passwords in build.gradle. Instead the injected Groovy reads
# project properties (project.findProperty(...)) which we supply by writing to
# the project-local gradle.properties. This keeps secrets out of the source and
# out of the process list. `assembleRelease` also still works if a CI step
# passes the same values via -P flags.
#
# We APPEND our properties under a marker and strip any previous managed block
# first, so re-runs don't accumulate duplicates.
GRADLE_PROPS="${ANDROID_DIR}/gradle.properties"
PROPS_MARKER="# WISP_RELEASE_SIGNING_PROPS (managed by scripts/ci-android-sign.sh)"
touch "${GRADLE_PROPS}"
# Remove any previously-managed block (from the marker line to EOF). Using a temp
# file keeps this portable across GNU/BSD sed.
if grep -qF "${PROPS_MARKER}" "${GRADLE_PROPS}"; then
log "Refreshing managed signing props in gradle.properties"
# Print everything BEFORE the marker line, then we re-append the fresh block.
awk -v m="${PROPS_MARKER}" 'index($0, m){found=1} !found{print}' \
"${GRADLE_PROPS}" > "${GRADLE_PROPS}.tmp"
mv "${GRADLE_PROPS}.tmp" "${GRADLE_PROPS}"
fi
# Append the fresh managed properties block. The store FILE path is relative to
# the app module (android/app), so just the filename is correct.
{
printf '\n%s\n' "${PROPS_MARKER}"
printf 'WISP_RELEASE_STORE_FILE=wisp.keystore\n'
printf 'WISP_RELEASE_STORE_PASSWORD=%s\n' "${ANDROID_KEYSTORE_PASSWORD}"
printf 'WISP_RELEASE_KEY_ALIAS=%s\n' "${ANDROID_KEY_ALIAS}"
printf 'WISP_RELEASE_KEY_PASSWORD=%s\n' "${ANDROID_KEY_PASSWORD}"
} >> "${GRADLE_PROPS}"
chmod 600 "${GRADLE_PROPS}" || true
# ---------------------------------------------------------------------------
# (b2) Inject the signingConfigs.release block + wire buildTypes.release.
# ---------------------------------------------------------------------------
# Idempotency: if our marker is already present, the gradle file is patched.
if grep -qF "${MARKER}" "${GRADLE_FILE}"; then
log "build.gradle already patched (marker present) — skipping injection."
exit 0
fi
# The Groovy snippet we add INSIDE the existing `signingConfigs { ... }` block.
# It reads from the gradle.properties values written above. We use def-with-
# fallback so a missing property yields a clear gradle error rather than NPE.
# `IFS=` keeps `read` from trimming the leading indentation of the first line.
IFS= read -r -d '' RELEASE_SIGNING_CONFIG <<'GROOVY' || true
release {
// -- WISP managed release signing config --
// Values come from android/gradle.properties (written by
// scripts/ci-android-sign.sh from CI secrets). storeFile is resolved
// relative to this module (android/app).
def wispStoreFile = project.findProperty('WISP_RELEASE_STORE_FILE') ?: 'wisp.keystore'
storeFile file(wispStoreFile)
storePassword project.findProperty('WISP_RELEASE_STORE_PASSWORD')
keyAlias project.findProperty('WISP_RELEASE_KEY_ALIAS')
keyPassword project.findProperty('WISP_RELEASE_KEY_PASSWORD')
}
GROOVY
# We patch with a small, robust Node script (Node is present in the build image
# via Bun/Expo tooling, and string surgery is far safer in JS than in sed for
# multi-line, brace-sensitive Groovy). It performs two edits:
#
# 1. Insert the `release { ... }` config as the FIRST entry inside the
# `signingConfigs {` block (the prebuild template ships a `debug` config
# there; we add `release` alongside it).
# 2. Inside the `buildTypes { ... }`'s `release { ... }` block SPECIFICALLY,
# set `signingConfig signingConfigs.release`. We must scope to the release
# block via brace matching: a naive global replace of
# `signingConfig signingConfigs.debug` would also (wrongly) rewrite the
# DEBUG build type, which legitimately keeps the debug signing config.
# (The Expo/RN template defaults release to the debug signing config so that
# `assembleRelease` works out-of-the-box; we override only the release one.)
#
# Both edits are guarded so the script fails LOUDLY if the expected anchors are
# missing (e.g. a future template change), rather than silently producing an
# unsigned/debug-signed APK.
log "Patching ${GRADLE_FILE}"
WISP_MARKER="${MARKER}" \
WISP_RELEASE_BLOCK="${RELEASE_SIGNING_CONFIG}" \
WISP_GRADLE_FILE="${GRADLE_FILE}" \
node <<'NODE'
const fs = require('fs');
const file = process.env.WISP_GRADLE_FILE;
const marker = process.env.WISP_MARKER;
const block = process.env.WISP_RELEASE_BLOCK;
let src = fs.readFileSync(file, 'utf8');
// --- Edit 1: insert `release { ... }` inside `signingConfigs { ... }` --------
// Match the literal `signingConfigs {` opener (Groovy DSL). We insert right
// after the opening brace so our release config sits beside the template's
// `debug` config.
const scRe = /signingConfigs\s*\{/;
if (!scRe.test(src)) {
console.error("[ci-android-sign] Could not find `signingConfigs {` in build.gradle.");
process.exit(1);
}
src = src.replace(
scRe,
(m) => `${m}\n ${marker}\n${block}`
);
// --- Helper: find the body span of `<keyword> {` via brace matching ----------
// Returns {open, close} string indices of the block's content (between the
// matching braces) for the FIRST occurrence of `<keyword> {` starting at/after
// `fromIndex`, or null if not found / unbalanced. This lets us scope edits to a
// specific nested block (e.g. the `release` block INSIDE `buildTypes`) instead
// of relying on greedy regexes that cross block boundaries.
function findBlockBody(text, keyword, fromIndex = 0) {
const opener = new RegExp(`\\b${keyword}\\s*\\{`, 'g');
opener.lastIndex = fromIndex;
const m = opener.exec(text);
if (!m) return null;
// Index of the `{` that opens the block.
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < text.length; i++) {
const ch = text[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
// body spans (just after the opening brace) .. (this closing brace)
return { open: m.index + m[0].length, close: i };
}
}
}
return null; // unbalanced braces
}
// --- Edit 2: point the RELEASE buildType at signingConfigs.release -----------
// Scope to buildTypes { ... } first, then to its release { ... } sub-block.
const buildTypes = findBlockBody(src, 'buildTypes');
if (!buildTypes) {
console.error("[ci-android-sign] Could not find buildTypes { } in build.gradle.");
process.exit(1);
}
const release = findBlockBody(src, 'release', buildTypes.open);
// Guard: the release block must lie INSIDE buildTypes (defensive against a
// future template where `release` appears elsewhere first).
if (!release || release.close > buildTypes.close) {
console.error("[ci-android-sign] Could not find buildTypes.release { } in build.gradle.");
process.exit(1);
}
let releaseBody = src.slice(release.open, release.close);
if (/signingConfig\s+signingConfigs\.debug/.test(releaseBody)) {
// Normal case: swap the template default (debug) for our release config.
releaseBody = releaseBody.replace(
/signingConfig\s+signingConfigs\.debug/,
'signingConfig signingConfigs.release'
);
} else if (/signingConfig\s+signingConfigs\.release/.test(releaseBody)) {
// Already points at release (e.g. a template that ships it) — nothing to do.
} else {
// No signingConfig line present — inject one as the first statement.
releaseBody = '\n signingConfig signingConfigs.release' + releaseBody;
}
src = src.slice(0, release.open) + releaseBody + src.slice(release.close);
fs.writeFileSync(file, src);
console.log("[ci-android-sign] build.gradle patched: added signingConfigs.release and wired release buildType.");
NODE
log "Done. Release signing is configured. You can now run: (cd android && ./gradlew assembleRelease)"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# scripts/gen-keystore.sh
# ---------------------------------------------------------------------------
# Generate a NEW dedicated release keystore for Wisp, then print the exact
# commands to base64-encode it and the list of Gitea Actions secrets to set.
#
# Run this ONCE on a trusted machine. The resulting keystore is the SINGLE
# source of truth for signing every future Wisp APK — if you lose it (or its
# passwords), you cannot ship an update that upgrades an already-installed APK
# (signature mismatch). BACK IT UP somewhere safe (password manager / offline).
#
# This script does NOT touch CI, does NOT commit anything, and does NOT generate
# a "real" production key for you silently — YOU run it, YOU keep the output.
#
# Requires: keytool (ships with any JDK).
#
# Usage:
# ./scripts/gen-keystore.sh # interactive password prompts
# STORE_PASS=... KEY_PASS=... ./scripts/gen-keystore.sh # non-interactive
# ---------------------------------------------------------------------------
set -euo pipefail
# --- Tunables (override via env) --------------------------------------------
# Output keystore path. Default lands beside the script, OUTSIDE the app tree so
# you don't accidentally commit it (.gitignore already excludes *.jks/*.keystore,
# but we keep it out of the way regardless).
KEYSTORE_OUT="${KEYSTORE_OUT:-./wisp-release.keystore}"
# Key alias. MUST match ANDROID_KEY_ALIAS in CI. scripts/ci-android-sign.sh and
# the docs assume the alias 'wisp'.
KEY_ALIAS="${KEY_ALIAS:-wisp}"
# RSA 2048, valid for 10000 days (~27 years) — Google Play's minimum validity
# recommendation for upload/app-signing keys is well within this.
KEY_ALG="RSA"
KEY_SIZE="2048"
VALIDITY_DAYS="10000"
# Distinguished Name. Edit to taste; only CN is really meaningful for an app key.
DNAME="${DNAME:-CN=Wisp, OU=Wisp, O=briggen.dev, L=, ST=, C=DE}"
log() { printf '[gen-keystore] %s\n' "$*"; }
fail() { printf '[gen-keystore] ERROR: %s\n' "$*" >&2; exit 1; }
# --- Preconditions ----------------------------------------------------------
command -v keytool >/dev/null 2>&1 || fail "keytool not found. Install a JDK (e.g. JDK 17)."
if [[ -e "${KEYSTORE_OUT}" ]]; then
fail "Refusing to overwrite existing keystore at ${KEYSTORE_OUT}. Move/delete it first."
fi
# --- Collect passwords ------------------------------------------------------
# Allow non-interactive use via env (STORE_PASS / KEY_PASS); otherwise prompt.
# We do NOT echo passwords. For most setups STORE_PASS and KEY_PASS can be equal.
STORE_PASS="${STORE_PASS:-}"
KEY_PASS="${KEY_PASS:-}"
if [[ -z "${STORE_PASS}" ]]; then
read -r -s -p "Enter NEW keystore (store) password: " STORE_PASS; echo
read -r -s -p "Confirm keystore password: " STORE_PASS_CONFIRM; echo
[[ "${STORE_PASS}" == "${STORE_PASS_CONFIRM}" ]] || fail "Store passwords do not match."
fi
[[ "${#STORE_PASS}" -ge 6 ]] || fail "Keystore password must be at least 6 characters (keytool requirement)."
if [[ -z "${KEY_PASS}" ]]; then
read -r -s -p "Enter NEW key (alias) password [blank = same as store]: " KEY_PASS; echo
KEY_PASS="${KEY_PASS:-$STORE_PASS}"
fi
# --- Generate the keystore --------------------------------------------------
# -storetype PKCS12 is the modern default (JKS is deprecated). keytool will emit
# a PKCS12 keystore; that's fully supported by Gradle/apksigner.
log "Generating ${KEY_ALG} ${KEY_SIZE} key, alias '${KEY_ALIAS}', valid ${VALIDITY_DAYS} days..."
keytool -genkeypair \
-keystore "${KEYSTORE_OUT}" \
-storetype PKCS12 \
-alias "${KEY_ALIAS}" \
-keyalg "${KEY_ALG}" \
-keysize "${KEY_SIZE}" \
-validity "${VALIDITY_DAYS}" \
-dname "${DNAME}" \
-storepass "${STORE_PASS}" \
-keypass "${KEY_PASS}"
log "Keystore created: ${KEYSTORE_OUT}"
echo
# --- Print follow-up instructions -------------------------------------------
# Show the EXACT base64 command and the secrets to configure in Gitea. We print
# the base64 command (not the base64 itself, and never the passwords) so the
# human runs it themselves and pastes into the Gitea secrets UI.
cat <<EOF
=============================================================================
NEXT STEPS — configure Gitea Actions secrets for the build-apk job
=============================================================================
1) Base64-encode the keystore (single line, no wrapping) and copy it:
# Linux:
base64 -w0 "${KEYSTORE_OUT}"
# macOS (no -w flag; -b0 disables wrapping):
base64 -b0 "${KEYSTORE_OUT}"
Copy the entire single-line output — that is ANDROID_KEYSTORE_BASE64.
2) In Gitea -> the Wisp repo -> Settings -> Actions -> Secrets, add:
ANDROID_KEYSTORE_BASE64 = <the base64 string from step 1>
ANDROID_KEYSTORE_PASSWORD = <the store password you just chose>
ANDROID_KEY_ALIAS = ${KEY_ALIAS}
ANDROID_KEY_PASSWORD = <the key password you just chose>
(These names match scripts/ci-android-sign.sh and .gitea/workflows/ci.yml.)
3) BACK UP "${KEYSTORE_OUT}" and BOTH passwords in a safe place (password
manager / offline). If lost, you can never update an already-installed APK.
4) Do NOT commit the keystore. (.gitignore already excludes *.keystore/*.jks.)
=============================================================================
EOF
+11 -9
View File
@@ -1,15 +1,17 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import AppTabs from '@/components/app-tabs';
export default function TabLayout() {
const colorScheme = useColorScheme();
export default function RootLayout() {
const scheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ title: 'Wisp' }} />
<Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}
-180
View File
@@ -1,180 +0,0 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});
+161 -77
View File
@@ -1,98 +1,182 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Link, useFocusEffect, useRouter } from 'expo-router';
import { useCallback } from 'react';
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import { formatClock } from '@/lib/format';
import { MODELS } from '@/lib/models/catalog';
import { pickAudio } from '@/lib/pickAudio';
import type { TranscriptMeta } from '@/lib/db';
import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts } from '@/stores/transcriptsStore';
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
export default function LibraryScreen() {
const theme = useTheme();
const router = useRouter();
const { items, loading, query, setQuery, refresh, remove } = useTranscripts();
const job = useTranscribe();
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
useFocusEffect(
useCallback(() => {
void refresh();
}, [refresh]),
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
const busy = job.status === 'loading' || job.status === 'transcribing';
const onNew = useCallback(async () => {
if (busy) return;
const picked = await pickAudio();
if (!picked) return;
const id = await useTranscribe.getState().start(picked, { title: picked.name });
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
}, [busy, router]);
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<View style={styles.flex}>
<ThemedText type="subtitle">Wisp</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Private transcription runs on your device, nothing uploaded.
</ThemedText>
</View>
<Link href="/settings" asChild>
<Pressable hitSlop={10}>
<ThemedText type="link" themeColor="textSecondary">
Settings
</ThemedText>
</Pressable>
</Link>
</View>
<Pressable
onPress={onNew}
disabled={busy}
style={({ pressed }) => [
styles.newButton,
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.newButtonText}> New transcription</ThemedText>
</Pressable>
{busy && <ActiveJob />}
{job.status === 'error' && (
<ThemedView type="backgroundElement" style={styles.card}>
<ThemedText type="smallBold">Transcription failed</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
{job.error}
</ThemedText>
</ThemedView>
)}
<TextInput
value={query}
onChangeText={(t) => void setQuery(t)}
placeholder="Search transcripts…"
placeholderTextColor={theme.textSecondary}
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
/>
{loading && items.length === 0 ? (
<ActivityIndicator style={styles.pad} />
) : items.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
{query ? 'No matches.' : 'No transcripts yet. Pick an audio or video file to begin.'}
</ThemedText>
) : (
items.map((t) => (
<TranscriptRow key={t.id} item={t} onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} />
))
)}
</ScrollView>
</ThemedView>
);
}
export default function HomeScreen() {
function ActiveJob() {
const { stage, progress, partial, cancel } = useTranscribe();
const pct = Math.round(progress * 100);
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold">
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
</ThemedText>
<Pressable onPress={cancel} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
</Pressable>
</View>
<ProgressBar value={progress} />
{partial.length > 0 && (
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
{partial.map((s) => s.text).join(' ')}
</ThemedText>
)}
</ThemedView>
);
}
function ProgressBar({ value }: { value: number }) {
return (
<View style={styles.track}>
<View style={[styles.bar, { width: `${Math.max(2, Math.min(100, value * 100))}%` }]} />
</View>
);
}
function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpen: () => void; onDelete: () => void }) {
const date = new Date(item.createdAt).toLocaleDateString();
return (
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
{item.title}
</ThemedText>
<Pressable onPress={onDelete} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary"></ThemedText>
</Pressable>
</View>
<ThemedText type="small" themeColor="textSecondary">
{date} · {formatClock(item.durationSec)} · {MODELS[item.modelId]?.label ?? item.modelId} · {item.segmentCount} segments
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</SafeAreaView>
</ThemedView>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
fill: { flex: 1 },
content: {
padding: Spacing.three,
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
width: '100%',
alignSelf: 'center',
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
flex: { flex: 1 },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
pressed: { opacity: 0.7 },
});
+66
View File
@@ -0,0 +1,66 @@
import { ScrollView, StyleSheet, Pressable, View } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import { listModels } from '@/lib/models/catalog';
import { useTranscribe } from '@/stores/transcribeStore';
export default function SettingsScreen() {
const theme = useTheme();
const modelId = useTranscribe((s) => s.modelId);
const setModel = useTranscribe((s) => s.setModel);
const models = listModels();
return (
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
<ThemedText type="subtitle">Model</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Smaller models are faster and run well on any CPU; larger ones are more accurate but want a GPU.
The model downloads once, then works fully offline.
</ThemedText>
{models.map((m) => {
const selected = m.id === modelId;
return (
<Pressable key={m.id} onPress={() => setModel(m.id)}>
<ThemedView
type={selected ? 'backgroundSelected' : 'backgroundElement'}
style={[styles.card, selected && { borderColor: '#3c87f7', borderWidth: 1 }]}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold">{m.label}</ThemedText>
{selected && <ThemedText type="small" style={{ color: '#3c87f7' }}> selected</ThemedText>}
</View>
<ThemedText type="small" themeColor="textSecondary">
{cap(m.tier)} · ~{m.approxMB} MB · {m.multilingual ? 'multilingual' : 'English-only'}
</ThemedText>
</ThemedView>
</Pressable>
);
})}
<View style={styles.spacer} />
<ThemedText type="subtitle">Privacy</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Wisp transcribes entirely on your device using OpenAI&apos;s Whisper model. Your audio is never
uploaded to any server there is no account, no per-minute fee, and it keeps working with the
network off. The only download is the model file itself.
</ThemedText>
</ScrollView>
</ThemedView>
);
}
function cap(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center' },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.one },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
spacer: { height: Spacing.three },
});
+191
View File
@@ -0,0 +1,191 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import { getRepo, type Transcript } from '@/lib/db';
import { downloadText } from '@/lib/download';
import { EXPORT_META, formatTranscript, type ExportFormat } from '@/lib/export';
import { formatClock } from '@/lib/format';
import type { Segment } from '@/lib/types';
import { useTranscribe } from '@/stores/transcribeStore';
export default function TranscriptScreen() {
const theme = useTheme();
const { id } = useLocalSearchParams<{ id: string }>();
const [transcript, setTranscript] = useState<Transcript | null | undefined>(undefined);
const [title, setTitle] = useState('');
const [segments, setSegments] = useState<Segment[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// The just-transcribed audio is playable in-session (object URL on web).
const audioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined));
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
let alive = true;
void getRepo()
.get(id)
.then((t) => {
if (!alive) return;
setTranscript(t ?? null);
setTitle(t?.title ?? '');
setSegments(t?.segments ?? []);
});
return () => {
alive = false;
};
}, [id]);
// Web-only <audio> element for click-to-seek playback.
useEffect(() => {
if (Platform.OS !== 'web' || !audioUrl) return;
const el = new Audio(audioUrl);
audioRef.current = el;
const onTime = () => setCurrentTime(el.currentTime);
el.addEventListener('timeupdate', onTime);
return () => {
el.pause();
el.removeEventListener('timeupdate', onTime);
audioRef.current = null;
};
}, [audioUrl]);
const activeIndex = useMemo(
() => segments.findIndex((s) => currentTime >= s.start && currentTime < s.end),
[segments, currentTime],
);
const seek = (t: number) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = t;
void el.play();
};
const editSegment = (i: number, text: string) => {
setSegments((prev) => prev.map((s, idx) => (idx === i ? { ...s, text } : s)));
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
await getRepo().update(id, { title, segments });
setDirty(false);
} finally {
setSaving(false);
}
};
const onExport = (fmt: ExportFormat) => {
const meta = EXPORT_META[fmt];
const content = formatTranscript(segments, fmt, { title });
const safeName = (title || 'transcript').replace(/[^\w.-]+/g, '_');
downloadText(`${safeName}.${meta.ext}`, meta.mime, content);
};
if (transcript === undefined) return <Centered><ActivityIndicator /></Centered>;
if (transcript === null)
return (
<Centered>
<ThemedText type="small" themeColor="textSecondary">Transcript not found.</ThemedText>
</Centered>
);
return (
<ThemedView style={styles.fill}>
<Stack.Screen options={{ title: title || 'Transcript' }} />
<ScrollView contentContainerStyle={styles.content}>
<TextInput
value={title}
onChangeText={(t) => {
setTitle(t);
setDirty(true);
}}
style={[styles.title, { color: theme.text }]}
placeholder="Title"
placeholderTextColor={theme.textSecondary}
/>
{!audioUrl && (
<ThemedText type="small" themeColor="textSecondary">
Tip: audio playback is available right after transcribing. Re-import the file to scrub along.
</ThemedText>
)}
<View style={styles.exportRow}>
{(Object.keys(EXPORT_META) as ExportFormat[]).map((fmt) => (
<Pressable
key={fmt}
onPress={() => onExport(fmt)}
style={({ pressed }) => [styles.chip, { backgroundColor: theme.backgroundElement, opacity: pressed ? 0.7 : 1 }]}>
<ThemedText type="small">{EXPORT_META[fmt].label}</ThemedText>
</Pressable>
))}
</View>
{segments.map((s, i) => (
<View key={i} style={[styles.segRow, i === activeIndex && { backgroundColor: theme.backgroundSelected }]}>
<Pressable onPress={() => seek(s.start)} hitSlop={6}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(s.start)}
</ThemedText>
</Pressable>
<TextInput
value={s.text}
onChangeText={(t) => editSegment(i, t)}
multiline
style={[styles.segText, { color: theme.text }]}
/>
</View>
))}
{segments.length === 0 && (
<ThemedText type="small" themeColor="textSecondary">This transcript has no segments.</ThemedText>
)}
</ScrollView>
{dirty && (
<Pressable
onPress={() => void save()}
disabled={saving}
style={({ pressed }) => [styles.saveButton, { opacity: saving ? 0.6 : pressed ? 0.85 : 1 }]}>
<ThemedText style={styles.saveText}>{saving ? 'Saving…' : 'Save changes'}</ThemedText>
</Pressable>
)}
</ThemedView>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return <ThemedView style={[styles.fill, styles.centered]}>{children}</ThemedView>;
}
const styles = StyleSheet.create({
fill: { flex: 1 },
centered: { alignItems: 'center', justifyContent: 'center' },
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center', paddingBottom: Spacing.six },
title: { fontSize: 22, fontWeight: '700', paddingVertical: Spacing.two },
exportRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two, marginBottom: Spacing.two },
chip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
segRow: { flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.one, paddingHorizontal: Spacing.two, borderRadius: Spacing.two, alignItems: 'flex-start' },
ts: { paddingTop: 4, minWidth: 52 },
segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 },
saveButton: { position: 'absolute', bottom: Spacing.three, right: Spacing.three, left: Spacing.three, maxWidth: MaxContentWidth, alignSelf: 'center', backgroundColor: '#3c87f7', paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
saveText: { color: '#fff', fontWeight: '700' },
});
-32
View File
@@ -1,32 +0,0 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}
-115
View File
@@ -1,115 +0,0 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});
+118
View File
@@ -0,0 +1,118 @@
// Native (iOS/Android) audio decoder.
//
// Unlike the web, React Native has no built-in media decoder we can rely on.
// `ffmpeg-kit-react-native` — the package everyone used to reach for — was
// RETIRED by its maintainer in early 2025, so we deliberately do NOT depend on
// it. For now we support only WAV, which we can decode in pure JS via our own
// `decodeWav`. Everything else throws a clear, actionable error.
//
// TODO(audio/native): add a maintained native decoder for compressed formats
// (mp3/m4a/aac/ogg/flac). The current front-runner is
// `react-native-audio-api` (a Web-Audio-style API for RN). When added, route
// non-WAV URIs through it and downmix/resample with `toMono16k`, mirroring the
// web path.
//
// File reading uses Expo SDK 56's object-oriented `File` API
// (`new File(uri).bytes()`), which returns the raw bytes as a `Uint8Array`
// directly — no base64 round-trip needed. We still keep a base64 decode helper
// below as a documented fallback for environments/URIs where `.bytes()` isn't
// available (it reads via `.base64()` and decodes to a Uint8Array by hand).
import { File } from 'expo-file-system';
import type { AudioDecoder, AudioFileInput } from './decode';
import type { PcmAudio } from '../types';
import { decodeWav } from './wav';
import { toMono16k } from './resample';
/**
* Decode a standard base64 string into a `Uint8Array`.
*
* Kept as a small, dependency-free fallback for reading file bytes when the
* direct `File.bytes()` path is unavailable. Uses `atob` when present (RN's
* Hermes provides it) and falls back to a manual base64 table otherwise so this
* never silently breaks on a runtime missing `atob`.
*/
function base64ToBytes(base64: string): Uint8Array {
// Strip any data-URI prefix and whitespace/newlines that some encoders add.
const clean = base64.replace(/^data:[^,]*,/, '').replace(/\s/g, '');
const g = globalThis as unknown as {
atob?: (s: string) => string;
};
if (typeof g.atob === 'function') {
const binary = g.atob(clean);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i) & 0xff;
}
return out;
}
// Manual base64 decode (no `atob`). Standard alphabet, '=' padding.
const ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Length without padding tells us the exact output byte count.
const noPad = clean.replace(/=+$/, '');
const outLen = Math.floor((noPad.length * 3) / 4);
const out = new Uint8Array(outLen);
let buffer = 0;
let bits = 0;
let o = 0;
for (let i = 0; i < noPad.length; i++) {
const c = ALPHABET.indexOf(noPad[i]!);
if (c === -1) continue; // skip any stray non-alphabet char defensively
buffer = (buffer << 6) | c;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (buffer >> bits) & 0xff;
}
}
return out;
}
/**
* Read a local file URI's raw bytes.
*
* Primary path: SDK 56 `File#bytes()` (direct `Uint8Array`). If that throws for
* some reason, fall back to reading base64 and decoding it ourselves.
*/
async function readFileBytes(uri: string): Promise<Uint8Array> {
const file = new File(uri);
try {
return await file.bytes();
} catch {
// Fallback for runtimes/URIs where `.bytes()` isn't supported.
const base64 = await file.base64();
return base64ToBytes(base64);
}
}
export const decoder: AudioDecoder = {
async decode(input: AudioFileInput): Promise<PcmAudio> {
const { uri } = input;
if (!uri) {
throw new Error('Native decoder requires a file `uri`.');
}
// Sniff the container from the URI extension (case-insensitive), ignoring
// any query string / fragment that content URIs sometimes carry.
const path = uri.split(/[?#]/, 1)[0] ?? uri;
const isWav = path.toLowerCase().endsWith('.wav');
if (!isWav) {
throw new Error(
'Only WAV is supported on native for now — ffmpeg-kit-react-native ' +
'was retired in 2025; a maintained native decoder (e.g. ' +
'react-native-audio-api) is a follow-up.',
);
}
const bytes = await readFileBytes(uri);
const { sampleRate, channelData } = decodeWav(bytes);
const samples = toMono16k(channelData, sampleRate);
return { sampleRate: 16000, samples };
},
};
+40
View File
@@ -0,0 +1,40 @@
// Platform-agnostic audio decoding contract.
//
// The job of a decoder is to take some encoded audio (a browser-decodable blob
// on web, a file URI on native) and return `PcmAudio`: 16 kHz mono Float32
// samples in [-1, 1], exactly what Whisper expects.
//
// This file holds ONLY the types/interface. The actual implementations live in
// platform-specific siblings (`decode.web.ts` / `decode.native.ts`) which Metro
// resolves by platform. Consumers should import the resolved `decoder` via
// `index.ts` (`getDecoder()`), never the platform files directly.
import type { PcmAudio } from '../types';
/**
* Input to a decoder. Which field is required depends on the platform:
*
* - Web reads from in-memory `data` (an `ArrayBuffer`, e.g. from a `<input
* type="file">` / drag-and-drop `File.arrayBuffer()`). Nothing is uploaded.
* - Native reads from a `file://` (or content) `uri` on disk.
*
* `name` is an optional original filename, used only for diagnostics / to sniff
* the container extension; decoders must not depend on it being present.
*/
export interface AudioFileInput {
/** Encoded bytes held in memory. Required on web. */
data?: ArrayBuffer;
/** On-disk location of the encoded file. Required on native. */
uri?: string;
/** Original filename, if known (used for extension sniffing / errors). */
name?: string;
}
/**
* Decodes encoded audio into Whisper-ready PCM (16 kHz, mono, [-1, 1]).
* Implementations are platform-specific; see `decode.web.ts` /
* `decode.native.ts`.
*/
export interface AudioDecoder {
decode(input: AudioFileInput): Promise<PcmAudio>;
}
+61
View File
@@ -0,0 +1,61 @@
// Web audio decoder.
//
// Runs FULLY CLIENT-SIDE: the encoded bytes never leave the browser. We hand
// them to the platform's `AudioContext.decodeAudioData`, which can natively
// decode whatever container/codec the browser supports — typically mp3, m4a/mp4
// (AAC), wav, ogg/opus, and (in most modern browsers) flac. The browser does
// the heavy codec work; we then downmix + resample the PCM to 16 kHz mono.
import type { AudioDecoder, AudioFileInput } from './decode';
import type { PcmAudio } from '../types';
import { toMono16k } from './resample';
/**
* Resolve a usable `AudioContext` constructor. Standard browsers expose
* `window.AudioContext`; older Safari only exposes the `webkit`-prefixed one.
*/
function getAudioContextCtor(): typeof AudioContext {
const ctor =
window.AudioContext ||
// Safari fallback; not in the lib DOM types, hence the cast.
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!ctor) {
throw new Error(
'Web Audio API is unavailable: no AudioContext in this environment.',
);
}
return ctor;
}
export const decoder: AudioDecoder = {
async decode(input: AudioFileInput): Promise<PcmAudio> {
const { data } = input;
if (!data) {
throw new Error('Web decoder requires `data` (an ArrayBuffer).');
}
const Ctor = getAudioContextCtor();
const ctx = new Ctor();
try {
// `decodeAudioData` may *detach* (neuter) the ArrayBuffer it's given, so
// we pass a copy via `slice(0)` to keep the caller's buffer reusable.
const buf = await ctx.decodeAudioData(data.slice(0));
// Pull out every channel's Float32 PCM. `getChannelData` returns a view
// backed by the AudioBuffer; `toMono16k` only reads from it, and we close
// the context only after the synchronous downmix/resample completes.
const channelData: Float32Array[] = Array.from(
{ length: buf.numberOfChannels },
(_unused, c) => buf.getChannelData(c),
);
const samples = toMono16k(channelData, buf.sampleRate);
return { sampleRate: 16000, samples };
} finally {
// Always release the AudioContext (browsers cap how many can be live).
// `close()` returns a promise; we don't need to await it before returning.
void ctx.close();
}
},
};
+3
View File
@@ -0,0 +1,3 @@
// Native (iOS/Android) resolution of the platform decoder. Metro picks this on
// native builds.
export { decoder } from './decode.native';
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks decodeImpl.web.ts / decodeImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { decoder } from './decode.web';
+2
View File
@@ -0,0 +1,2 @@
// Web resolution of the platform decoder. Metro picks this on web builds.
export { decoder } from './decode.web';
+15
View File
@@ -0,0 +1,15 @@
// Public entry point for audio decoding.
//
// `./decodeImpl` is resolved by Metro to the right platform sibling
// (`decodeImpl.web.ts` on web, `decodeImpl.native.ts` on iOS/Android), each of
// which re-exports the matching `decoder`. Consumers call `getDecoder()` and
// stay platform-agnostic; they should never import `decode.web` / `decode.native`
// directly.
import { decoder } from './decodeImpl';
/** Return the platform-resolved audio decoder. */
export const getDecoder = () => decoder;
// Re-export the shared types (`AudioDecoder`, `AudioFileInput`).
export * from './decode';
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { resampleLinear, downmixToMono, toMono16k } from './resample';
describe('resampleLinear', () => {
it('returns an equal-valued but distinct copy when fromRate === toRate', () => {
const input = new Float32Array([0.1, -0.2, 0.3, 0.4]);
const out = resampleLinear(input, 16000, 16000);
expect(out).not.toBe(input); // different instance
expect(Array.from(out)).toEqual(Array.from(input)); // same values
// Mutating the copy must not affect the original.
out[0] = 999;
expect(input[0]).toBeCloseTo(0.1, 6);
});
it('returns empty for empty input regardless of rates', () => {
expect(resampleLinear(new Float32Array(0), 8000, 16000).length).toBe(0);
expect(resampleLinear(new Float32Array(0), 16000, 16000).length).toBe(0);
expect(resampleLinear(new Float32Array(0), 44100, 16000).length).toBe(0);
});
it('upsamples 4 samples from 8000 to 16000 into 8 samples', () => {
const input = new Float32Array([0, 1, 2, 3]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(8); // round(4 * 16000 / 8000) = 8
});
it('output length matches Math.round(len * toRate / fromRate) when downsampling', () => {
const input = new Float32Array(10);
const out = resampleLinear(input, 16000, 8000);
expect(out.length).toBe(5); // round(10 * 8000 / 16000) = 5
});
it('keeps a linear ramp monotonic (non-decreasing) after upsampling', () => {
const n = 16;
const input = new Float32Array(n);
for (let i = 0; i < n; i++) input[i] = i; // 0..15 ramp
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(32);
for (let i = 1; i < out.length; i++) {
expect(out[i]!).toBeGreaterThanOrEqual(out[i - 1]!);
}
// Linear interpolation of a perfect ramp preserves endpoints.
expect(out[0]!).toBeCloseTo(0, 6);
});
it('linearly interpolates the midpoint correctly', () => {
// [0, 10] upsampled x2 -> first output stays 0, then a value between 0 and 10.
const input = new Float32Array([0, 10]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(4);
expect(out[0]!).toBeCloseTo(0, 6);
// srcPos for i=1 is 1 * (8000/16000) = 0.5 -> midpoint between 0 and 10.
expect(out[1]!).toBeCloseTo(5, 6);
});
it('never reads past the end (clamps tail) when upsampling', () => {
const input = new Float32Array([1, 2, 3]);
const out = resampleLinear(input, 1000, 3000);
// No NaN / undefined leakage at the tail.
for (let i = 0; i < out.length; i++) {
expect(Number.isFinite(out[i]!)).toBe(true);
}
expect(out[0]!).toBeCloseTo(1, 6);
});
it('handles a single-sample input', () => {
const input = new Float32Array([0.42]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(2);
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.42, 6);
}
});
});
describe('downmixToMono', () => {
it('returns empty Float32Array for no channels', () => {
const out = downmixToMono([]);
expect(out).toBeInstanceOf(Float32Array);
expect(out.length).toBe(0);
});
it('returns a distinct copy for a single channel', () => {
const ch = new Float32Array([0.1, 0.2, 0.3]);
const out = downmixToMono([ch]);
expect(out).not.toBe(ch);
expect(Array.from(out)).toEqual(Array.from(ch));
out[0] = 999;
expect(ch[0]).toBeCloseTo(0.1, 6);
});
it('averages two constant channels (1.0 and 0.0) to all 0.5', () => {
const a = new Float32Array([1, 1, 1, 1]);
const b = new Float32Array([0, 0, 0, 0]);
const out = downmixToMono([a, b]);
expect(out.length).toBe(4);
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.5, 6);
}
});
it('averages three channels sample-wise', () => {
const a = new Float32Array([3, 6, 9]);
const b = new Float32Array([0, 0, 0]);
const c = new Float32Array([0, 3, 0]);
const out = downmixToMono([a, b, c]);
expect(out[0]!).toBeCloseTo(1, 6); // (3+0+0)/3
expect(out[1]!).toBeCloseTo(3, 6); // (6+0+3)/3
expect(out[2]!).toBeCloseTo(3, 6); // (9+0+0)/3
});
});
describe('toMono16k', () => {
it('downmixes then doubles the length when source is 8000 Hz', () => {
const a = new Float32Array([1, 1, 1, 1]);
const b = new Float32Array([0, 0, 0, 0]);
const out = toMono16k([a, b], 8000);
expect(out.length).toBe(8); // round(4 * 16000 / 8000) = 8
// Constant 0.5 mono signal stays 0.5 after resampling.
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.5, 6);
}
});
it('returns empty for no channels', () => {
expect(toMono16k([], 8000).length).toBe(0);
});
it('returns same length when source is already 16000 Hz', () => {
const a = new Float32Array([0.2, 0.4, 0.6]);
const out = toMono16k([a], 16000);
expect(out.length).toBe(3);
expect(Array.from(out)).toEqual(Array.from(a));
});
});
+109
View File
@@ -0,0 +1,109 @@
// Pure audio resampling + channel downmixing utilities.
//
// These are dependency-free numeric helpers used by the decode pipeline to get
// arbitrary PCM into the 16 kHz mono shape Whisper expects. No platform code,
// no Node builtins — just Float32Array math.
import { WHISPER_SAMPLE_RATE } from '../types';
/**
* Resample `input` from `fromRate` to `toRate` using linear interpolation.
*
* - If `fromRate === toRate`, returns a *copy* (never the same instance).
* - Output length is `Math.round(input.length * toRate / fromRate)`.
* - Empty input yields an empty Float32Array.
*
* Linear interpolation maps each output index `i` back to the continuous source
* position `i * fromRate / toRate`, then blends the two neighboring input
* samples. The last source sample is clamped so we never read past the end.
*/
export function resampleLinear(
input: Float32Array,
fromRate: number,
toRate: number,
): Float32Array {
if (input.length === 0) {
return new Float32Array(0);
}
if (fromRate === toRate) {
// Copy: callers rely on getting a distinct array instance.
return input.slice();
}
const outLength = Math.round((input.length * toRate) / fromRate);
if (outLength <= 0) {
return new Float32Array(0);
}
const output = new Float32Array(outLength);
const ratio = fromRate / toRate;
const lastIndex = input.length - 1;
for (let i = 0; i < outLength; i++) {
const srcPos = i * ratio;
let i0 = Math.floor(srcPos);
if (i0 > lastIndex) {
i0 = lastIndex;
}
const frac = srcPos - i0;
const i1 = i0 < lastIndex ? i0 + 1 : lastIndex;
// Guarded indexed access for noUncheckedIndexedAccess: i0/i1 are clamped to
// [0, lastIndex], so these are always defined, but we coalesce to be safe.
const s0 = input[i0] ?? 0;
const s1 = input[i1] ?? 0;
output[i] = s0 + (s1 - s0) * frac;
}
return output;
}
/**
* Average a set of equal-length channels into a single mono channel.
*
* - `[]` -> empty Float32Array.
* - A single channel -> a copy (distinct instance).
* - Otherwise the per-sample mean across all channels.
*
* Channels are assumed to share the same length; the first channel's length
* defines the output length.
*/
export function downmixToMono(channels: Float32Array[]): Float32Array {
if (channels.length === 0) {
return new Float32Array(0);
}
const first = channels[0]!;
if (channels.length === 1) {
return first.slice();
}
const length = first.length;
const out = new Float32Array(length);
const channelCount = channels.length;
for (let c = 0; c < channelCount; c++) {
const channel = channels[c]!;
for (let i = 0; i < length; i++) {
out[i] = (out[i] ?? 0) + (channel[i] ?? 0);
}
}
for (let i = 0; i < length; i++) {
out[i] = (out[i] ?? 0) / channelCount;
}
return out;
}
/**
* Downmix the given channels to mono, then resample to 16 kHz (Whisper rate).
* Convenience composition of {@link downmixToMono} and {@link resampleLinear}.
*/
export function toMono16k(
channels: Float32Array[],
fromRate: number,
): Float32Array {
const mono = downmixToMono(channels);
return resampleLinear(mono, fromRate, WHISPER_SAMPLE_RATE);
}
+290
View File
@@ -0,0 +1,290 @@
import { describe, it, expect } from 'vitest';
import { decodeWav, encodeWav } from './wav';
/** Build a minimal, valid WAV byte buffer by hand for decode tests. */
function buildWav(opts: {
audioFormat: number;
numChannels: number;
sampleRate: number;
bitsPerSample: number;
/** Raw bytes for the data chunk body. */
data: Uint8Array;
/** When true, write the data chunk before the fmt chunk. */
dataFirst?: boolean;
/** Extra unknown chunks (id must be 4 chars) inserted after fmt. */
extraChunks?: { id: string; body: Uint8Array }[];
}): Uint8Array {
const { audioFormat, numChannels, sampleRate, bitsPerSample, data } = opts;
const blockAlign = (numChannels * bitsPerSample) / 8;
const byteRate = sampleRate * blockAlign;
const fmtBody = new Uint8Array(16);
const fmtView = new DataView(fmtBody.buffer);
fmtView.setUint16(0, audioFormat, true);
fmtView.setUint16(2, numChannels, true);
fmtView.setUint32(4, sampleRate, true);
fmtView.setUint32(8, byteRate, true);
fmtView.setUint16(12, blockAlign, true);
fmtView.setUint16(14, bitsPerSample, true);
type Chunk = { id: string; body: Uint8Array };
const fmtChunk: Chunk = { id: 'fmt ', body: fmtBody };
const dataChunk: Chunk = { id: 'data', body: data };
const chunks: Chunk[] = [];
if (opts.dataFirst) {
chunks.push(dataChunk, fmtChunk);
} else {
chunks.push(fmtChunk);
if (opts.extraChunks) chunks.push(...opts.extraChunks);
chunks.push(dataChunk);
}
// Compute total size: 12-byte descriptor + each chunk (8-byte header + body
// + pad byte for odd-length bodies).
let bodyTotal = 0;
for (const c of chunks) {
bodyTotal += 8 + c.body.length + (c.body.length % 2);
}
const total = 12 + bodyTotal;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
const writeTag = (off: number, tag: string) => {
for (let i = 0; i < 4; i++) view.setUint8(off + i, tag.charCodeAt(i));
};
writeTag(0, 'RIFF');
view.setUint32(4, total - 8, true);
writeTag(8, 'WAVE');
let off = 12;
for (const c of chunks) {
writeTag(off, c.id);
view.setUint32(off + 4, c.body.length, true);
out.set(c.body, off + 8);
off += 8 + c.body.length;
if (c.body.length % 2 === 1) off += 1; // pad byte (already zero)
}
return out;
}
describe('encodeWav / decodeWav round-trip', () => {
it('round-trips mono 16 kHz samples within int16 tolerance', () => {
const n = 2048;
const input = new Float32Array(n);
for (let i = 0; i < n; i++) {
input[i] = Math.sin((2 * Math.PI * 440 * i) / 16000) * 0.9;
}
const encoded = encodeWav(input, 16000);
const decoded = decodeWav(encoded);
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(1);
const out = decoded.channelData[0]!;
expect(out.length).toBe(n);
for (let i = 0; i < n; i++) {
expect(Math.abs(out[i]! - input[i]!)).toBeLessThanOrEqual(1 / 32768);
}
});
it('produces a 44-byte header + 2 bytes per sample', () => {
const input = new Float32Array([0, 0.5, -0.5, 1, -1]);
const encoded = encodeWav(input, 16000);
expect(encoded.byteLength).toBe(44 + input.length * 2);
});
it('clamps out-of-range samples to [-1, 1]', () => {
const input = new Float32Array([2, -2, 5, -9]);
const decoded = decodeWav(encodeWav(input, 16000));
const out = decoded.channelData[0]!;
expect(out[0]!).toBeCloseTo(1, 4);
expect(out[1]!).toBeCloseTo(-1, 4);
expect(out[2]!).toBeCloseTo(1, 4);
expect(out[3]!).toBeCloseTo(-1, 4);
});
it('handles an empty sample buffer', () => {
const decoded = decodeWav(encodeWav(new Float32Array(0), 16000));
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]!.length).toBe(0);
});
it('preserves a non-16k sample rate', () => {
const decoded = decodeWav(encodeWav(new Float32Array([0.1, -0.1]), 44100));
expect(decoded.sampleRate).toBe(44100);
});
});
describe('decodeWav stereo de-interleaving', () => {
it('splits an interleaved 16-bit stereo data chunk into 2 channels', () => {
// 3 frames; L = {100, 300, 500}, R = {-100, -300, -500} (int16 values).
const lVals = [100, 300, 500];
const rVals = [-100, -300, -500];
const data = new Uint8Array(3 * 2 * 2);
const dv = new DataView(data.buffer);
for (let f = 0; f < 3; f++) {
dv.setInt16(f * 4, lVals[f]!, true);
dv.setInt16(f * 4 + 2, rVals[f]!, true);
}
const wav = buildWav({
audioFormat: 1,
numChannels: 2,
sampleRate: 16000,
bitsPerSample: 16,
data,
});
const decoded = decodeWav(wav);
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(2);
const left = decoded.channelData[0]!;
const right = decoded.channelData[1]!;
expect(left.length).toBe(3);
expect(right.length).toBe(3);
for (let f = 0; f < 3; f++) {
expect(left[f]!).toBeCloseTo(lVals[f]! / 0x7fff, 5);
expect(right[f]!).toBeCloseTo(rVals[f]! / 0x8000, 5);
}
});
it('decodes chunks in any order (data before fmt)', () => {
const data = new Uint8Array(4);
const dv = new DataView(data.buffer);
dv.setInt16(0, 16383, true); // ~0.5
dv.setInt16(2, -16384, true); // -0.5
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 8000,
bitsPerSample: 16,
data,
dataFirst: true,
});
const decoded = decodeWav(wav);
expect(decoded.sampleRate).toBe(8000);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(16383 / 0x7fff, 4);
expect(decoded.channelData[0]![1]!).toBeCloseTo(-16384 / 0x8000, 4);
});
it('skips unknown chunks (including odd-length ones)', () => {
const data = new Uint8Array(2);
new DataView(data.buffer).setInt16(0, 12345, true);
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data,
extraChunks: [
{ id: 'LIST', body: new Uint8Array([1, 2, 3]) }, // odd length -> pad byte
{ id: 'fact', body: new Uint8Array([0, 0, 0, 0]) },
],
});
const decoded = decodeWav(wav);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]!.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(12345 / 0x7fff, 4);
});
it('decodes 32-bit IEEE float (format 3) data', () => {
// mono, 2 samples: 0.25 and -0.75
const data = new Uint8Array(8);
const dv = new DataView(data.buffer);
dv.setFloat32(0, 0.25, true);
dv.setFloat32(4, -0.75, true);
const wav = buildWav({
audioFormat: 3,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 32,
data,
});
const decoded = decodeWav(wav);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(0.25, 6);
expect(decoded.channelData[0]![1]!).toBeCloseTo(-0.75, 6);
});
});
describe('decodeWav error handling', () => {
it('throws on a tiny non-WAVE buffer', () => {
expect(() => decodeWav(new Uint8Array([1, 2, 3]))).toThrow();
});
it("throws when 'RIFF' magic is missing", () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data: new Uint8Array([0, 0]),
});
wav[0] = 'X'.charCodeAt(0);
expect(() => decodeWav(wav)).toThrow(/RIFF/);
});
it("throws when the form is not 'WAVE'", () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data: new Uint8Array([0, 0]),
});
wav[8] = 'A'.charCodeAt(0);
wav[9] = 'V'.charCodeAt(0);
wav[10] = 'I'.charCodeAt(0);
wav[11] = ' '.charCodeAt(0);
expect(() => decodeWav(wav)).toThrow(/WAVE/);
});
it('throws on an unsupported sample format (8-bit PCM)', () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 8,
data: new Uint8Array([0, 0]),
});
expect(() => decodeWav(wav)).toThrow(/Unsupported/);
});
it("throws when the 'data' chunk is missing", () => {
// Build a RIFF/WAVE with only a fmt chunk.
const fmtBody = new Uint8Array(16);
const fmtView = new DataView(fmtBody.buffer);
fmtView.setUint16(0, 1, true);
fmtView.setUint16(2, 1, true);
fmtView.setUint32(4, 16000, true);
fmtView.setUint32(8, 32000, true);
fmtView.setUint16(12, 2, true);
fmtView.setUint16(14, 16, true);
const total = 12 + 8 + 16;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
const writeTag = (off: number, tag: string) => {
for (let i = 0; i < 4; i++) view.setUint8(off + i, tag.charCodeAt(i));
};
writeTag(0, 'RIFF');
view.setUint32(4, total - 8, true);
writeTag(8, 'WAVE');
writeTag(12, 'fmt ');
view.setUint32(16, 16, true);
out.set(fmtBody, 20);
expect(() => decodeWav(out)).toThrow(/data/);
});
});
+214
View File
@@ -0,0 +1,214 @@
// Minimal, dependency-free WAV (RIFF/WAVE) codec for PCM audio.
//
// Supports decoding:
// - 16-bit integer PCM (fmt audioFormat === 1)
// - 32-bit IEEE float PCM (fmt audioFormat === 3)
// Chunks may appear in any order; unknown chunks are skipped. Channels are
// de-interleaved into one Float32Array per channel, with samples in [-1, 1].
//
// Encoding produces a mono, 16-bit PCM WAVE file.
//
// All multi-byte header fields are little-endian (per the WAVE spec). We use
// DataView so endianness is explicit and never relies on the host platform.
/** A decoded WAVE file: sample rate plus one Float32Array per channel. */
export interface DecodedWav {
sampleRate: number;
/** One entry per channel; each holds that channel's samples in [-1, 1]. */
channelData: Float32Array[];
}
const WAVE_FORMAT_PCM = 1;
const WAVE_FORMAT_IEEE_FLOAT = 3;
/** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */
function readTag(view: DataView, offset: number): string {
return (
String.fromCharCode(view.getUint8(offset)) +
String.fromCharCode(view.getUint8(offset + 1)) +
String.fromCharCode(view.getUint8(offset + 2)) +
String.fromCharCode(view.getUint8(offset + 3))
);
}
/**
* Parse a RIFF/WAVE byte buffer.
*
* @throws if the buffer is too small, is not a RIFF container, is not a WAVE
* form, lacks fmt/data chunks, or uses an unsupported sample format.
*/
export function decodeWav(bytes: Uint8Array): DecodedWav {
// A valid WAVE needs at least the 12-byte RIFF/WAVE descriptor.
if (bytes.byteLength < 12) {
throw new Error('Invalid WAV: file too small to contain a RIFF header');
}
const view = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
if (readTag(view, 0) !== 'RIFF') {
throw new Error("Invalid WAV: missing 'RIFF' magic");
}
if (readTag(view, 8) !== 'WAVE') {
throw new Error("Invalid WAV: not a 'WAVE' file");
}
let audioFormat = -1;
let numChannels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = -1;
let dataLength = 0;
let haveFmt = false;
let haveData = false;
// Walk the chunk list starting right after the 12-byte descriptor.
let offset = 12;
while (offset + 8 <= bytes.byteLength) {
const chunkId = readTag(view, offset);
const chunkSize = view.getUint32(offset + 4, true);
const chunkBody = offset + 8;
if (chunkId === 'fmt ') {
if (chunkBody + 16 > bytes.byteLength) {
throw new Error('Invalid WAV: truncated fmt chunk');
}
audioFormat = view.getUint16(chunkBody, true);
numChannels = view.getUint16(chunkBody + 2, true);
sampleRate = view.getUint32(chunkBody + 4, true);
// bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored)
bitsPerSample = view.getUint16(chunkBody + 14, true);
haveFmt = true;
} else if (chunkId === 'data') {
dataOffset = chunkBody;
// Clamp to the actual buffer in case the declared size overruns it.
dataLength = Math.min(chunkSize, bytes.byteLength - chunkBody);
haveData = true;
}
// Unknown chunks fall through and are skipped.
// Chunks are word-aligned: an odd size is followed by a pad byte.
let advance = chunkSize;
if (advance % 2 === 1) advance += 1;
offset = chunkBody + advance;
}
if (!haveFmt) {
throw new Error("Invalid WAV: missing 'fmt ' chunk");
}
if (!haveData) {
throw new Error("Invalid WAV: missing 'data' chunk");
}
if (numChannels < 1) {
throw new Error(`Invalid WAV: bad channel count ${numChannels}`);
}
const channelData: Float32Array[] = [];
if (audioFormat === WAVE_FORMAT_PCM && bitsPerSample === 16) {
const bytesPerSample = 2;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
const int16 = view.getInt16(base + c * bytesPerSample, true);
// Asymmetric int16 range: divide negatives by 32768, positives by 32767.
const f = int16 < 0 ? int16 / 0x8000 : int16 / 0x7fff;
channelData[c]![frame] = f;
}
}
} else if (
audioFormat === WAVE_FORMAT_IEEE_FLOAT &&
bitsPerSample === 32
) {
const bytesPerSample = 4;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
channelData[c]![frame] = view.getFloat32(
base + c * bytesPerSample,
true,
);
}
}
} else {
throw new Error(
`Unsupported WAV format: audioFormat=${audioFormat}, ` +
`bitsPerSample=${bitsPerSample} (supported: 16-bit PCM, 32-bit float)`,
);
}
return { sampleRate, channelData };
}
/**
* Encode mono samples (in [-1, 1]) into a 16-bit PCM WAVE file.
* Out-of-range samples are clamped before quantization.
*/
export function encodeWav(
samples: Float32Array,
sampleRate: number,
): Uint8Array {
const numChannels = 1;
const bitsPerSample = 16;
const bytesPerSample = bitsPerSample / 8;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const headerSize = 44;
const totalSize = headerSize + dataSize;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const writeTag = (offset: number, tag: string): void => {
for (let i = 0; i < tag.length; i++) {
view.setUint8(offset + i, tag.charCodeAt(i));
}
};
// RIFF descriptor
writeTag(0, 'RIFF');
view.setUint32(4, totalSize - 8, true); // chunkSize = file size minus first 8
writeTag(8, 'WAVE');
// fmt chunk (16 bytes of PCM format data)
writeTag(12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk body size
view.setUint16(20, WAVE_FORMAT_PCM, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
// data chunk
writeTag(36, 'data');
view.setUint32(40, dataSize, true);
let offset = headerSize;
for (let i = 0; i < samples.length; i++) {
let s = samples[i]!;
// Clamp to [-1, 1] to avoid integer overflow/wraparound on quantize.
if (s > 1) s = 1;
else if (s < -1) s = -1;
// Match the asymmetric int16 range used when decoding.
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
view.setInt16(offset, int16, true);
offset += bytesPerSample;
}
return new Uint8Array(buffer);
}
+13
View File
@@ -0,0 +1,13 @@
// Public entry point for the storage layer.
//
// `./repoImpl` has no concrete file — Metro/RN picks repoImpl.web.ts or
// repoImpl.native.ts by platform extension, so the right backend is bundled
// for each target. Consumers import getRepo() and the StorageRepo/schema types
// from here and never touch a platform-specific module directly.
import { repo } from './repoImpl';
/** Accessor for the platform-resolved storage repo. */
export const getRepo = () => repo;
export * from './repo';
export * from './schema';
+233
View File
@@ -0,0 +1,233 @@
// Native storage implementation backed by expo-sqlite (SDK 56 async API).
//
// Mirrors the web/Dexie repo's contract exactly. The full transcript body is
// stored as JSON in the 'json' column; metadata lives in dedicated columns so
// list()/search() can be answered from columns alone without parsing every
// body. Search uses a LIKE over the lowercased 'searchText' column.
//
// API reference: https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync).
import * as SQLite from 'expo-sqlite';
import type { Segment } from '../types';
import { newId } from '../ids';
import {
parseDraft,
zSegment,
type TranscriptDraft,
type TranscriptMeta,
type Transcript,
} from './schema';
import type { StorageRepo } from './repo';
// Row shape as returned by getAllAsync from the metadata columns. SQLite has no
// boolean/undefined: optional language comes back as string | null.
interface MetaRow {
id: string;
title: string;
createdAt: number;
updatedAt: number;
durationSec: number;
segmentCount: number;
modelId: string;
language: string | null;
}
// A meta row plus the JSON body, for get().
interface FullRow extends MetaRow {
json: string;
}
// ---------------------------------------------------------------------------
// Pure derivation helpers
// ---------------------------------------------------------------------------
/** Lowercased haystack of title + every segment's text, for LIKE search. */
function deriveSearchText(title: string, segments: Segment[]): string {
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}
/** Map a metadata row to the public TranscriptMeta (drop null language). */
function rowToMeta(r: MetaRow): TranscriptMeta {
return {
id: r.id,
title: r.title,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
durationSec: r.durationSec,
modelId: r.modelId as TranscriptMeta['modelId'],
segmentCount: r.segmentCount,
...(r.language !== null ? { language: r.language } : {}),
};
}
// ---------------------------------------------------------------------------
// Lazy, memoized database handle + schema bootstrap
// ---------------------------------------------------------------------------
let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;
function getDb(): Promise<SQLite.SQLiteDatabase> {
// Open once and reuse; create the table on first open. The async open returns
// a promise we cache so concurrent callers share a single connection.
if (!dbPromise) {
dbPromise = (async () => {
const db = await SQLite.openDatabaseAsync('wisp.db');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY NOT NULL,
json TEXT NOT NULL,
searchText TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
durationSec REAL NOT NULL,
segmentCount INTEGER NOT NULL,
modelId TEXT NOT NULL,
language TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcripts_createdAt
ON transcripts (createdAt);
`);
return db;
})();
}
return dbPromise;
}
// The metadata columns we select for list()/search(), kept in one place.
const META_COLUMNS =
'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language';
// ---------------------------------------------------------------------------
// Repo implementation
// ---------------------------------------------------------------------------
export const repo: StorageRepo = {
async list(): Promise<TranscriptMeta[]> {
const db = await getDb();
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
);
return rows.map(rowToMeta);
},
async get(id: string): Promise<Transcript | undefined> {
const db = await getDb();
const row = await db.getFirstAsync<FullRow>(
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
[id],
);
if (!row) return undefined;
// The JSON body is the authoritative full Transcript (already validated on
// write), so we return it directly rather than reconstructing from columns.
return JSON.parse(row.json) as Transcript;
},
async create(draft: TranscriptDraft): Promise<Transcript> {
const valid = parseDraft(draft);
const now = Date.now();
const transcript: Transcript = {
id: newId('t_'),
title: valid.title,
createdAt: now,
updatedAt: now,
durationSec: valid.durationSec,
modelId: valid.modelId,
...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length,
segments: valid.segments,
};
const db = await getDb();
await db.runAsync(
`INSERT INTO transcripts
(id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, modelId, language)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
transcript.id,
JSON.stringify(transcript),
deriveSearchText(transcript.title, transcript.segments),
transcript.createdAt,
transcript.updatedAt,
transcript.durationSec,
transcript.segmentCount,
transcript.modelId,
// expo-sqlite accepts null but not undefined for params.
transcript.language ?? null,
],
);
return transcript;
},
async update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const db = await getDb();
const existingRow = await db.getFirstAsync<FullRow>(
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
[id],
);
if (!existingRow) {
throw new Error(`transcript not found: ${id}`);
}
const existing = JSON.parse(existingRow.json) as Transcript;
// Re-validate segments on update so non-finite times can never slip in.
const nextSegments =
patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s))
: existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
const updated: Transcript = {
...existing,
title: nextTitle,
segments: nextSegments,
segmentCount: nextSegments.length,
updatedAt: Date.now(),
};
await db.runAsync(
`UPDATE transcripts
SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?
WHERE id = ?`,
[
JSON.stringify(updated),
deriveSearchText(nextTitle, nextSegments),
updated.updatedAt,
updated.segmentCount,
updated.title,
id,
],
);
return updated;
},
async remove(id: string): Promise<void> {
const db = await getDb();
await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]);
},
async search(query: string): Promise<TranscriptMeta[]> {
const db = await getDb();
const needle = query.toLowerCase().trim();
if (!needle) {
// Empty query => everything, newest first (matches the web repo).
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
);
return rows.map(rowToMeta);
}
// Escape LIKE wildcards (% and _) and the escape char itself so user input
// is matched literally, then use ESCAPE to declare the escape character.
const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts
WHERE searchText LIKE ? ESCAPE '\\'
ORDER BY createdAt DESC`,
[`%${escaped}%`],
);
return rows.map(rowToMeta);
},
};
+150
View File
@@ -0,0 +1,150 @@
// Contract tests for the storage layer, run against the web (Dexie) repo.
//
// fake-indexeddb/auto polyfills IndexedDB into the Node global so Dexie works
// under vitest. We test the Dexie repo as the reference implementation of the
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
// imported here because expo-sqlite cannot load outside a device/simulator.
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach } from 'vitest';
import { repo } from './repo.web';
import { parseDraft, type TranscriptDraft } from './schema';
// Helper: a minimal valid draft, overridable per test.
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
return {
title: 'My Recording',
durationSec: 12.5,
modelId: 'tiny.en',
segments: [
{ start: 0, end: 2, text: 'hello world' },
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
],
...over,
};
}
// Wipe storage between tests so ordering/search assertions are deterministic.
beforeEach(async () => {
const all = await repo.list();
await Promise.all(all.map((m) => repo.remove(m.id)));
});
describe('StorageRepo (Dexie web impl)', () => {
it('create -> get round-trips segments and metadata', async () => {
const created = await repo.create(makeDraft());
expect(created.id).toMatch(/^t_/);
expect(created.createdAt).toBeTypeOf('number');
expect(created.updatedAt).toBe(created.createdAt);
expect(created.segmentCount).toBe(2);
const fetched = await repo.get(created.id);
expect(fetched).toBeDefined();
expect(fetched!.title).toBe('My Recording');
expect(fetched!.durationSec).toBe(12.5);
expect(fetched!.modelId).toBe('tiny.en');
// Segments survive the round-trip exactly.
expect(fetched!.segments).toEqual([
{ start: 0, end: 2, text: 'hello world' },
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
]);
});
it('get returns undefined for an unknown id', async () => {
expect(await repo.get('t_nope')).toBeUndefined();
});
it('list returns metadatas newest-first (without segments)', async () => {
const a = await repo.create(makeDraft({ title: 'first' }));
// Force distinct, increasing createdAt timestamps.
await new Promise((r) => setTimeout(r, 2));
const b = await repo.create(makeDraft({ title: 'second' }));
await new Promise((r) => setTimeout(r, 2));
const c = await repo.create(makeDraft({ title: 'third' }));
const metas = await repo.list();
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
// Metas omit the segment body.
expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
expect(metas[0]!.segmentCount).toBe(2);
});
it('update changes title + segments and re-derives searchText for search', async () => {
const created = await repo.create(
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
);
const originalUpdatedAt = created.updatedAt;
await new Promise((r) => setTimeout(r, 2));
const updated = await repo.update(created.id, {
title: 'Brand New Title',
segments: [
{ start: 0, end: 1, text: 'banana' },
{ start: 1, end: 2, text: 'cherry' },
],
});
expect(updated.title).toBe('Brand New Title');
expect(updated.segmentCount).toBe(2);
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
// Old text is no longer findable...
expect(await repo.search('apple')).toHaveLength(0);
// ...and the new text + new title are.
const byText = await repo.search('cherry');
expect(byText.map((m) => m.id)).toEqual([created.id]);
const byTitle = await repo.search('brand new');
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
});
it('update throws for an unknown id', async () => {
await expect(repo.update('t_missing', { title: 'x' })).rejects.toThrow();
});
it('search matches title and segment text case-insensitively', async () => {
const t = await repo.create(
makeDraft({
title: 'Quarterly MEETING notes',
segments: [{ start: 0, end: 3, text: 'Discuss the BUDGET forecast' }],
}),
);
// Title match, different case.
expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
// Segment-text match, different case.
expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
// Non-match.
expect(await repo.search('zzz-not-present')).toHaveLength(0);
});
it('remove deletes a transcript', async () => {
const created = await repo.create(makeDraft());
expect(await repo.get(created.id)).toBeDefined();
await repo.remove(created.id);
expect(await repo.get(created.id)).toBeUndefined();
expect(await repo.list()).toHaveLength(0);
});
it('parseDraft rejects a NaN duration', () => {
expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
});
it('parseDraft rejects a non-finite segment time', () => {
expect(() =>
parseDraft(makeDraft({ segments: [{ start: 0, end: Infinity, text: 'x' }] })),
).toThrow();
expect(() =>
parseDraft(makeDraft({ segments: [{ start: NaN, end: 1, text: 'x' }] })),
).toThrow();
});
it('create rejects an invalid draft (defense in depth)', async () => {
// create() calls parseDraft internally, so bad input never persists.
await expect(
repo.create(makeDraft({ durationSec: -1 })),
).rejects.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
// The platform-agnostic storage contract.
//
// Both the web (Dexie/IndexedDB) and native (expo-sqlite) implementations
// satisfy this interface, so the rest of the app depends only on StorageRepo
// and never on a specific backend. The correct implementation is selected at
// build time via platform file extensions (see repoImpl.web.ts / repoImpl.native.ts).
import type { TranscriptDraft, TranscriptMeta, Transcript } from './schema';
export interface StorageRepo {
/** All transcript metadatas, newest first (by createdAt desc). */
list(): Promise<TranscriptMeta[]>;
/** Full transcript by id, or undefined if it does not exist. */
get(id: string): Promise<Transcript | undefined>;
/** Validate the draft and persist a new transcript, returning the stored row. */
create(draft: TranscriptDraft): Promise<Transcript>;
/**
* Patch a transcript's title and/or segments. Re-derives any denormalized
* fields (searchText, segmentCount, updatedAt) and re-validates segments.
* Rejects if the id does not exist.
*/
update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript>;
/** Delete by id. No-op if absent. */
remove(id: string): Promise<void>;
/**
* Case-insensitive substring search over title + segment text.
* Returns metadatas, newest first.
*/
search(query: string): Promise<TranscriptMeta[]>;
}
+150
View File
@@ -0,0 +1,150 @@
// Web storage implementation backed by Dexie (IndexedDB).
//
// We keep ONE object store ('transcripts') keyed by id. Each record holds the
// full Transcript plus a derived, lowercased 'searchText' (title + all segment
// text) used for case-insensitive substring search. Search is done in-memory
// (filter over all rows) because IndexedDB has no native substring index;
// transcript counts are small enough on-device that this is fine.
import Dexie from 'dexie';
import type { Segment } from '../types';
import { newId } from '../ids';
import {
parseDraft,
zSegment,
type TranscriptDraft,
type TranscriptMeta,
type Transcript,
} from './schema';
import type { StorageRepo } from './repo';
// The shape persisted in IndexedDB: a full Transcript plus the derived
// searchText column. searchText is never returned to callers.
interface StoredTranscript extends Transcript {
searchText: string;
}
// ---------------------------------------------------------------------------
// Pure derivation helpers (shared by create/update within this file)
// ---------------------------------------------------------------------------
/** Lowercased haystack of title + every segment's text, for substring search. */
function deriveSearchText(title: string, segments: Segment[]): string {
// Join with newlines so adjacent segments don't accidentally fuse into a
// word that matches a query spanning the boundary.
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}
/** Strip the derived searchText, returning the public Transcript view. */
function toTranscript(row: StoredTranscript): Transcript {
const { searchText: _searchText, ...rest } = row;
return rest;
}
/** Strip both searchText and segments, returning the lightweight meta view. */
function toMeta(row: StoredTranscript): TranscriptMeta {
const { searchText: _searchText, segments: _segments, ...meta } = row;
return meta;
}
// ---------------------------------------------------------------------------
// Dexie database
// ---------------------------------------------------------------------------
class WispDexie extends Dexie {
// Only 'id' and 'createdAt' need to be indexed: id is the primary key,
// createdAt powers the newest-first ordering. searchText is matched by an
// in-memory filter, so it does not need an index.
transcripts!: Dexie.Table<StoredTranscript, string>;
constructor() {
super('wisp');
this.version(1).stores({
transcripts: 'id, createdAt',
});
}
}
const db = new WispDexie();
// ---------------------------------------------------------------------------
// Repo implementation
// ---------------------------------------------------------------------------
export const repo: StorageRepo = {
async list(): Promise<TranscriptMeta[]> {
// Sort by createdAt then reverse for newest-first.
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
return rows.map(toMeta);
},
async get(id: string): Promise<Transcript | undefined> {
const row = await db.transcripts.get(id);
return row ? toTranscript(row) : undefined;
},
async create(draft: TranscriptDraft): Promise<Transcript> {
// Validate+throw before doing anything else.
const valid = parseDraft(draft);
const now = Date.now();
const stored: StoredTranscript = {
id: newId('t_'),
title: valid.title,
createdAt: now,
updatedAt: now,
durationSec: valid.durationSec,
modelId: valid.modelId,
// Preserve optional language only when present (avoid storing undefined).
...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length,
segments: valid.segments,
searchText: deriveSearchText(valid.title, valid.segments),
};
await db.transcripts.put(stored);
return toTranscript(stored);
},
async update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const existing = await db.transcripts.get(id);
if (!existing) {
throw new Error(`transcript not found: ${id}`);
}
// Re-validate segments on every write so an update can never persist
// non-finite times that a create() would have rejected.
const nextSegments =
patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s))
: existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
const updated: StoredTranscript = {
...existing,
title: nextTitle,
segments: nextSegments,
// Re-derive denormalized fields so search() and list() stay correct.
segmentCount: nextSegments.length,
searchText: deriveSearchText(nextTitle, nextSegments),
updatedAt: Date.now(),
};
await db.transcripts.put(updated);
return toTranscript(updated);
},
async remove(id: string): Promise<void> {
await db.transcripts.delete(id);
},
async search(query: string): Promise<TranscriptMeta[]> {
const needle = query.toLowerCase().trim();
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
// Empty query => return everything (newest first), matching common list UX.
const matched = needle
? rows.filter((r) => r.searchText.includes(needle))
: rows;
return matched.map(toMeta);
},
};
+4
View File
@@ -0,0 +1,4 @@
// Platform shim (native): re-exports the expo-sqlite repo.
// Metro/RN resolves './repoImpl' to this file on iOS/Android via the
// .native extension, keeping expo-sqlite out of the web bundle.
export { repo } from './repo.native';
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks repoImpl.web.ts / repoImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { repo } from './repo.web';
+3
View File
@@ -0,0 +1,3 @@
// Platform shim (web): re-exports the Dexie/IndexedDB repo.
// Metro/RN resolves './repoImpl' to this file on web via the .web extension.
export { repo } from './repo.web';
+122
View File
@@ -0,0 +1,122 @@
// Zod schemas + domain types for stored transcripts.
//
// This module is the single source of truth for what a "valid" transcript
// looks like before it ever touches storage. Every write path (web Dexie repo,
// native SQLite repo) funnels user/engine-produced data through these schemas
// so malformed numbers (NaN/Infinity) and bad shapes can never be persisted.
//
// IMPORTANT: relative imports only inside src/lib (vitest has no '@/*' alias).
import { z } from 'zod';
import type { Segment, ModelId } from '../types';
// ---------------------------------------------------------------------------
// Reusable number guards
// ---------------------------------------------------------------------------
// A finite number: rejects NaN and ±Infinity. `z.number()` alone accepts both,
// which is dangerous for durations/timestamps that get serialized to JSON/SQL.
const zFinite = z.number().refine(Number.isFinite, {
message: 'must be a finite number (no NaN/Infinity)',
});
// A finite number that is also >= 0, for durations and time offsets.
const zFiniteNonNeg = zFinite.refine((n) => n >= 0, {
message: 'must be >= 0',
});
// ---------------------------------------------------------------------------
// Segment
// ---------------------------------------------------------------------------
/**
* One transcribed segment as accepted from a draft.
* Mirrors {@link Segment} from ../types but with runtime validation:
* - start/end: finite and >= 0 (seconds, absolute to the media)
* - text: any string (may be empty in pathological cases)
* - confidence: optional, finite and within [0, 1]
*/
export const zSegment = z.object({
start: zFiniteNonNeg,
end: zFiniteNonNeg,
text: z.string(),
confidence: zFinite
.refine((n) => n >= 0 && n <= 1, { message: 'confidence must be in [0,1]' })
.optional(),
});
// The 6 supported model ids, as a Zod enum. Kept in lockstep with ModelId in
// ../types. A compile-time assertion below guarantees they never drift.
export const zModelId = z.enum([
'tiny.en',
'tiny',
'base.en',
'base',
'small.en',
'small',
]);
// Compile-time guard: if ../types ModelId changes, this assignment fails to
// typecheck, forcing zModelId to be updated. (No runtime cost.)
type _ModelIdMatches = ModelId extends z.infer<typeof zModelId>
? z.infer<typeof zModelId> extends ModelId
? true
: never
: never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _modelIdMatches: _ModelIdMatches = true;
// ---------------------------------------------------------------------------
// Transcript draft (input to create())
// ---------------------------------------------------------------------------
/**
* The unsaved shape a caller hands to the repo to create a transcript.
* No id/timestamps/segmentCount yet — the repo derives those.
*/
export const zTranscriptDraft = z.object({
title: z.string(),
durationSec: zFiniteNonNeg,
modelId: zModelId,
language: z.string().optional(),
segments: z.array(zSegment),
});
/** Inferred TS type for a validated draft. */
export type TranscriptDraft = z.infer<typeof zTranscriptDraft>;
/**
* Validate an unknown input as a TranscriptDraft, throwing (ZodError) on any
* violation. This is the throwing gatekeeper the repos call on every write.
*/
export function parseDraft(input: unknown): TranscriptDraft {
return zTranscriptDraft.parse(input);
}
// ---------------------------------------------------------------------------
// Stored types (output of the repo)
// ---------------------------------------------------------------------------
/**
* Lightweight metadata for a stored transcript — everything except the
* (potentially large) segments array. Returned by list()/search() so the UI
* can render lists without loading full bodies.
*/
export interface TranscriptMeta {
id: string;
title: string;
/** Creation time, ms since epoch (runtime clock). */
createdAt: number;
/** Last-update time, ms since epoch (runtime clock). */
updatedAt: number;
durationSec: number;
modelId: ModelId;
language?: string;
/** Number of segments in the body (denormalized for list views). */
segmentCount: number;
}
/** A full stored transcript: metadata plus the segment body. */
export interface Transcript extends TranscriptMeta {
segments: Segment[];
}
+19
View File
@@ -0,0 +1,19 @@
import { Platform } from 'react-native';
/**
* Trigger a client-side file download on web (no server, nothing uploaded).
* Returns false on native, where the caller should fall back to share/save.
*/
export function downloadText(filename: string, mime: string, content: string): boolean {
if (Platform.OS !== 'web') return false;
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return true;
}
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest';
import type { Segment } from '../types';
import {
toSrt,
toVtt,
toTxt,
toMarkdown,
toJson,
formatTranscript,
EXPORT_META,
type ExportFormat,
type TranscriptExport,
} from './index';
// A small fixture spanning a sub-minute, a multi-minute, and an hour-plus time.
const segments: Segment[] = [
{ start: 0, end: 2.5, text: 'Hello world.' },
{ start: 2.5, end: 65.25, text: ' Leading and trailing space. ' },
{ start: 3661.123, end: 3663.5, text: 'After one hour.' },
];
describe('toSrt', () => {
it('numbers cues sequentially starting at 1', () => {
const out = toSrt(segments);
const lines = out.split('\n');
expect(lines[0]).toBe('1');
// Cues are separated by a blank line; indices appear as standalone lines.
expect(out).toContain('\n\n2\n');
expect(out).toContain('\n\n3\n');
});
it('uses HH:MM:SS,mmm timecodes with a comma separator', () => {
const out = toSrt(segments);
expect(out).toContain('00:00:00,000 --> 00:00:02,500');
expect(out).toContain('00:00:02,500 --> 00:01:05,250');
});
it('renders hour-plus timestamps correctly', () => {
const out = toSrt(segments);
// 3661.123s -> 01:01:01,123 ; 3663.5s -> 01:01:03,500
expect(out).toContain('01:01:01,123 --> 01:01:03,500');
});
it('trims segment text', () => {
const out = toSrt(segments);
expect(out).toContain('Leading and trailing space.');
expect(out).not.toContain(' Leading and trailing space. ');
});
it('separates cues by exactly one blank line and has no trailing newline', () => {
const out = toSrt([
{ start: 0, end: 1, text: 'a' },
{ start: 1, end: 2, text: 'b' },
]);
expect(out).toBe('1\n00:00:00,000 --> 00:00:01,000\na\n\n2\n00:00:01,000 --> 00:00:02,000\nb');
});
it('returns empty string for empty input', () => {
expect(toSrt([])).toBe('');
});
});
describe('toVtt', () => {
it('starts with the WEBVTT header followed by a blank line', () => {
const out = toVtt(segments);
expect(out.startsWith('WEBVTT\n\n')).toBe(true);
});
it('uses a "." millisecond separator in cues', () => {
const out = toVtt(segments);
expect(out).toContain('00:00:00.000 --> 00:00:02.500');
expect(out).toContain('01:01:01.123 --> 01:01:03.500');
// No SRT-style comma separators should appear.
expect(out).not.toMatch(/\d{2}:\d{2}:\d{2},\d{3}/);
});
it('trims segment text', () => {
const out = toVtt(segments);
expect(out).toContain('Leading and trailing space.');
});
it('returns exactly the header for empty input', () => {
expect(toVtt([])).toBe('WEBVTT\n\n');
});
});
describe('toTxt', () => {
it('emits one trimmed segment per line', () => {
const out = toTxt(segments);
expect(out).toBe('Hello world.\nLeading and trailing space.\nAfter one hour.');
});
it('contains no timestamp-like substrings', () => {
const out = toTxt(segments);
// No HH:MM:SS, no m:ss clocks, and no bracketed times.
expect(out).not.toMatch(/\d{2}:\d{2}:\d{2}/);
expect(out).not.toMatch(/\d+:\d{2}/);
expect(out).not.toMatch(/\[/);
});
it('returns empty string for empty input', () => {
expect(toTxt([])).toBe('');
});
});
describe('toMarkdown', () => {
it('renders bold clock prefixes using formatClock', () => {
const out = toMarkdown(segments);
// Prefix uses the segment START, formatted by formatClock.
expect(out).toContain('**[0:00]** Hello world.');
expect(out).toContain('**[0:02]** Leading and trailing space.');
// Hour-plus start uses h:mm:ss.
expect(out).toContain('**[1:01:01]** After one hour.');
});
it('prepends a "# {title}" heading when a title is provided', () => {
const out = toMarkdown(segments, { title: 'My Talk' });
expect(out.startsWith('# My Talk\n\n')).toBe(true);
expect(out).toContain('**[0:00]** Hello world.');
});
it('omits the heading when no title is given', () => {
const out = toMarkdown(segments);
expect(out.startsWith('#')).toBe(false);
});
it('emits just the heading for empty input with a title', () => {
expect(toMarkdown([], { title: 'Empty' })).toBe('# Empty');
});
it('returns empty string for empty input without a title', () => {
expect(toMarkdown([])).toBe('');
});
});
describe('toJson', () => {
it('round-trips via JSON.parse with version, segments and durationSec', () => {
const out = toJson(segments, { title: 'Talk' });
const parsed = JSON.parse(out) as TranscriptExport;
expect(parsed.version).toBe(1);
expect(parsed.title).toBe('Talk');
expect(parsed.segments).toEqual(segments);
// durationSec is the last (max) end.
expect(parsed.durationSec).toBe(3663.5);
});
it('computes durationSec as the max segment end, not necessarily the last', () => {
const out = toJson([
{ start: 0, end: 10, text: 'a' },
{ start: 1, end: 4, text: 'b' },
]);
expect((JSON.parse(out) as TranscriptExport).durationSec).toBe(10);
});
it('is pretty-printed with 2-space indentation', () => {
const out = toJson(segments);
expect(out).toContain('\n "version": 1');
});
it('omits title when not provided', () => {
const out = toJson(segments);
const parsed = JSON.parse(out) as TranscriptExport;
expect('title' in parsed).toBe(false);
});
it('yields durationSec 0 and empty segments for empty input', () => {
const parsed = JSON.parse(toJson([])) as TranscriptExport;
expect(parsed.durationSec).toBe(0);
expect(parsed.segments).toEqual([]);
expect(parsed.version).toBe(1);
});
});
describe('formatTranscript dispatch', () => {
const cases: ExportFormat[] = ['srt', 'vtt', 'txt', 'md', 'json'];
it('dispatches to each formatter identically to calling it directly', () => {
expect(formatTranscript(segments, 'srt')).toBe(toSrt(segments));
expect(formatTranscript(segments, 'vtt')).toBe(toVtt(segments));
expect(formatTranscript(segments, 'txt')).toBe(toTxt(segments));
expect(formatTranscript(segments, 'md', { title: 'T' })).toBe(toMarkdown(segments, { title: 'T' }));
expect(formatTranscript(segments, 'json', { title: 'T' })).toBe(toJson(segments, { title: 'T' }));
});
it('produces non-throwing output for every format on empty input', () => {
for (const fmt of cases) {
expect(() => formatTranscript([], fmt)).not.toThrow();
}
expect(formatTranscript([], 'srt')).toBe('');
expect(formatTranscript([], 'vtt')).toBe('WEBVTT\n\n');
});
});
describe('EXPORT_META', () => {
it('has an entry with ext/mime/label for every format', () => {
const formats: ExportFormat[] = ['srt', 'vtt', 'txt', 'md', 'json'];
for (const fmt of formats) {
const meta = EXPORT_META[fmt];
expect(meta.ext).toBeTruthy();
expect(meta.mime).toContain('/');
expect(meta.label).toBeTruthy();
}
});
it('maps the file extension to match the format key', () => {
expect(EXPORT_META.srt.ext).toBe('srt');
expect(EXPORT_META.vtt.ext).toBe('vtt');
expect(EXPORT_META.json.mime).toBe('application/json');
});
});
+56
View File
@@ -0,0 +1,56 @@
// Export-format registry and dispatcher. Re-exports each formatter and maps a
// format id to its serializer plus file metadata (extension / MIME / label).
import type { Segment } from '../types';
import { toSrt } from './srt';
import { toVtt } from './vtt';
import { toTxt } from './txt';
import { toMarkdown } from './md';
import { toJson } from './json';
export { toSrt } from './srt';
export { toVtt } from './vtt';
export { toTxt } from './txt';
export { toMarkdown } from './md';
export { toJson } from './json';
export type { TranscriptExport } from './json';
/** Supported export formats. */
export type ExportFormat = 'srt' | 'vtt' | 'txt' | 'md' | 'json';
/** File metadata for each format, for download / share UIs. */
export const EXPORT_META: Record<ExportFormat, { ext: string; mime: string; label: string }> = {
srt: { ext: 'srt', mime: 'application/x-subrip', label: 'SubRip (.srt)' },
vtt: { ext: 'vtt', mime: 'text/vtt', label: 'WebVTT (.vtt)' },
txt: { ext: 'txt', mime: 'text/plain', label: 'Plain text (.txt)' },
md: { ext: 'md', mime: 'text/markdown', label: 'Markdown (.md)' },
json: { ext: 'json', mime: 'application/json', label: 'JSON (.json)' },
};
/**
* Serialize segments using the requested format. `opts.title` is honored by the
* Markdown and JSON formatters and ignored by the others.
*/
export function formatTranscript(
segments: Segment[],
format: ExportFormat,
opts?: { title?: string },
): string {
switch (format) {
case 'srt':
return toSrt(segments);
case 'vtt':
return toVtt(segments);
case 'txt':
return toTxt(segments);
case 'md':
return toMarkdown(segments, opts);
case 'json':
return toJson(segments, opts);
default: {
// Exhaustiveness guard: if `ExportFormat` grows, this fails to compile.
const never: never = format;
throw new Error(`Unknown export format: ${String(never)}`);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// JSON (.json) export. Stable, versioned, machine-readable transcript shape.
import type { Segment } from '../types';
/** Serializable transcript envelope. `version` is bumped on breaking changes. */
export interface TranscriptExport {
version: 1;
title?: string;
/** Total duration in seconds = the largest segment end (0 if no segments). */
durationSec: number;
segments: Segment[];
}
/**
* Render segments as pretty-printed (2-space) JSON.
* `durationSec` is the maximum segment `end` (0 when there are no segments).
* A `title` is included only when provided and non-empty.
*/
export function toJson(segments: Segment[], opts?: { title?: string }): string {
let durationSec = 0;
for (const seg of segments) {
if (seg.end > durationSec) durationSec = seg.end;
}
const title = opts?.title?.trim();
const payload: TranscriptExport = {
version: 1,
...(title ? { title } : {}),
durationSec,
segments,
};
return JSON.stringify(payload, null, 2);
}
+19
View File
@@ -0,0 +1,19 @@
// Markdown (.md) export. Optional title heading, then `**[m:ss]** text` lines.
import type { Segment } from '../types';
import { formatClock } from '../format';
/**
* Render segments as Markdown. Each segment becomes a line like
* `**[m:ss]** text` using {@link formatClock}. When `opts.title` is provided a
* `# {title}` heading is emitted first, separated from the body by a blank line.
*/
export function toMarkdown(segments: Segment[], opts?: { title?: string }): string {
const lines = segments.map((seg) => `**[${formatClock(seg.start)}]** ${seg.text.trim()}`);
const body = lines.join('\n');
const title = opts?.title?.trim();
if (title) {
return body.length > 0 ? `# ${title}\n\n${body}` : `# ${title}`;
}
return body;
}
+25
View File
@@ -0,0 +1,25 @@
// SubRip (.srt) export. Numbered cues with `HH:MM:SS,mmm` timecodes.
import type { Segment } from '../types';
import { formatTimecode } from '../format';
/**
* Render segments as SubRip subtitles.
*
* Each cue is:
* <index>
* HH:MM:SS,mmm --> HH:MM:SS,mmm
* <text>
* with a blank line between cues. Empty input yields an empty string.
*/
export function toSrt(segments: Segment[]): string {
if (segments.length === 0) return '';
const cues = segments.map((seg, i) => {
const index = i + 1;
const start = formatTimecode(seg.start, ',');
const end = formatTimecode(seg.end, ',');
const text = seg.text.trim();
return `${index}\n${start} --> ${end}\n${text}`;
});
return cues.join('\n\n');
}
+12
View File
@@ -0,0 +1,12 @@
// Plain-text (.txt) export. One trimmed segment per line, no timestamps.
import type { Segment } from '../types';
/**
* Render segments as plain text: each segment's trimmed text on its own line.
* No timestamps, no numbering. Empty input yields an empty string.
*/
export function toTxt(segments: Segment[]): string {
if (segments.length === 0) return '';
return segments.map((seg) => seg.text.trim()).join('\n');
}
+22
View File
@@ -0,0 +1,22 @@
// WebVTT (.vtt) export. Header + cues with `HH:MM:SS.mmm` timecodes.
import type { Segment } from '../types';
import { formatTimecode } from '../format';
const HEADER = 'WEBVTT\n\n';
/**
* Render segments as WebVTT. Always starts with the `WEBVTT` header followed by
* a blank line. Cues use `.` as the millisecond separator. Empty input yields
* just the header.
*/
export function toVtt(segments: Segment[]): string {
if (segments.length === 0) return HEADER;
const cues = segments.map((seg) => {
const start = formatTimecode(seg.start, '.');
const end = formatTimecode(seg.end, '.');
const text = seg.text.trim();
return `${start} --> ${end}\n${text}`;
});
return HEADER + cues.join('\n\n') + '\n';
}
+31
View File
@@ -0,0 +1,31 @@
// Time formatting shared by the export formatters and the UI. Pure & tested.
/**
* Format seconds as a subtitle timecode `HH:MM:SS<sep>mmm`.
* @param sep millisecond separator: `,` for SRT, `.` for WebVTT.
*/
export function formatTimecode(totalSeconds: number, sep: ',' | '.' = '.'): string {
const safe = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
const ms = Math.round(safe * 1000);
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}${sep}${pad(millis, 3)}`;
}
/** Short human clock for UI / Markdown: `m:ss` or `h:mm:ss`. */
export function formatClock(totalSeconds: number): string {
const safe = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
const whole = Math.floor(safe);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const seconds = whole % 60;
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
+6
View File
@@ -0,0 +1,6 @@
// Small id helper for transcripts/jobs. Sortable-ish (time prefix) + random tail.
export function newId(prefix = ''): string {
const time = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 10);
return `${prefix}${time}${rand}`;
}
+295
View File
@@ -0,0 +1,295 @@
import { describe, it, expect } from 'vitest';
import {
createJob,
isTerminal,
reduceJob,
jobProgress,
type Job,
type JobEvent,
} from './queue';
describe('createJob', () => {
it('starts pending with zeroed counters and no error', () => {
const job = createJob('abc');
expect(job).toEqual({
id: 'abc',
status: 'pending',
chunkIndex: 0,
chunkCount: 0,
});
expect(job.error).toBeUndefined();
});
it('preserves the given id', () => {
expect(createJob('xyz-123').id).toBe('xyz-123');
});
});
describe('isTerminal', () => {
it('is false for in-flight states', () => {
expect(isTerminal(createJob('a'))).toBe(false);
expect(isTerminal({ ...createJob('a'), status: 'decoding' })).toBe(false);
expect(isTerminal({ ...createJob('a'), status: 'transcribing' })).toBe(
false,
);
});
it('is true for done, error, and cancelled', () => {
expect(isTerminal({ ...createJob('a'), status: 'done' })).toBe(true);
expect(isTerminal({ ...createJob('a'), status: 'error' })).toBe(true);
expect(isTerminal({ ...createJob('a'), status: 'cancelled' })).toBe(true);
});
});
describe('reduceJob — full happy path', () => {
it('pending -> decoding -> transcribing(3) -> chunkDone 1,2,3 -> complete, progress 1', () => {
let job = createJob('job1');
expect(job.status).toBe('pending');
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'startDecode' });
expect(job.status).toBe('decoding');
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
expect(job.status).toBe('transcribing');
expect(job.chunkCount).toBe(3);
expect(job.chunkIndex).toBe(0);
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job.chunkIndex).toBe(1);
expect(jobProgress(job)).toBeCloseTo(1 / 3, 10);
job = reduceJob(job, { type: 'chunkDone', index: 2 });
expect(job.chunkIndex).toBe(2);
expect(jobProgress(job)).toBeCloseTo(2 / 3, 10);
job = reduceJob(job, { type: 'chunkDone', index: 3 });
expect(job.chunkIndex).toBe(3);
expect(jobProgress(job)).toBe(1);
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(3);
expect(isTerminal(job)).toBe(true);
expect(jobProgress(job)).toBe(1);
});
it('complete forces chunkIndex up to chunkCount even if chunks were not all reported', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 5 });
job = reduceJob(job, { type: 'chunkDone', index: 2 });
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(5);
expect(jobProgress(job)).toBe(1);
});
it('complete with zero chunkCount yields done with progress 1', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(0);
expect(job.chunkCount).toBe(0);
// done short-circuits to 1 regardless of counts.
expect(jobProgress(job)).toBe(1);
});
});
describe('reduceJob — fail', () => {
it('sets error status, captures the message, and is terminal', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startDecode' });
job = reduceJob(job, { type: 'fail', error: 'decode boom' });
expect(job.status).toBe('error');
expect(job.error).toBe('decode boom');
expect(isTerminal(job)).toBe(true);
});
it('can fail straight from transcribing', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
job = reduceJob(job, { type: 'chunkDone', index: 1 });
job = reduceJob(job, { type: 'fail', error: 'gpu lost' });
expect(job.status).toBe('error');
expect(job.error).toBe('gpu lost');
// partial progress is retained on failure
expect(job.chunkIndex).toBe(1);
expect(job.chunkCount).toBe(4);
});
});
describe('reduceJob — cancel', () => {
it('moves to cancelled and is terminal', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startDecode' });
job = reduceJob(job, { type: 'cancel' });
expect(job.status).toBe('cancelled');
expect(isTerminal(job)).toBe(true);
});
it('can cancel from pending', () => {
const job = reduceJob(createJob('j'), { type: 'cancel' });
expect(job.status).toBe('cancelled');
});
});
describe('reduceJob — events after terminal are ignored', () => {
const terminalStatuses: Array<Job['status']> = ['done', 'error', 'cancelled'];
const followups: JobEvent[] = [
{ type: 'startDecode' },
{ type: 'startTranscribe', chunkCount: 9 },
{ type: 'chunkDone', index: 5 },
{ type: 'complete' },
{ type: 'fail', error: 'late failure' },
{ type: 'cancel' },
];
for (const status of terminalStatuses) {
for (const ev of followups) {
it(`${status} job ignores ${ev.type} (same reference returned)`, () => {
const terminal: Job = {
id: 'j',
status,
chunkIndex: 2,
chunkCount: 4,
...(status === 'error' ? { error: 'orig' } : {}),
};
const result = reduceJob(terminal, ev);
// Unchanged AND same reference (no allocation).
expect(result).toBe(terminal);
expect(result).toEqual(terminal);
});
}
}
});
describe('reduceJob — purity (no mutation, new object)', () => {
it('does not mutate its input', () => {
const job = createJob('j');
const snapshot = { ...job };
const next = reduceJob(job, { type: 'startDecode' });
expect(job).toEqual(snapshot);
expect(next).not.toBe(job);
expect(next.status).toBe('decoding');
// original untouched
expect(job.status).toBe('pending');
});
it('returns a new object for every state-changing event', () => {
let job = createJob('j');
const seen = new Set<Job>();
seen.add(job);
const events: JobEvent[] = [
{ type: 'startDecode' },
{ type: 'startTranscribe', chunkCount: 2 },
{ type: 'chunkDone', index: 1 },
{ type: 'chunkDone', index: 2 },
{ type: 'complete' },
];
for (const ev of events) {
const next = reduceJob(job, ev);
expect(seen.has(next)).toBe(false);
seen.add(next);
job = next;
}
});
it('chunkDone does not mutate the input when clamping/ignoring', () => {
const job: Job = {
id: 'j',
status: 'transcribing',
chunkIndex: 3,
chunkCount: 3,
};
const snapshot = { ...job };
const next = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job).toEqual(snapshot); // input untouched
expect(next).not.toBe(job); // still a fresh object
expect(next.chunkIndex).toBe(3); // monotonic: stays at 3
});
});
describe('reduceJob — chunkDone clamping and monotonicity', () => {
it('clamps index to chunkCount', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
job = reduceJob(job, { type: 'chunkDone', index: 99 });
expect(job.chunkIndex).toBe(3);
expect(jobProgress(job)).toBe(1);
});
it('never moves backward (monotonic) across chunkDone events', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 10 });
const sequence = [3, 1, 5, 2, 5, 6, 4, 10, 7];
let prevProgress = jobProgress(job);
let prevIndex = job.chunkIndex;
for (const idx of sequence) {
job = reduceJob(job, { type: 'chunkDone', index: idx });
expect(job.chunkIndex).toBeGreaterThanOrEqual(prevIndex);
expect(jobProgress(job)).toBeGreaterThanOrEqual(prevProgress);
prevIndex = job.chunkIndex;
prevProgress = jobProgress(job);
}
expect(job.chunkIndex).toBe(10);
expect(jobProgress(job)).toBe(1);
});
it('treats a smaller index as a no-op for progress', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
job = reduceJob(job, { type: 'chunkDone', index: 3 });
job = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job.chunkIndex).toBe(3);
});
});
describe('jobProgress', () => {
it('is 0 before chunkCount is known', () => {
expect(jobProgress(createJob('j'))).toBe(0);
expect(
jobProgress({ ...createJob('j'), status: 'decoding' }),
).toBe(0);
});
it('is the chunk ratio while transcribing', () => {
const job: Job = {
id: 'j',
status: 'transcribing',
chunkIndex: 1,
chunkCount: 4,
};
expect(jobProgress(job)).toBe(0.25);
});
it('is 1 for a done job regardless of counts', () => {
expect(
jobProgress({ id: 'j', status: 'done', chunkIndex: 0, chunkCount: 0 }),
).toBe(1);
});
it('reflects partial progress for error/cancelled (not forced to 1)', () => {
const errored: Job = {
id: 'j',
status: 'error',
chunkIndex: 1,
chunkCount: 4,
error: 'x',
};
expect(jobProgress(errored)).toBe(0.25);
const cancelled: Job = {
id: 'j',
status: 'cancelled',
chunkIndex: 2,
chunkCount: 4,
};
expect(jobProgress(cancelled)).toBe(0.5);
});
});
+111
View File
@@ -0,0 +1,111 @@
// Pure, framework-free state machine for a single transcription job.
//
// A job moves through a fixed lifecycle:
// pending -> decoding -> transcribing -> done
// with `error` and `cancelled` as alternative terminal states reachable from
// any non-terminal state. The reducer is PURE: it never mutates its input and
// always returns a fresh object. Events delivered to a terminal job are
// ignored (the same reference is returned), so callers can fire-and-forget.
/** Lifecycle states a job can occupy. */
export type JobStatus =
| 'pending'
| 'decoding'
| 'transcribing'
| 'done'
| 'error'
| 'cancelled';
/** Immutable snapshot of a job's progress through the pipeline. */
export interface Job {
/** Stable, caller-assigned identity. */
id: string;
/** Current lifecycle state. */
status: JobStatus;
/** Number of chunks fully transcribed so far. */
chunkIndex: number;
/** Total chunks to transcribe; 0 until `startTranscribe`. */
chunkCount: number;
/** Human-readable failure reason; present only when status === 'error'. */
error?: string;
}
/** Events that drive a job forward. */
export type JobEvent =
| { type: 'startDecode' }
| { type: 'startTranscribe'; chunkCount: number }
/** `index` = total chunks now completed (cumulative, not a delta). */
| { type: 'chunkDone'; index: number }
| { type: 'complete' }
| { type: 'fail'; error: string }
| { type: 'cancel' };
/** Create a fresh job in the initial `pending` state. */
export function createJob(id: string): Job {
return { id, status: 'pending', chunkIndex: 0, chunkCount: 0 };
}
/** True once the job can no longer change state. */
export function isTerminal(job: Job): boolean {
return (
job.status === 'done' ||
job.status === 'error' ||
job.status === 'cancelled'
);
}
/**
* Pure reducer. Returns a NEW Job for any state-changing event; returns the
* SAME reference (unchanged) when the job is already terminal. Never mutates
* `job`.
*/
export function reduceJob(job: Job, ev: JobEvent): Job {
// Terminal jobs absorb all further events with no change.
if (isTerminal(job)) return job;
switch (ev.type) {
case 'startDecode':
return { ...job, status: 'decoding' };
case 'startTranscribe':
return {
...job,
status: 'transcribing',
chunkCount: ev.chunkCount,
};
case 'chunkDone': {
// Progress is monotonic (never goes backward) and never exceeds the
// known chunk count.
const advanced = Math.max(job.chunkIndex, ev.index);
const clamped = Math.min(advanced, job.chunkCount);
return { ...job, chunkIndex: clamped };
}
case 'complete':
return { ...job, status: 'done', chunkIndex: job.chunkCount };
case 'fail':
return { ...job, status: 'error', error: ev.error };
case 'cancel':
return { ...job, status: 'cancelled' };
default: {
// Exhaustiveness guard: if a new event type is added without handling,
// this fails to compile.
const _exhaustive: never = ev;
return _exhaustive;
}
}
}
/**
* Fractional progress in [0, 1]. A `done` job is always 1. Otherwise it is the
* ratio of completed chunks to total, or 0 when the total is not yet known.
*/
export function jobProgress(job: Job): number {
if (job.status === 'done') return 1;
if (job.chunkCount > 0) return job.chunkIndex / job.chunkCount;
return 0;
}
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';
import type { ModelId, Backend } from '../types';
import {
MODELS,
DEFAULT_MODEL,
recommendModel,
modelsByTier,
listModels,
type ModelInfo,
type ModelTier,
type DeviceCaps,
} from './catalog';
const ALL_IDS: ModelId[] = [
'tiny.en',
'tiny',
'base.en',
'base',
'small.en',
'small',
];
describe('MODELS catalog', () => {
it('contains exactly the six expected ids', () => {
const keys = Object.keys(MODELS).sort();
expect(keys).toEqual([...ALL_IDS].sort());
});
it('each entry id field matches its key', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(info.id).toBe(id);
}
});
it('populates required descriptive fields with sane values', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(typeof info.label).toBe('string');
expect(info.label.length).toBeGreaterThan(0);
expect(info.approxMB).toBeGreaterThan(0);
expect(info.webRepo.length).toBeGreaterThan(0);
expect(info.ggml.length).toBeGreaterThan(0);
}
});
it('webRepo and ggml follow the documented naming conventions', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(info.webRepo).toBe(`Xenova/whisper-${id}`);
expect(info.ggml).toBe(`ggml-${id}.bin`);
}
});
it('marks .en ids as English-only and the rest as multilingual', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
if (id.endsWith('.en')) {
expect(info.multilingual).toBe(false);
} else {
expect(info.multilingual).toBe(true);
}
}
});
it('assigns tiers by family: tiny=fast, base=balanced, small=accurate', () => {
const expected: Record<ModelId, ModelTier> = {
'tiny.en': 'fast',
tiny: 'fast',
'base.en': 'balanced',
base: 'balanced',
'small.en': 'accurate',
small: 'accurate',
};
for (const id of ALL_IDS) {
expect(MODELS[id].tier).toBe(expected[id]);
}
});
it('uses the documented rough size buckets', () => {
expect(MODELS['tiny.en'].approxMB).toBe(75);
expect(MODELS.tiny.approxMB).toBe(75);
expect(MODELS['base.en'].approxMB).toBe(145);
expect(MODELS.base.approxMB).toBe(145);
expect(MODELS['small.en'].approxMB).toBe(480);
expect(MODELS.small.approxMB).toBe(480);
});
});
describe('DEFAULT_MODEL', () => {
it('is tiny.en', () => {
expect(DEFAULT_MODEL).toBe('tiny.en');
});
it('is a real catalog entry', () => {
expect(MODELS[DEFAULT_MODEL]).toBeDefined();
});
});
describe('recommendModel', () => {
const gpuBackends: Backend[] = ['webgpu', 'coreml', 'metal'];
const cpuBackends: Backend[] = ['cpu', 'wasm'];
it('recommends base.en for every GPU-class backend', () => {
for (const backend of gpuBackends) {
expect(recommendModel({ backend })).toBe('base.en');
}
});
it('recommends tiny.en for every CPU-class backend', () => {
for (const backend of cpuBackends) {
expect(recommendModel({ backend })).toBe('tiny.en');
}
});
it('recommends tiny.en when lowMemory, even on a GPU backend', () => {
for (const backend of gpuBackends) {
expect(recommendModel({ backend, lowMemory: true })).toBe('tiny.en');
}
});
it('recommends tiny.en when lowMemory on a CPU backend', () => {
for (const backend of cpuBackends) {
expect(recommendModel({ backend, lowMemory: true })).toBe('tiny.en');
}
});
it('treats lowMemory:false the same as omitting it', () => {
expect(recommendModel({ backend: 'webgpu', lowMemory: false })).toBe(
'base.en',
);
expect(recommendModel({ backend: 'cpu', lowMemory: false })).toBe(
'tiny.en',
);
});
it('never recommends a small.* model for any input', () => {
const allBackends: Backend[] = [...gpuBackends, ...cpuBackends];
for (const backend of allBackends) {
for (const lowMemory of [undefined, false, true]) {
const caps: DeviceCaps = { backend, lowMemory };
const picked = recommendModel(caps);
expect(picked.startsWith('small')).toBe(false);
}
}
});
it('always returns an id present in the catalog', () => {
const allBackends: Backend[] = [...gpuBackends, ...cpuBackends];
for (const backend of allBackends) {
const picked = recommendModel({ backend });
expect(MODELS[picked]).toBeDefined();
}
});
});
describe('modelsByTier', () => {
it('returns the tiny models for the fast tier', () => {
const fast = modelsByTier('fast');
const ids = fast.map((m) => m.id).sort();
expect(ids).toEqual(['tiny', 'tiny.en']);
});
it('returns the base models for the balanced tier', () => {
const balanced = modelsByTier('balanced');
const ids = balanced.map((m) => m.id).sort();
expect(ids).toEqual(['base', 'base.en']);
});
it('returns the small models for the accurate tier', () => {
const accurate = modelsByTier('accurate');
const ids = accurate.map((m) => m.id).sort();
expect(ids).toEqual(['small', 'small.en']);
});
it('returns entries whose tier matches the query', () => {
const tiers: ModelTier[] = ['fast', 'balanced', 'accurate'];
for (const tier of tiers) {
for (const info of modelsByTier(tier)) {
expect(info.tier).toBe(tier);
}
}
});
});
describe('listModels', () => {
it('lists all six models', () => {
const list = listModels();
expect(list).toHaveLength(6);
});
it('returns full ModelInfo objects with matching ids', () => {
for (const info of listModels()) {
const typed: ModelInfo = info;
expect(MODELS[typed.id]).toEqual(typed);
}
});
it('covers exactly the catalog ids with no duplicates', () => {
const ids = listModels().map((m) => m.id);
expect(new Set(ids).size).toBe(ids.length);
expect([...ids].sort()).toEqual([...ALL_IDS].sort());
});
});
+129
View File
@@ -0,0 +1,129 @@
// Whisper model catalog plus a small device-aware recommendation policy.
// Pure data + functions: no platform, no I/O. Downstream UI and the engine
// loader read from MODELS to know download sizes, repos, and ggml filenames.
import type { ModelId, Backend } from '../types';
/** Rough capability bucket for a model, used for UX grouping and sorting. */
export type ModelTier = 'fast' | 'balanced' | 'accurate';
/** Static metadata describing a single Whisper model variant. */
export interface ModelInfo {
/** Canonical model id (matches its key in {@link MODELS}). */
id: ModelId;
/** Human-friendly display name. */
label: string;
/** Speed/accuracy bucket. */
tier: ModelTier;
/** True for the general multilingual variants; false for `.en` (English-only). */
multilingual: boolean;
/** Approximate on-disk / download size in megabytes (rough, for UX). */
approxMB: number;
/** Hugging Face repo for the web/transformers.js backend, e.g. 'Xenova/whisper-tiny.en'. */
webRepo: string;
/** ggml binary filename for the native whisper.cpp backend, e.g. 'ggml-tiny.en.bin'. */
ggml: string;
}
/**
* The full catalog of supported models, keyed by id. Tiers:
* tiny* -> 'fast', base* -> 'balanced', small* -> 'accurate'.
* `.en` ids are English-only (multilingual=false); the bare ids are multilingual.
*/
export const MODELS: Record<ModelId, ModelInfo> = {
'tiny.en': {
id: 'tiny.en',
label: 'Tiny (English)',
tier: 'fast',
multilingual: false,
approxMB: 75,
webRepo: 'Xenova/whisper-tiny.en',
ggml: 'ggml-tiny.en.bin',
},
tiny: {
id: 'tiny',
label: 'Tiny',
tier: 'fast',
multilingual: true,
approxMB: 75,
webRepo: 'Xenova/whisper-tiny',
ggml: 'ggml-tiny.bin',
},
'base.en': {
id: 'base.en',
label: 'Base (English)',
tier: 'balanced',
multilingual: false,
approxMB: 145,
webRepo: 'Xenova/whisper-base.en',
ggml: 'ggml-base.en.bin',
},
base: {
id: 'base',
label: 'Base',
tier: 'balanced',
multilingual: true,
approxMB: 145,
webRepo: 'Xenova/whisper-base',
ggml: 'ggml-base.bin',
},
'small.en': {
id: 'small.en',
label: 'Small (English)',
tier: 'accurate',
multilingual: false,
approxMB: 480,
webRepo: 'Xenova/whisper-small.en',
ggml: 'ggml-small.en.bin',
},
small: {
id: 'small',
label: 'Small',
tier: 'accurate',
multilingual: true,
approxMB: 480,
webRepo: 'Xenova/whisper-small',
ggml: 'ggml-small.bin',
},
};
/** The safe out-of-the-box default: fast, small download, no language baggage. */
export const DEFAULT_MODEL: ModelId = 'tiny.en';
/** What we know about the current device's compute path and memory pressure. */
export interface DeviceCaps {
backend: Backend;
/** When true, prefer the smallest model regardless of backend. */
lowMemory?: boolean;
}
/** Backends that have meaningful GPU-class acceleration. */
const GPU_BACKENDS: ReadonlySet<Backend> = new Set<Backend>([
'webgpu',
'coreml',
'metal',
]);
/**
* Pick a sensible default model for a device.
* Policy:
* - lowMemory -> 'tiny.en' (always wins)
* - GPU-class backend (webgpu/coreml/metal) -> 'base.en'
* - CPU-class backend (cpu/wasm) -> 'tiny.en'
* Never recommends a 'small.*' model as a default.
*/
export function recommendModel(caps: DeviceCaps): ModelId {
if (caps.lowMemory) return 'tiny.en';
if (GPU_BACKENDS.has(caps.backend)) return 'base.en';
return 'tiny.en';
}
/** All models in a given tier, in catalog (declaration) order. */
export function modelsByTier(tier: ModelTier): ModelInfo[] {
return listModels().filter((m) => m.tier === tier);
}
/** All models as a flat list, in catalog (declaration) order. */
export function listModels(): ModelInfo[] {
return Object.values(MODELS);
}
+27
View File
@@ -0,0 +1,27 @@
// Cross-platform audio/video file picker that returns an AudioFileInput the
// decoder understands: an ArrayBuffer on web, a file uri on native.
import * as DocumentPicker from 'expo-document-picker';
import { Platform } from 'react-native';
import type { AudioFileInput } from '@/lib/audio';
export type PickedAudio = AudioFileInput & { name: string };
export async function pickAudio(): Promise<PickedAudio | null> {
const res = await DocumentPicker.getDocumentAsync({
type: ['audio/*', 'video/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (res.canceled || res.assets.length === 0) return null;
const asset = res.assets[0]!;
const name = asset.name || 'recording';
if (Platform.OS === 'web') {
// On web the uri is a blob: URL — read it into an ArrayBuffer for WebAudio.
const data = await (await fetch(asset.uri)).arrayBuffer();
return { data, name };
}
return { uri: asset.uri, name };
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { planChunks } from './chunking';
const SR = 16000;
describe('planChunks', () => {
it('returns [] for empty or invalid lengths', () => {
expect(planChunks(0)).toEqual([]);
expect(planChunks(-5)).toEqual([]);
});
it('emits a single chunk covering everything when it fits one window', () => {
const total = 10 * SR;
const p = planChunks(total, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(p).toHaveLength(1);
expect(p[0]).toMatchObject({
index: 0,
startSample: 0,
endSample: total,
isFirst: true,
isLast: true,
});
expect(p[0]!.endSec).toBeCloseTo(10);
});
it('windows long audio with the right stride and full coverage', () => {
const total = 100 * SR;
const windowSamples = 28 * SR;
const stride = (28 - 2) * SR;
const p = planChunks(total, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(p.length).toBeGreaterThan(1);
p.forEach((c, i) => {
expect(c.index).toBe(i);
expect(c.startSample).toBe(i * stride);
expect(c.endSample).toBe(Math.min(i * stride + windowSamples, total));
});
// exactly one first, exactly one last; last reaches the end
expect(p.filter((c) => c.isFirst)).toHaveLength(1);
expect(p.filter((c) => c.isLast)).toHaveLength(1);
expect(p[p.length - 1]!.endSample).toBe(total);
// adjacent chunks genuinely overlap
for (let i = 1; i < p.length; i++) {
expect(p[i]!.startSample).toBeLessThan(p[i - 1]!.endSample);
}
});
it('rejects nonsensical window/overlap', () => {
expect(() => planChunks(SR, { windowSec: 0 })).toThrow();
expect(() => planChunks(SR, { windowSec: 5, overlapSec: 5 })).toThrow();
expect(() => planChunks(SR, { windowSec: 5, overlapSec: -1 })).toThrow();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { WHISPER_SAMPLE_RATE } from '../types';
/** One planned window over the audio. Sample ranges are half-open [start, end). */
export interface ChunkPlanItem {
index: number;
startSample: number;
endSample: number;
startSec: number;
endSec: number;
isFirst: boolean;
isLast: boolean;
}
export interface ChunkPlanOptions {
/** Window length in seconds. Whisper's encoder works on 30s frames; keep < 30. */
windowSec?: number;
/** Overlap between adjacent windows, in seconds. */
overlapSec?: number;
sampleRate?: number;
}
/**
* Plan fixed-size overlapping windows over `totalSamples`.
*
* Whisper can only attend to ~30s at a time, so long media must be windowed.
* Adjacent windows overlap by `overlapSec` so the stitcher (see stitch.ts) can
* drop words that Whisper truncates/hallucinates at a window edge. Every chunk
* starts at `index * stride` where `stride = windowSamples - overlapSamples`,
* and the final chunk is clamped to `totalSamples`.
*/
export function planChunks(totalSamples: number, opts: ChunkPlanOptions = {}): ChunkPlanItem[] {
const sampleRate = opts.sampleRate ?? WHISPER_SAMPLE_RATE;
const windowSec = opts.windowSec ?? 28;
const overlapSec = opts.overlapSec ?? 2;
if (!(totalSamples > 0)) return [];
if (!(windowSec > 0)) throw new Error('windowSec must be > 0');
if (overlapSec < 0 || overlapSec >= windowSec) {
throw new Error('overlapSec must be in [0, windowSec)');
}
const windowSamples = Math.max(1, Math.round(windowSec * sampleRate));
const overlapSamples = Math.round(overlapSec * sampleRate);
const stride = windowSamples - overlapSamples; // > 0 by the guard above
if (totalSamples <= windowSamples) {
return [makeItem(0, 0, totalSamples, sampleRate, totalSamples)];
}
const items: ChunkPlanItem[] = [];
for (let index = 0; ; index++) {
const start = index * stride;
const end = Math.min(start + windowSamples, totalSamples);
items.push(makeItem(index, start, end, sampleRate, totalSamples));
if (end >= totalSamples) break;
}
return items;
}
function makeItem(
index: number,
startSample: number,
endSample: number,
sampleRate: number,
totalSamples: number,
): ChunkPlanItem {
return {
index,
startSample,
endSample,
startSec: startSample / sampleRate,
endSec: endSample / sampleRate,
isFirst: startSample === 0,
isLast: endSample >= totalSamples,
};
}
+60
View File
@@ -0,0 +1,60 @@
// The transcription engine interface: the single abstraction that hides the
// platform-specific Whisper backend (transformers.js on web, whisper.rn on
// native) behind one stable contract. The pure orchestration pipeline
// (pipeline.ts) is written entirely against this interface so it can be unit
// tested with a fake engine and never imports any heavy/native dependency.
//
// IMPORTANT contract detail: `transcribeChunk` returns CHUNK-LOCAL times
// (0-based, relative to the start of the chunk it was handed). The pipeline is
// responsible for offsetting those into absolute media time and stitching the
// overlapping windows together (see pipeline.ts + stitch.ts).
import type {
Backend,
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
/**
* What an engine discovered about the device it is running on. Used by the UI
* to gate model choices and to decide whether live-mic capture is available.
*/
export interface EngineCapabilities {
/** The compute backend the engine resolved to (e.g. 'webgpu', 'cpu'). */
backend: Backend;
/** Whether this engine can transcribe from a live microphone stream. */
supportsLiveMic: boolean;
/** Largest model we'd recommend running on this device/backend. */
maxRecommendedModel: ModelId;
}
/**
* A platform-agnostic Whisper engine. Both `engineImpl.web.ts` and
* `engineImpl.native.ts` export a singleton `engine` satisfying this.
*/
export interface TranscriptionEngine {
/** Which platform family this engine targets. */
readonly platform: 'web' | 'native';
/** Probe the device. May be async (e.g. WebGPU adapter request). */
capabilities(): Promise<EngineCapabilities>;
/**
* Ensure `modelId` is loaded and ready. `onProgress` (if given) receives a
* fraction in [0, 1] during download/initialization. Implementations should
* be idempotent: calling again for an already-loaded model is a no-op.
*/
loadModel(modelId: ModelId, onProgress?: (p: number) => void): Promise<void>;
/** Synchronous check: is this model already loaded in memory? */
isModelLoaded(modelId: ModelId): boolean;
/**
* Transcribe a single already-decoded 16 kHz mono chunk.
* Returns segments with CHUNK-LOCAL times (seconds, 0-based). The caller
* offsets and stitches; engines must NOT add the chunk's media offset.
*/
transcribeChunk(audio: PcmAudio, opts: TranscribeOptions): Promise<Segment[]>;
}
+137
View File
@@ -0,0 +1,137 @@
// NATIVE-ONLY transcription engine, backed by whisper.rn (React Native binding
// of whisper.cpp). This module imports a native module (whisper.rn) and Expo's
// file system, so it is NEVER imported by any vitest test (only the mock-based
// pipeline.test.ts is run). It is selected at runtime by platform-aware code.
//
// whisper.rn API used here (v0.6.0, verified against
// node_modules/whisper.rn/lib/typescript/index.d.ts +
// .../NativeRNWhisper.d.ts and the transcribeData source in src/index.ts):
//
// initWhisper({ filePath }): Promise<WhisperContext>
// - `filePath` is a string path to a ggml .bin model file.
//
// WhisperContext.transcribeData(
// data: string | ArrayBuffer, // base64 float32 PCM, or raw ArrayBuffer
// options?: TranscribeFileOptions, // { language?, translate?, ... }
// ): { stop: () => Promise<void>; promise: Promise<TranscribeResult> }
// - We pass the Float32Array's ArrayBuffer directly (the binding accepts an
// ArrayBuffer of raw float32 PCM; see transcribeData in src/index.ts).
// - It returns an object with a `promise`, NOT a bare promise.
//
// TranscribeResult = {
// result: string; language: string; isAborted: boolean;
// segments: Array<{ text: string; t0: number; t1: number }>;
// }
// - t0/t1 are whisper.cpp timestamps in CENTISECONDS (1/100 s), so we divide
// by 100 to get seconds. They are chunk-local because we hand the context
// one window at a time.
import { initWhisper } from 'whisper.rn';
import type { WhisperContext } from 'whisper.rn';
import { Paths, File } from 'expo-file-system';
import type {
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
import { MODELS, recommendModel } from '../models/catalog';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
/** Loaded whisper.cpp contexts, keyed by model id. */
const loaded = new Map<ModelId, WhisperContext>();
/**
* Resolve the on-device path of a model's ggml file.
*
* Models live under `<documentDirectory>/models/<ggml-filename>`. The document
* directory survives app restarts and OS storage pressure, which is what we want
* for multi-hundred-MB model files.
*
* TODO: the model DOWNLOAD UI / fetcher is NOT built yet. This function only
* computes where the file *should* be; the .bin must already exist there
* (e.g. side-loaded during development) or loadModel() will reject. Wire a
* downloader (File.downloadFileAsync into Paths.document/'models') before
* shipping.
*
* whisper.rn's initWhisper expects a plain filesystem path. Expo's File `.uri`
* is a `file://` URI, so we strip the scheme.
*/
function modelPathFor(id: ModelId): string {
const file = new File(Paths.document, 'models', MODELS[id].ggml);
// e.g. 'file:///data/.../Documents/models/ggml-tiny.en.bin' -> '/data/.../ggml-tiny.en.bin'
return file.uri.replace(/^file:\/\//, '');
}
export const engine: TranscriptionEngine = {
platform: 'native',
async capabilities(): Promise<EngineCapabilities> {
// CoreML (iOS Neural Engine) and Metal acceleration are decided at the
// native build/runtime level (e.g. ContextOptions.useGpu / useCoreMLIos),
// not something we can reliably probe from JS here. Default to the
// conservative 'cpu' backend for model gating; an accelerated build still
// runs fine, it just won't be recommended a larger model by this heuristic.
const backend = 'cpu' as const;
return {
backend,
// whisper.rn supports realtime/live-mic transcription on device.
supportsLiveMic: true,
maxRecommendedModel: recommendModel({ backend }),
};
},
async loadModel(modelId: ModelId): Promise<void> {
if (loaded.has(modelId)) return; // idempotent
const filePath = modelPathFor(modelId);
// initWhisper rejects if the file is missing/corrupt. See the TODO in
// modelPathFor: until the download UI exists, the .bin must already be there.
const ctx = await initWhisper({ filePath });
loaded.set(modelId, ctx);
},
isModelLoaded(modelId: ModelId): boolean {
return loaded.has(modelId);
},
async transcribeChunk(
audio: PcmAudio,
opts: TranscribeOptions,
): Promise<Segment[]> {
const ctx = loaded.get(opts.modelId);
if (!ctx) {
throw new Error(
`Model "${opts.modelId}" is not loaded; call loadModel() first.`,
);
}
// Pass the raw float32 PCM as an ArrayBuffer. We hand over a tight copy of
// exactly this chunk's samples: `audio.samples` may be a subarray VIEW into
// a larger buffer (the pipeline slices with subarray), so `.buffer` alone
// would expose the whole backing buffer. `.slice()` yields a standalone
// Float32Array whose `.buffer` is exactly these samples.
const pcm = audio.samples.slice();
// transcribeData returns { stop, promise }; await the promise.
const { promise } = ctx.transcribeData(pcm.buffer, {
// language undefined => whisper.rn auto-detects ('auto').
language: opts.language,
translate: opts.translate ?? false,
});
const result = await promise;
// Map whisper.cpp segments (centiseconds) -> our Segment[] (chunk-local s).
const segments: Segment[] = [];
for (const s of result.segments) {
const text = s.text.trim();
if (text.length === 0) continue;
segments.push({
start: s.t0 / 100, // centiseconds -> seconds
end: s.t1 / 100,
text,
});
}
return segments;
},
};
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks engineImpl.web.ts / engineImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { engine } from './engineImpl.web';
+138
View File
@@ -0,0 +1,138 @@
// WEB-ONLY transcription engine, backed by transformers.js (@huggingface/
// transformers) running Whisper in the browser (WebGPU when available, else
// multi-threaded WASM).
//
// WHY WE LOAD IT FROM A CDN AT RUNTIME (not a static import):
// transformers.js depends on onnxruntime-web, which uses a *computed* dynamic
// import (`import(/*webpackIgnore*/ a)`) and ships WASM — Metro (Expo's web
// bundler) cannot statically bundle either and fails the build. So we never let
// Metro see the package: we load the ESM build from a CDN at runtime via a
// dynamic import hidden behind `new Function` (so Metro's static analyzer can't
// trip over it). The browser resolves it natively. This keeps the JS bundle
// small and is the standard way to run transformers.js under Metro/Expo web.
//
// NOTE: the page must be cross-origin isolated (COOP + COEP) for multi-threaded
// WASM; we use COEP: credentialless so the CDN script and the Hugging Face model
// files (CORS-enabled) load without requiring CORP headers. See docker/nginx.conf.
//
// This module is web-only and is NEVER imported by any vitest test.
import { MODELS, recommendModel } from '../models/catalog';
import type { Backend, ModelId, PcmAudio, Segment, TranscribeOptions } from '../types';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
// Pin the transformers.js version we load at runtime.
const TRANSFORMERS_CDN = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0';
// `new Function` hides the dynamic import() specifier from Metro's bundler so it
// never tries to resolve/transform transformers.js or onnxruntime-web.
const runtimeImport = new Function('u', 'return import(u)') as (u: string) => Promise<TransformersModule>;
// Minimal structural types for the bits of transformers.js we use.
interface AsrChunk {
timestamp: [number, number | null];
text: string;
}
interface AsrOutput {
text: string;
chunks?: AsrChunk[];
}
type AsrPipeline = (audio: Float32Array, opts: Record<string, unknown>) => Promise<AsrOutput>;
interface PipelineOptions {
device?: string;
dtype?: string;
progress_callback?: (e: { status?: string; progress?: number }) => void;
}
interface TransformersModule {
pipeline: (task: string, model: string, opts?: PipelineOptions) => Promise<AsrPipeline>;
env: { allowLocalModels: boolean };
}
let libPromise: Promise<TransformersModule> | null = null;
async function lib(): Promise<TransformersModule> {
if (!libPromise) {
libPromise = runtimeImport(TRANSFORMERS_CDN).then((m) => {
// Never read models off the local filesystem in the browser.
m.env.allowLocalModels = false;
return m;
});
}
return libPromise;
}
/** Loaded ASR pipelines, keyed by model id. */
const loaded = new Map<ModelId, AsrPipeline>();
let cachedBackend: Backend | undefined;
async function detectWebGpu(): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false;
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
const adapter = await gpu?.requestAdapter();
return adapter != null;
} catch {
return false;
}
}
async function resolveBackend(): Promise<Backend> {
if (cachedBackend) return cachedBackend;
cachedBackend = (await detectWebGpu()) ? 'webgpu' : 'wasm';
return cachedBackend;
}
export const engine: TranscriptionEngine = {
platform: 'web',
async capabilities(): Promise<EngineCapabilities> {
const backend = await resolveBackend();
return {
backend,
supportsLiveMic: false,
maxRecommendedModel: recommendModel({ backend }),
};
},
async loadModel(modelId: ModelId, onProgress?: (p: number) => void): Promise<void> {
if (loaded.has(modelId)) return;
const { pipeline } = await lib();
const webgpu = (await resolveBackend()) === 'webgpu';
const asr = await pipeline('automatic-speech-recognition', MODELS[modelId].webRepo, {
// WebGPU + fp16 when available; otherwise 8-bit weights on WASM, which
// stays small to download and runs acceptably on a plain CPU.
device: webgpu ? 'webgpu' : 'wasm',
dtype: webgpu ? 'fp16' : 'q8',
progress_callback: (e) => {
if (e.status === 'progress' && e.progress != null) onProgress?.(e.progress / 100);
},
});
loaded.set(modelId, asr);
},
isModelLoaded(modelId: ModelId): boolean {
return loaded.has(modelId);
},
async transcribeChunk(audio: PcmAudio, opts: TranscribeOptions): Promise<Segment[]> {
const asr = loaded.get(opts.modelId);
if (!asr) throw new Error(`Model "${opts.modelId}" is not loaded; call loadModel() first.`);
const out = await asr(audio.samples, {
return_timestamps: true,
// One window at a time; 30s matches Whisper's frame so it won't re-chunk.
chunk_length_s: 30,
language: opts.language,
task: opts.translate ? 'translate' : 'transcribe',
});
const segments: Segment[] = [];
for (const c of out.chunks ?? []) {
const [start, end] = c.timestamp;
if (start == null) continue;
const text = c.text.trim();
if (text.length === 0) continue;
segments.push({ start, end: end ?? start, text });
}
return segments;
},
};
+13
View File
@@ -0,0 +1,13 @@
// Public entry point for the transcription engine.
//
// `./engineImpl` is resolved by Metro to engineImpl.web.ts or
// engineImpl.native.ts by platform extension; the base engineImpl.ts re-export
// (web) is what TypeScript resolves for typechecking. Consumers call
// getEngine() and stay platform-agnostic.
import { engine } from './engineImpl';
/** Return the platform-resolved transcription engine. */
export const getEngine = () => engine;
export * from './engine';
export * from './pipeline';
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest';
import { transcribe } from './pipeline';
import type { TranscribeProgress } from './pipeline';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
import type {
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
import { WHISPER_SAMPLE_RATE } from '../types';
import { planChunks } from './chunking';
/**
* A fully in-memory fake engine. It records calls and returns exactly one
* segment per chunk so the test can assert how the pipeline offsets/stitches
* chunk-local times into absolute time.
*/
class FakeEngine implements TranscriptionEngine {
readonly platform = 'web' as const;
/** Toggles whether loadModel is exercised. */
loaded: boolean;
loadModelCalls = 0;
transcribeCalls = 0;
lastLoadProgress: number[] = [];
constructor(opts: { loaded: boolean } = { loaded: true }) {
this.loaded = opts.loaded;
}
async capabilities(): Promise<EngineCapabilities> {
return {
backend: 'wasm',
supportsLiveMic: false,
maxRecommendedModel: 'tiny.en',
};
}
async loadModel(
_modelId: ModelId,
onProgress?: (p: number) => void,
): Promise<void> {
this.loadModelCalls++;
// Report a couple of fractional progress ticks then complete.
onProgress?.(0.5);
onProgress?.(1);
this.lastLoadProgress.push(1);
this.loaded = true;
}
isModelLoaded(_modelId: ModelId): boolean {
return this.loaded;
}
async transcribeChunk(
audio: PcmAudio,
_opts: TranscribeOptions,
): Promise<Segment[]> {
// One chunk-local segment per chunk, labelled by call index so we can
// verify ordering and absolute-time offsetting after stitching. We place it
// squarely in the chunk's interior (mid-window) so the stitcher's
// mid-overlap cut keeps exactly one copy per chunk rather than dropping a
// boundary-hugging segment. Times are chunk-LOCAL (the engine contract).
const idx = this.transcribeCalls++;
const lenSec = audio.samples.length / audio.sampleRate;
const mid = lenSec / 2;
return [{ start: mid, end: mid + 0.5, text: `chunk${idx}` }];
}
}
/** Build a silent PcmAudio of `seconds` length at 16 kHz. */
function makeAudio(seconds: number): PcmAudio {
return {
sampleRate: WHISPER_SAMPLE_RATE,
samples: new Float32Array(Math.round(seconds * WHISPER_SAMPLE_RATE)),
};
}
const OPTIONS: TranscribeOptions = { modelId: 'tiny.en' };
describe('transcribe pipeline', () => {
it('returns stitched, absolute-time segments (one per chunk)', async () => {
// 60s with the default 28s window / 2s overlap -> multiple chunks.
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const plan = planChunks(audio.samples.length, {
windowSec: 28,
overlapSec: 2,
sampleRate: WHISPER_SAMPLE_RATE,
});
const segments = await transcribe({ audio, options: OPTIONS, engine });
// One segment per chunk survives stitching (each is well inside its window).
expect(engine.transcribeCalls).toBe(plan.length);
expect(segments).toHaveLength(plan.length);
// Each segment was offset by its chunk's media start time. The fake emits a
// chunk-local segment at the window midpoint; after offsetting, segment i
// should start at chunk.startSec + (window/2), i.e. strictly inside chunk i.
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const chunk = plan[i]!;
const localMid = (chunk.endSec - chunk.startSec) / 2;
expect(seg.text).toBe(`chunk${i}`);
expect(seg.start).toBeCloseTo(chunk.startSec + localMid, 5);
}
// Output must be sorted ascending by start.
for (let i = 1; i < segments.length; i++) {
expect(segments[i]!.start).toBeGreaterThanOrEqual(segments[i - 1]!.start);
}
});
it('reports increasing transcribing progress ending at 1', async () => {
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const events: TranscribeProgress[] = [];
await transcribe({
audio,
options: OPTIONS,
engine,
onProgress: (p) => events.push(p),
});
const transcribing = events.filter((e) => e.stage === 'transcribing');
expect(transcribing.length).toBeGreaterThan(0);
// Monotonically non-decreasing, strictly bounded in (0, 1].
for (let i = 0; i < transcribing.length; i++) {
const e = transcribing[i]!;
expect(e.progress).toBeGreaterThan(0);
expect(e.progress).toBeLessThanOrEqual(1);
if (i > 0) {
expect(e.progress).toBeGreaterThan(transcribing[i - 1]!.progress);
}
}
// Final transcribing event hits exactly 1.
expect(transcribing[transcribing.length - 1]!.progress).toBe(1);
// The growing partial should reach the final segment count on the last tick.
const lastPartial = transcribing[transcribing.length - 1]!.partial;
expect(lastPartial.length).toBeGreaterThan(0);
});
it('calls loadModel (with load progress) when the model is not loaded', async () => {
const audio = makeAudio(10); // single chunk is fine here
const engine = new FakeEngine({ loaded: false });
const loadingEvents: TranscribeProgress[] = [];
await transcribe({
audio,
options: OPTIONS,
engine,
onProgress: (p) => {
if (p.stage === 'loading') loadingEvents.push(p);
},
});
expect(engine.loadModelCalls).toBe(1);
// Loading events have an empty partial and a fraction in [0, 1].
expect(loadingEvents.length).toBeGreaterThan(0);
for (const e of loadingEvents) {
expect(e.partial).toEqual([]);
expect(e.progress).toBeGreaterThanOrEqual(0);
expect(e.progress).toBeLessThanOrEqual(1);
}
// Last loading tick reaches 1.
expect(loadingEvents[loadingEvents.length - 1]!.progress).toBe(1);
});
it('does NOT call loadModel when the model is already loaded', async () => {
const audio = makeAudio(10);
const engine = new FakeEngine({ loaded: true });
await transcribe({ audio, options: OPTIONS, engine });
expect(engine.loadModelCalls).toBe(0);
});
it('throws AbortError when the signal is already aborted', async () => {
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const controller = new AbortController();
controller.abort();
await expect(
transcribe({
audio,
options: OPTIONS,
engine,
signal: controller.signal,
}),
).rejects.toMatchObject({ name: 'AbortError' });
// Nothing was transcribed because we aborted before the first chunk.
expect(engine.transcribeCalls).toBe(0);
});
});
+107
View File
@@ -0,0 +1,107 @@
// The shared, pure orchestration pipeline. Given a decoded PcmAudio and a
// TranscriptionEngine, it:
// 1. loads the model if needed (reporting load progress),
// 2. plans overlapping windows over the audio (chunking.ts),
// 3. transcribes each window via the engine (chunk-local times),
// 4. stitches the per-chunk results into one absolute-time transcript,
// emitting a growing `partial` after every chunk for live UI.
//
// This module is intentionally free of any platform/native imports so it can be
// exercised end-to-end in vitest with a fake engine (see pipeline.test.ts).
import { WHISPER_SAMPLE_RATE } from '../types';
import type { PcmAudio, Segment, TranscribeOptions } from '../types';
import { planChunks } from './chunking';
import { stitchSegments } from './stitch';
import type { TranscriptionEngine } from './engine';
/** Progress event emitted while transcribing. */
export interface TranscribeProgress {
/** 'loading' while the model downloads/initializes, then 'transcribing'. */
stage: 'loading' | 'transcribing';
/** Fraction in [0, 1] for the current stage. */
progress: number;
/**
* Best-effort transcript so far, in absolute time. Empty during 'loading';
* grows after each chunk during 'transcribing'.
*/
partial: Segment[];
}
export interface TranscribeParams {
audio: PcmAudio;
options: TranscribeOptions;
engine: TranscriptionEngine;
onProgress?: (p: TranscribeProgress) => void;
/** Abort mid-run; throws DOMException('Aborted','AbortError'). */
signal?: AbortSignal;
/** Window length in seconds (default 28; Whisper attends to ~30s). */
windowSec?: number;
/** Overlap between adjacent windows in seconds (default 2). */
overlapSec?: number;
}
/** Default window/overlap, matching planChunks/stitchSegments conventions. */
const DEFAULT_WINDOW_SEC = 28;
const DEFAULT_OVERLAP_SEC = 2;
/**
* Transcribe a full PcmAudio into absolute-time segments. See module comment.
*/
export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
const {
audio,
options,
engine,
onProgress,
signal,
windowSec = DEFAULT_WINDOW_SEC,
overlapSec = DEFAULT_OVERLAP_SEC,
} = params;
const { modelId } = options;
// 1. Load the model if it isn't already resident. Load progress maps onto the
// 'loading' stage; `partial` is empty because we have no segments yet.
if (!engine.isModelLoaded(modelId)) {
await engine.loadModel(modelId, (p) =>
onProgress?.({ stage: 'loading', progress: p, partial: [] }),
);
}
// 2. Plan overlapping windows. We pin the sample rate to Whisper's 16 kHz;
// PcmAudio is guaranteed to already be at that rate by its type.
const plan = planChunks(audio.samples.length, {
windowSec,
overlapSec,
sampleRate: WHISPER_SAMPLE_RATE,
});
// 3. Transcribe each window. `perChunk[i]` holds chunk-local segments for
// plan[i]; the stitcher offsets and merges them.
const perChunk: Segment[][] = [];
for (let i = 0; i < plan.length; i++) {
// Cooperative cancellation: check before each (potentially long) chunk.
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const chunk = plan[i]!;
// subarray is a zero-copy view; the engine only reads it, never mutates.
const chunkAudio: PcmAudio = {
sampleRate: WHISPER_SAMPLE_RATE,
samples: audio.samples.subarray(chunk.startSample, chunk.endSample),
};
const segs = await engine.transcribeChunk(chunkAudio, options);
perChunk.push(segs);
// Emit a growing absolute-time partial: stitch everything decoded so far.
onProgress?.({
stage: 'transcribing',
progress: (i + 1) / plan.length,
partial: stitchSegments(perChunk, plan.slice(0, i + 1), { overlapSec }),
});
}
// 4. Final stitch over the full plan.
return stitchSegments(perChunk, plan, { overlapSec });
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { stitchSegments } from './stitch';
import { planChunks } from './chunking';
import type { Segment } from '../types';
const SR = 16000;
describe('stitchSegments', () => {
it('passes a single chunk through unchanged (offset 0)', () => {
const plan = planChunks(10 * SR, { sampleRate: SR });
const segs: Segment[] = [
{ start: 0, end: 2, text: 'hello' },
{ start: 2, end: 4, text: 'world' },
];
const out = stitchSegments([segs], plan);
expect(out.map((s) => s.text)).toEqual(['hello', 'world']);
expect(out[0]).toMatchObject({ start: 0, end: 2 });
});
it('offsets chunk-local times into absolute time', () => {
const plan = planChunks(60 * SR, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(plan.length).toBeGreaterThanOrEqual(2);
const perChunk: Segment[][] = plan.map((_, i) => [{ start: 1, end: 2, text: `c${i}` }]);
const out = stitchSegments(perChunk, plan);
expect(out.find((s) => s.text === 'c0')!.start).toBeCloseTo(1);
const c1 = plan[1]!;
expect(out.find((s) => s.text === 'c1')!.start).toBeCloseTo(c1.startSec + 1);
});
it('keeps a word duplicated across the overlap only once', () => {
const plan = planChunks(60 * SR, { windowSec: 28, overlapSec: 2, sampleRate: SR });
const c0 = plan[0]!;
const c1 = plan[1]!;
const seamAbs = c1.startSec + 0.5; // inside the 2s overlap, below the mid cut
const perChunk: Segment[][] = plan.map(() => []);
perChunk[0] = [{ start: seamAbs - c0.startSec - 0.25, end: seamAbs - c0.startSec + 0.25, text: 'seam' }];
perChunk[1] = [{ start: seamAbs - c1.startSec - 0.25, end: seamAbs - c1.startSec + 0.25, text: 'seam' }];
const out = stitchSegments(perChunk, plan, { overlapSec: 2 });
expect(out.filter((s) => s.text === 'seam')).toHaveLength(1);
});
it('produces monotonic, non-overlapping output', () => {
const plan = planChunks(10 * SR, { sampleRate: SR });
const segs: Segment[] = [
{ start: 0, end: 3, text: 'a' },
{ start: 2.5, end: 5, text: 'b' },
];
const out = stitchSegments([segs], plan);
for (let i = 1; i < out.length; i++) {
expect(out[i]!.start).toBeGreaterThanOrEqual(out[i - 1]!.start);
expect(out[i - 1]!.end).toBeLessThanOrEqual(out[i]!.start + 1e-9);
}
});
it('tolerates missing/empty chunk entries', () => {
const plan = planChunks(60 * SR, { sampleRate: SR });
const out = stitchSegments([], plan);
expect(out).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { Segment } from '../types';
import type { ChunkPlanItem } from './chunking';
export interface StitchOptions {
/** Overlap (seconds) between adjacent chunks; should match planChunks. */
overlapSec?: number;
}
/**
* Merge per-chunk, CHUNK-LOCAL segment lists into one absolute-time transcript.
*
* `perChunk[i]` holds the segments engine returned for chunk `i`, with times
* relative to that chunk's start. We offset each by `plan[i].startSec`, then at
* every seam choose a cut point in the middle of the overlap and assign each
* segment to a chunk by its midpoint: the earlier chunk owns everything before
* the cut, the later chunk everything after. Because Whisper truncates and
* occasionally hallucinates at a window edge, a mid-overlap cut drops the
* duplicated/garbled boundary copy and keeps the clean one. Finally we sort and
* clamp so the output is monotonic and non-overlapping (nice for the editor).
*/
export function stitchSegments(
perChunk: Segment[][],
plan: ChunkPlanItem[],
opts: StitchOptions = {},
): Segment[] {
const overlapSec = opts.overlapSec ?? 2;
const out: Segment[] = [];
for (let i = 0; i < plan.length; i++) {
const chunk = plan[i];
const segs = perChunk[i];
if (!chunk || !segs) continue;
const offset = chunk.startSec;
// Below the cut shared with the previous chunk → that chunk already owns it.
const lowerCut = i > 0 ? chunk.startSec + overlapSec / 2 : -Infinity;
// At/after the cut shared with the next chunk → the next chunk will own it.
const next = plan[i + 1];
const upperCut = next ? next.startSec + overlapSec / 2 : Infinity;
for (const s of segs) {
const start = s.start + offset;
const end = s.end + offset;
const mid = (start + end) / 2;
if (mid < lowerCut || mid >= upperCut) continue;
out.push({ ...s, start, end });
}
}
out.sort((a, b) => a.start - b.start || a.end - b.end);
// Clamp so each segment ends no later than the next one starts.
for (let i = 0; i < out.length - 1; i++) {
const cur = out[i]!;
const nxt = out[i + 1]!;
if (cur.end > nxt.start) cur.end = nxt.start;
}
return out;
}
+39
View File
@@ -0,0 +1,39 @@
// Ambient module shim for whisper.rn.
//
// Why this exists: whisper.rn@0.6.0 ships an `exports` map containing only a
// `"./*"` subpath pattern and NO `"."` (root) entry. Under this project's
// TypeScript settings (moduleResolution: 'bundler' with the 'react-native'
// custom condition, inherited from expo/tsconfig.base), the bare specifier
// `import ... from 'whisper.rn'` cannot be resolved by tsc:
// "Export specifier '.' does not exist in package.json scope".
// Metro/Babel resolve it fine at runtime (they honor the package's `main` /
// `react-native` fields), so this is purely a *type*-resolution gap.
//
// This shim declares the `'whisper.rn'` module and aliases the library's real,
// shipped declarations (lib/typescript/index.d.ts) through an inline `import()`
// type that points at a relative path, bypassing the `exports` map.
//
// IMPORTANT: this file must stay a GLOBAL/ambient script (no top-level `import`
// or `export` statements) so that `declare module 'whisper.rn'` *creates* the
// module rather than *augmenting* a non-resolvable one. We therefore reference
// the upstream types via inline `import('...')` type expressions. The relative
// path is from this file (src/lib/transcription) up to the repo root, then into
// node_modules. It changes no runtime behavior and touches no shared files.
// Remove this whole file once whisper.rn publishes a `"."` export entry.
declare module 'whisper.rn' {
type _Upstream = typeof import('../../../node_modules/whisper.rn/lib/typescript/index');
export type WhisperContext =
import('../../../node_modules/whisper.rn/lib/typescript/index').WhisperContext;
export type ContextOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').ContextOptions;
export type TranscribeOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeOptions;
export type TranscribeResult =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeResult;
export type TranscribeFileOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeFileOptions;
export const initWhisper: _Upstream['initWhisper'];
}
+49
View File
@@ -0,0 +1,49 @@
// Core domain types shared across the whole transcription engine.
// Kept tiny and dependency-free so every pure module can import from here
// without pulling in React Native, Expo, or any platform code.
/** Whisper always works at 16 kHz mono. */
export const WHISPER_SAMPLE_RATE = 16000 as const;
/**
* A contiguous transcribed segment. Times are in SECONDS and, once a media
* file has been fully processed, ABSOLUTE to the whole media (post-stitch).
* Engine implementations emit chunk-local times (0-based per chunk); the pure
* pipeline offsets and stitches them into absolute time.
*/
export interface Segment {
/** Inclusive start time in seconds. */
start: number;
/** Exclusive end time in seconds. */
end: number;
/** Recognized text for this segment (already trimmed). */
text: string;
/** Optional confidence in [0,1], derived from avg token logprob when available. */
confidence?: number;
}
/** Decoded audio ready for Whisper: 16 kHz, mono, normalized to [-1, 1]. */
export interface PcmAudio {
readonly sampleRate: typeof WHISPER_SAMPLE_RATE;
readonly samples: Float32Array;
}
/** The Whisper model variants we expose. `.en` = English-only (smaller/faster). */
export type ModelId =
| 'tiny.en'
| 'tiny'
| 'base.en'
| 'base'
| 'small.en'
| 'small';
export interface TranscribeOptions {
modelId: ModelId;
/** ISO language code; undefined => auto-detect (multilingual models only). */
language?: string;
/** Translate the result to English (multilingual models only). */
translate?: boolean;
}
/** Which compute backend an engine resolved to, for UX and model gating. */
export type Backend = 'coreml' | 'metal' | 'cpu' | 'webgpu' | 'wasm';
+120
View File
@@ -0,0 +1,120 @@
// Drives a single active transcription: decode -> run the pipeline on-device ->
// save to the local library. Holds live progress + the partial transcript so
// the UI can "fill in" as chunks complete, plus an object URL for playback.
import { Platform } from 'react-native';
import { create } from 'zustand';
import { getDecoder, type AudioFileInput } from '@/lib/audio';
import { getRepo } from '@/lib/db';
import { DEFAULT_MODEL } from '@/lib/models/catalog';
import { getEngine } from '@/lib/transcription';
import { transcribe } from '@/lib/transcription/pipeline';
import type { ModelId, Segment } from '@/lib/types';
type Status = 'idle' | 'loading' | 'transcribing' | 'done' | 'error';
interface StartOptions {
title?: string;
language?: string;
translate?: boolean;
}
interface TranscribeState {
status: Status;
stage?: 'loading' | 'transcribing';
progress: number;
partial: Segment[];
error?: string;
modelId: ModelId;
lastTranscriptId?: string;
/** Playable source for the just-finished audio (object URL on web, uri on native). */
audioUrl?: string;
_abort?: AbortController;
setModel: (m: ModelId) => void;
start: (input: AudioFileInput, opts?: StartOptions) => Promise<string | undefined>;
cancel: () => void;
reset: () => void;
}
function makeAudioUrl(input: AudioFileInput): string | undefined {
if (Platform.OS === 'web' && input.data) {
return URL.createObjectURL(new Blob([input.data]));
}
return input.uri;
}
export const useTranscribe = create<TranscribeState>((set, get) => ({
status: 'idle',
progress: 0,
partial: [],
modelId: DEFAULT_MODEL,
setModel: (m) => set({ modelId: m }),
start: async (input, opts) => {
// Clean up any previous object URL.
const prev = get().audioUrl;
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
const abort = new AbortController();
set({
status: 'loading',
stage: 'loading',
progress: 0,
partial: [],
error: undefined,
lastTranscriptId: undefined,
audioUrl: makeAudioUrl(input),
_abort: abort,
});
const { modelId } = get();
try {
const pcm = await getDecoder().decode(input);
const segments = await transcribe({
audio: pcm,
options: { modelId, language: opts?.language, translate: opts?.translate },
engine: getEngine(),
signal: abort.signal,
onProgress: (p) =>
set({ stage: p.stage, progress: p.progress, partial: p.partial, status: p.stage }),
});
const durationSec = pcm.samples.length / pcm.sampleRate;
const saved = await getRepo().create({
title: opts?.title?.trim() || defaultTitle(),
durationSec,
modelId,
language: opts?.language,
segments,
});
set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id });
return saved.id;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
set({ status: 'idle', progress: 0 });
return undefined;
}
set({ status: 'error', error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
cancel: () => {
get()._abort?.abort();
},
reset: () => {
const prev = get().audioUrl;
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
set({ status: 'idle', stage: undefined, progress: 0, partial: [], error: undefined, audioUrl: undefined });
},
}));
function defaultTitle(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `Transcript ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
+49
View File
@@ -0,0 +1,49 @@
// Library state: the saved transcripts list + search, backed by the local
// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only —
// the record of truth lives in the repo.
import { create } from 'zustand';
import { getRepo, type TranscriptMeta } from '@/lib/db';
interface TranscriptsState {
items: TranscriptMeta[];
loading: boolean;
query: string;
refresh: () => Promise<void>;
setQuery: (q: string) => Promise<void>;
remove: (id: string) => Promise<void>;
rename: (id: string, title: string) => Promise<void>;
}
export const useTranscripts = create<TranscriptsState>((set, get) => ({
items: [],
loading: false,
query: '',
refresh: async () => {
set({ loading: true });
try {
const repo = getRepo();
const q = get().query.trim();
const items = q ? await repo.search(q) : await repo.list();
set({ items });
} finally {
set({ loading: false });
}
},
setQuery: async (q) => {
set({ query: q });
await get().refresh();
},
remove: async (id) => {
await getRepo().remove(id);
await get().refresh();
},
rename: async (id, title) => {
await getRepo().update(id, { title });
await get().refresh();
},
}));
+8
View File
@@ -0,0 +1,8 @@
// Ambient declarations so `tsc` understands CSS imports that Metro handles
// natively (the Expo template uses a global stylesheet + CSS modules on web).
declare module '*.css';
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
+1
View File
@@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": [
"./src/*"
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
// Pure-engine tests run in Node. The repository contract tests pull in
// `fake-indexeddb/auto`, which polyfills IndexedDB into the Node global, so
// they run here too without a DOM.
export default defineConfig({
test: {
environment: 'node',
include: ['src/lib/**/*.test.ts'],
globals: false,
},
});