Build Wisp: on-device transcription studio (web + native, one codebase)
Private, offline speech-to-text that runs Whisper on the user's own device — free, no account, no per-minute fees. Replaces Otter.ai / Rev. - Pure, tested engine: chunking, overlap timestamp-stitching, exports (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests). - Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch. - Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated. - UI: library + search, import, live-progress transcription, synced click-to-seek editor, multi-format export; model picker + privacy in settings. - Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM; Docker (nginx) image + Traefik compose for wisp.briggen.dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "single",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# docker/android.Dockerfile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reproducible Android build environment for Wisp (Expo SDK 56 / RN 0.85).
|
||||||
|
#
|
||||||
|
# This image contains everything needed to run:
|
||||||
|
# bunx expo prebuild --platform android --no-install
|
||||||
|
# cd android && ./gradlew assembleRelease
|
||||||
|
#
|
||||||
|
# It is used both in CI (.gitea/workflows/ci.yml -> build-apk) and locally
|
||||||
|
# (see docs/DEPLOY.md "Build the APK locally") so the APK is built identically
|
||||||
|
# everywhere. The repository itself is mounted in at run time; this image only
|
||||||
|
# provides the toolchain (JDK + Android SDK + Bun), not the source.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pinned tool versions (Expo SDK 56 / React Native 0.85 era).
|
||||||
|
# Sourced from node_modules/react-native/gradle/libs.versions.toml:
|
||||||
|
# compileSdk = 36 targetSdk = 36 minSdk = 24
|
||||||
|
# buildTools = 36.0.0
|
||||||
|
# ndkVersion = 27.1.12297006 (installed lazily by Gradle if a module needs it)
|
||||||
|
# AGP = 8.12.0 Kotlin = 2.1.20 Gradle wrapper = 9.3.1
|
||||||
|
#
|
||||||
|
# IMPORTANT: pin to JDK 17. AGP 8.x targets JDK 17; the dev machine may have a
|
||||||
|
# newer JDK (e.g. JDK 26), but CI/Docker MUST use 17 for reproducible, supported
|
||||||
|
# builds. See docs/DEPLOY.md.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM eclipse-temurin:17-jdk
|
||||||
|
|
||||||
|
# ---- Build-time version arguments (override with --build-arg if needed) ----
|
||||||
|
# Android compile/target SDK platform required by Expo SDK 56 / RN 0.85.
|
||||||
|
ARG ANDROID_PLATFORM_VERSION=36
|
||||||
|
# Android build-tools version matching libs.versions.toml (buildTools = 36.0.0).
|
||||||
|
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
|
||||||
|
# Google's commandline-tools package. "latest" via the published zip; pinned to a
|
||||||
|
# known build number for reproducibility. Update from:
|
||||||
|
# https://developer.android.com/studio#command-line-tools-only
|
||||||
|
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
|
||||||
|
|
||||||
|
# ---- Android SDK locations (both vars set for tool compatibility) ----------
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
# Put the SDK tools on PATH. cmdline-tools live under .../cmdline-tools/latest/bin
|
||||||
|
# (we deliberately install them into a directory literally named "latest", which
|
||||||
|
# is what sdkmanager expects).
|
||||||
|
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools
|
||||||
|
|
||||||
|
# Non-interactive apt for clean CI logs.
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# ---- OS packages -----------------------------------------------------------
|
||||||
|
# - unzip/curl: fetch + extract the cmdline-tools zip
|
||||||
|
# - git: expo prebuild / some gradle tasks shell out to git
|
||||||
|
# - bash: scripts/ci-android-sign.sh is bash (the default sh would do, but be explicit)
|
||||||
|
# - ca-certificates: TLS for downloads (sdkmanager, gradle, HF, etc.)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
unzip \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- Install Bun (JS package manager / task runner) ------------------------
|
||||||
|
# Wisp uses Bun, not npm/yarn. The official installer drops Bun into ~/.bun.
|
||||||
|
# We run as root in this image, so HOME=/root.
|
||||||
|
ENV BUN_INSTALL=/root/.bun
|
||||||
|
ENV PATH=${BUN_INSTALL}/bin:${PATH}
|
||||||
|
RUN curl -fsSL https://bun.sh/install | bash \
|
||||||
|
&& bun --version
|
||||||
|
|
||||||
|
# ---- Install the Android SDK commandline tools -----------------------------
|
||||||
|
# Download the Linux cmdline-tools zip and unpack it into the directory layout
|
||||||
|
# sdkmanager requires: $ANDROID_HOME/cmdline-tools/latest/...
|
||||||
|
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
|
||||||
|
&& curl -fsSL -o /tmp/cmdline-tools.zip \
|
||||||
|
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
|
||||||
|
&& unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools \
|
||||||
|
# The zip extracts to a top-level "cmdline-tools" dir; move it to "latest".
|
||||||
|
&& mv /tmp/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
|
||||||
|
&& rm -rf /tmp/cmdline-tools.zip /tmp/cmdline-tools
|
||||||
|
|
||||||
|
# ---- Accept licenses and install SDK packages ------------------------------
|
||||||
|
# `yes |` auto-accepts every interactive license prompt. We install:
|
||||||
|
# - platform-tools (adb, etc.)
|
||||||
|
# - platforms;android-36 (compileSdk/targetSdk = 36)
|
||||||
|
# - build-tools;36.0.0 (aapt2, zipalign, apksigner, ...)
|
||||||
|
# The NDK (27.1.12297006) is intentionally NOT pre-installed: most JS-only Expo
|
||||||
|
# apps don't need it, and Gradle will download the exact required NDK on demand.
|
||||||
|
# If your native deps require it at build time, add:
|
||||||
|
# "ndk;27.1.12297006"
|
||||||
|
# to the sdkmanager line below to bake it in (saves time but enlarges the image).
|
||||||
|
RUN yes | sdkmanager --licenses > /dev/null \
|
||||||
|
&& sdkmanager --install \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
|
||||||
|
&& yes | sdkmanager --licenses > /dev/null
|
||||||
|
|
||||||
|
# Gradle home lives outside the mounted repo so the daemon cache can persist via
|
||||||
|
# a Docker volume between builds (see docs/DEPLOY.md for the local-build command).
|
||||||
|
ENV GRADLE_USER_HOME=/opt/gradle
|
||||||
|
|
||||||
|
# The repository is mounted here at run time (e.g. `-v "$PWD":/workspace`).
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Default to an interactive shell; CI overrides this with an explicit build
|
||||||
|
# command (`bash -lc "..."`). Documented usage is in docs/DEPLOY.md.
|
||||||
|
CMD ["bash"]
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# docker/nginx.conf
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# nginx server block for the Wisp static web app (used by docker/web.Dockerfile).
|
||||||
|
#
|
||||||
|
# Responsibilities:
|
||||||
|
# 1. Serve the Expo/Metro static SPA, falling back to /index.html for client
|
||||||
|
# routes (expo-router uses HTML5 history routing).
|
||||||
|
# 2. Emit the cross-origin isolation headers required for multi-threaded WASM
|
||||||
|
# (SharedArrayBuffer). Without these, `crossOriginIsolated` is false in the
|
||||||
|
# browser and the threaded Whisper WASM backend cannot start.
|
||||||
|
# 3. gzip responses and cache immutable hashed assets aggressively.
|
||||||
|
# 4. Serve the signed Android APK at /wisp.apk when one is present.
|
||||||
|
#
|
||||||
|
# This file is copied to /etc/nginx/conf.d/default.conf, replacing the stock
|
||||||
|
# nginx site, so it is a `server { }` block (the surrounding http{}/events{}
|
||||||
|
# come from the base image's /etc/nginx/nginx.conf).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Map the request to a Cache-Control value: hashed build assets get a 1-year
|
||||||
|
# immutable cache; everything else (HTML, the service worker, the APK) is
|
||||||
|
# revalidated so deploys take effect immediately.
|
||||||
|
map $uri $wisp_cache_control {
|
||||||
|
default "no-cache";
|
||||||
|
# Expo emits content-hashed files under /_expo/ and /assets/. These are safe
|
||||||
|
# to cache forever because the hash changes whenever the content changes.
|
||||||
|
~*^/_expo/ "public, max-age=31536000, immutable";
|
||||||
|
~*^/assets/ "public, max-age=31536000, immutable";
|
||||||
|
~*\.(?:js|css|woff2?|ttf|otf|png|jpg|jpeg|gif|svg|webp|wasm)$ "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
# Non-privileged port; docker-compose maps/exposes 8080 and the host's
|
||||||
|
# reverse proxy forwards the wisp host/path here.
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# ---- Compression --------------------------------------------------------
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on; # vary on Accept-Encoding so proxies cache correctly
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024; # don't bother compressing tiny responses
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/wasm
|
||||||
|
image/svg+xml
|
||||||
|
font/ttf
|
||||||
|
font/otf;
|
||||||
|
# NOTE: nginx cannot brotli-compress without the (non-default) ngx_brotli
|
||||||
|
# module. The large .wasm model-runtime files benefit most from brotli; if
|
||||||
|
# you need it, switch to an nginx image that bundles ngx_brotli and add
|
||||||
|
# `brotli on; brotli_static on;` here.
|
||||||
|
|
||||||
|
# ---- Cross-origin isolation (REQUIRED for threaded WASM) ----------------
|
||||||
|
# These two headers make the document "cross-origin isolated", which is what
|
||||||
|
# unlocks SharedArrayBuffer and therefore multi-threaded WASM. They are set
|
||||||
|
# on EVERY response (`always`) so they apply even to error responses.
|
||||||
|
#
|
||||||
|
# COOP same-origin -> process-isolate this page from cross-origin openers
|
||||||
|
# COEP require-corp -> every subresource must explicitly opt in (via CORP
|
||||||
|
# or CORS) to being embedded here
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
# Our own assets are same-origin; declare CORP so they remain loadable under
|
||||||
|
# COEP and can also be used by other isolated origins if ever needed.
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# IMPORTANT — Hugging Face model weights & CORS/CORP interaction
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# The app downloads Whisper model weights at runtime from the Hugging Face
|
||||||
|
# Hub (a cross-origin host). Under `Cross-Origin-Embedder-Policy: require-corp`
|
||||||
|
# every cross-origin subresource must be served with either:
|
||||||
|
# Cross-Origin-Resource-Policy: cross-origin (a CORP header), or
|
||||||
|
# valid CORS headers AND be fetched with crossorigin/CORS mode.
|
||||||
|
#
|
||||||
|
# The HF Hub / its CDN generally DO send permissive CORS (Access-Control-
|
||||||
|
# Allow-Origin) headers, so CORS-mode fetches usually work under require-corp.
|
||||||
|
# However, if HF (or a future CDN) ever omits CORP/CORS for a given asset,
|
||||||
|
# require-corp will BLOCK the download and the model load will fail.
|
||||||
|
#
|
||||||
|
# The robust fallback is COEP "credentialless": it keeps the page cross-origin
|
||||||
|
# isolated (SharedArrayBuffer stays available) but lets no-CORS cross-origin
|
||||||
|
# resources load by sending them WITHOUT credentials, removing the hard CORP
|
||||||
|
# requirement. To switch, comment out the `require-corp` line above and
|
||||||
|
# enable the line below instead:
|
||||||
|
#
|
||||||
|
# add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
#
|
||||||
|
# (Browser support: credentialless is supported in modern Chromium/Firefox;
|
||||||
|
# Safari only supports require-corp, so require-corp is the safer default.)
|
||||||
|
|
||||||
|
# ---- SPA routing --------------------------------------------------------
|
||||||
|
location / {
|
||||||
|
# Serve the file if it exists, else a matching directory, else fall back
|
||||||
|
# to index.html so client-side (expo-router) routes resolve. This is the
|
||||||
|
# canonical SPA fallback.
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Apply the computed cache policy (see the map{} above). The header is
|
||||||
|
# re-asserted here because `add_header` does not inherit into locations
|
||||||
|
# once a location defines its own add_header directives.
|
||||||
|
add_header Cache-Control $wisp_cache_control;
|
||||||
|
# Re-assert the isolation headers inside this location (add_header in an
|
||||||
|
# outer scope is dropped as soon as a location sets ANY add_header).
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Android APK download ----------------------------------------------
|
||||||
|
# The CI build-apk job scps the signed APK to the server, where it is mounted
|
||||||
|
# into the web root as /usr/share/nginx/html/wisp.apk (see docker-compose.yml).
|
||||||
|
# If no APK is present yet, this 404s cleanly rather than falling through to
|
||||||
|
# the SPA index.html (which would download an HTML file named wisp.apk).
|
||||||
|
location = /wisp.apk {
|
||||||
|
# Don't cache the APK aggressively so a freshly deployed build is served.
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
# CORP must still be present on this response under COEP.
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
# Force a download with a sensible filename and correct MIME type.
|
||||||
|
types { } # clear inherited type map
|
||||||
|
default_type application/vnd.android.package-archive;
|
||||||
|
add_header Content-Disposition 'attachment; filename="wisp.apk"' always;
|
||||||
|
try_files /wisp.apk =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint for the reverse proxy / container orchestrator.
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# docker/web.Dockerfile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multi-stage build for the Wisp web app.
|
||||||
|
#
|
||||||
|
# Stage 1 (builder): Uses the official Bun image to install dependencies and
|
||||||
|
# run the Expo/Metro static web export. The output is a
|
||||||
|
# fully static SPA in /app/dist.
|
||||||
|
#
|
||||||
|
# Stage 2 (runtime): A tiny nginx:alpine image that serves the exported
|
||||||
|
# static files. nginx is configured (docker/nginx.conf)
|
||||||
|
# to send the cross-origin isolation headers that the
|
||||||
|
# multi-threaded WASM Whisper backend requires, namely:
|
||||||
|
# Cross-Origin-Opener-Policy: same-origin
|
||||||
|
# Cross-Origin-Embedder-Policy: require-corp
|
||||||
|
#
|
||||||
|
# Build: docker build -f docker/web.Dockerfile -t wisp-web:latest .
|
||||||
|
# Run: docker run --rm -p 8080:8080 wisp-web:latest
|
||||||
|
#
|
||||||
|
# NOTE: build context must be the repository root (so the whole repo + lockfile
|
||||||
|
# are available to the builder).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1: build the static site with Bun + Expo export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pin to the Bun 1.x line. `oven/bun:1` tracks the latest 1.x patch release;
|
||||||
|
# pin to an exact digest/tag in production if you want fully reproducible builds.
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy manifest + lockfile first so Docker can cache the (slow) dependency
|
||||||
|
# install layer independently of source changes. `bun.lock` is Bun's lockfile.
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
|
# Install EXACTLY what the lockfile specifies. --frozen-lockfile fails the build
|
||||||
|
# if package.json and bun.lock have drifted, which keeps CI deterministic.
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Now copy the rest of the repository (app code, assets, config, etc.).
|
||||||
|
# Anything matched by .dockerignore (node_modules, dist, .git ...) is skipped.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Produce the static web bundle. `bun run export:web` maps to
|
||||||
|
# `expo export --platform web`, which writes the static site to ./dist
|
||||||
|
# (Expo's default --output-dir). app.json sets web.output = "static", so this
|
||||||
|
# emits prerendered HTML + the JS/WASM assets for the SPA.
|
||||||
|
RUN bun run export:web
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2: serve the static site with nginx
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# Drop the stock nginx site config and install ours. Our config:
|
||||||
|
# - listens on 8080 (non-privileged port; matches docker-compose + reverse proxy)
|
||||||
|
# - SPA fallback (try_files ... /index.html)
|
||||||
|
# - COOP/COEP/CORP cross-origin isolation headers (for threaded WASM)
|
||||||
|
# - gzip + long-lived caching for hashed static assets
|
||||||
|
# - serves /wisp.apk if an APK has been dropped into the web root
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy the exported static site from the builder stage into nginx's web root.
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Document the port the container listens on (informational; publish with -p).
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# nginx:alpine's default entrypoint already runs nginx in the foreground
|
||||||
|
# ("daemon off") via /docker-entrypoint.sh, so no CMD override is needed.
|
||||||
+220
@@ -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
@@ -4,15 +4,19 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.17",
|
||||||
|
"@huggingface/transformers": "^4.2.0",
|
||||||
|
"dexie": "^4.4.3",
|
||||||
"expo": "~56.0.11",
|
"expo": "~56.0.11",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-device": "~56.0.4",
|
"expo-device": "~56.0.4",
|
||||||
|
"expo-document-picker": "^56.0.4",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-router": "~56.2.10",
|
"expo-router": "~56.2.10",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
|
"expo-sqlite": "^56.0.5",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"expo-symbols": "~56.0.6",
|
"expo-symbols": "~56.0.6",
|
||||||
"expo-system-ui": "~56.0.5",
|
"expo-system-ui": "~56.0.5",
|
||||||
@@ -25,18 +29,29 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-web": "~0.21.0",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "~19.2.2",
|
"@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": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"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"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
Executable
+253
@@ -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)"
|
||||||
Executable
+120
@@ -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
@@ -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 { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
export default function RootLayout() {
|
||||||
import AppTabs from '@/components/app-tabs';
|
const scheme = useColorScheme();
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<AnimatedSplashOverlay />
|
<Stack>
|
||||||
<AppTabs />
|
<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>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+165
-81
@@ -1,98 +1,182 @@
|
|||||||
import * as Device from 'expo-device';
|
import { Link, useFocusEffect, useRouter } from 'expo-router';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { useCallback } from 'react';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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 { ThemedText } from '@/components/themed-text';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { WebBadge } from '@/components/web-badge';
|
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
import { BottomTabInset, 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() {
|
export default function LibraryScreen() {
|
||||||
if (Platform.OS === 'web') {
|
const theme = useTheme();
|
||||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
const router = useRouter();
|
||||||
}
|
const { items, loading, query, setQuery, refresh, remove } = useTranscripts();
|
||||||
if (Device.isDevice) {
|
const job = useTranscribe();
|
||||||
return (
|
|
||||||
<ThemedText type="small">
|
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
|
||||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
useFocusEffect(
|
||||||
</ThemedText>
|
useCallback(() => {
|
||||||
);
|
void refresh();
|
||||||
}
|
}, [refresh]),
|
||||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
|
||||||
return (
|
|
||||||
<ThemedText type="small">
|
|
||||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
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 (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.fill}>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<ThemedView style={styles.heroSection}>
|
<View style={styles.headerRow}>
|
||||||
<AnimatedIcon />
|
<View style={styles.flex}>
|
||||||
<ThemedText type="title" style={styles.title}>
|
<ThemedText type="subtitle">Wisp</ThemedText>
|
||||||
Welcome to Expo
|
<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>
|
</ThemedText>
|
||||||
</ThemedView>
|
) : (
|
||||||
|
items.map((t) => (
|
||||||
<ThemedText type="code" style={styles.code}>
|
<TranscriptRow key={t.id} item={t} onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} />
|
||||||
get started
|
))
|
||||||
</ThemedText>
|
)}
|
||||||
|
</ScrollView>
|
||||||
<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>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActiveJob() {
|
||||||
|
const { stage, progress, partial, cancel } = useTranscribe();
|
||||||
|
const pct = Math.round(progress * 100);
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
fill: { flex: 1 },
|
||||||
flex: 1,
|
content: {
|
||||||
justifyContent: 'center',
|
padding: Spacing.three,
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: Spacing.three,
|
gap: Spacing.three,
|
||||||
paddingBottom: BottomTabInset + Spacing.three,
|
|
||||||
maxWidth: MaxContentWidth,
|
maxWidth: MaxContentWidth,
|
||||||
|
width: '100%',
|
||||||
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
heroSection: {
|
flex: { flex: 1 },
|
||||||
alignItems: 'center',
|
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
|
||||||
justifyContent: 'center',
|
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
|
||||||
flex: 1,
|
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
paddingHorizontal: Spacing.four,
|
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
||||||
gap: Spacing.four,
|
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||||
},
|
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||||
title: {
|
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
|
||||||
textAlign: 'center',
|
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
|
||||||
},
|
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
|
||||||
code: {
|
pressed: { opacity: 0.7 },
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: Spacing.three,
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
paddingHorizontal: Spacing.three,
|
|
||||||
paddingVertical: Spacing.four,
|
|
||||||
borderRadius: Spacing.four,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'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 },
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Native (iOS/Android) resolution of the platform decoder. Metro picks this on
|
||||||
|
// native builds.
|
||||||
|
export { decoder } from './decode.native';
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Web resolution of the platform decoder. Metro picks this on web builds.
|
||||||
|
export { decoder } from './decode.web';
|
||||||
@@ -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';
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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'];
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
}));
|
||||||
Vendored
+8
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user