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

Private, offline speech-to-text that runs Whisper on the user's own device —
free, no account, no per-minute fees. Replaces Otter.ai / Rev.

- Pure, tested engine: chunking, overlap timestamp-stitching, exports
  (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests).
- Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from
  CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn
  on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch.
- Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated.
- UI: library + search, import, live-progress transcription, synced click-to-seek
  editor, multi-format export; model picker + privacy in settings.
- Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM;
  Docker (nginx) image + Traefik compose for wisp.briggen.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 17:54:21 +02:00
parent 97996c9846
commit 9f42ee2460
72 changed files with 7293 additions and 421 deletions
+138
View File
@@ -0,0 +1,138 @@
// WEB-ONLY transcription engine, backed by transformers.js (@huggingface/
// transformers) running Whisper in the browser (WebGPU when available, else
// multi-threaded WASM).
//
// WHY WE LOAD IT FROM A CDN AT RUNTIME (not a static import):
// transformers.js depends on onnxruntime-web, which uses a *computed* dynamic
// import (`import(/*webpackIgnore*/ a)`) and ships WASM — Metro (Expo's web
// bundler) cannot statically bundle either and fails the build. So we never let
// Metro see the package: we load the ESM build from a CDN at runtime via a
// dynamic import hidden behind `new Function` (so Metro's static analyzer can't
// trip over it). The browser resolves it natively. This keeps the JS bundle
// small and is the standard way to run transformers.js under Metro/Expo web.
//
// NOTE: the page must be cross-origin isolated (COOP + COEP) for multi-threaded
// WASM; we use COEP: credentialless so the CDN script and the Hugging Face model
// files (CORS-enabled) load without requiring CORP headers. See docker/nginx.conf.
//
// This module is web-only and is NEVER imported by any vitest test.
import { MODELS, recommendModel } from '../models/catalog';
import type { Backend, ModelId, PcmAudio, Segment, TranscribeOptions } from '../types';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
// Pin the transformers.js version we load at runtime.
const TRANSFORMERS_CDN = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0';
// `new Function` hides the dynamic import() specifier from Metro's bundler so it
// never tries to resolve/transform transformers.js or onnxruntime-web.
const runtimeImport = new Function('u', 'return import(u)') as (u: string) => Promise<TransformersModule>;
// Minimal structural types for the bits of transformers.js we use.
interface AsrChunk {
timestamp: [number, number | null];
text: string;
}
interface AsrOutput {
text: string;
chunks?: AsrChunk[];
}
type AsrPipeline = (audio: Float32Array, opts: Record<string, unknown>) => Promise<AsrOutput>;
interface PipelineOptions {
device?: string;
dtype?: string;
progress_callback?: (e: { status?: string; progress?: number }) => void;
}
interface TransformersModule {
pipeline: (task: string, model: string, opts?: PipelineOptions) => Promise<AsrPipeline>;
env: { allowLocalModels: boolean };
}
let libPromise: Promise<TransformersModule> | null = null;
async function lib(): Promise<TransformersModule> {
if (!libPromise) {
libPromise = runtimeImport(TRANSFORMERS_CDN).then((m) => {
// Never read models off the local filesystem in the browser.
m.env.allowLocalModels = false;
return m;
});
}
return libPromise;
}
/** Loaded ASR pipelines, keyed by model id. */
const loaded = new Map<ModelId, AsrPipeline>();
let cachedBackend: Backend | undefined;
async function detectWebGpu(): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false;
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
const adapter = await gpu?.requestAdapter();
return adapter != null;
} catch {
return false;
}
}
async function resolveBackend(): Promise<Backend> {
if (cachedBackend) return cachedBackend;
cachedBackend = (await detectWebGpu()) ? 'webgpu' : 'wasm';
return cachedBackend;
}
export const engine: TranscriptionEngine = {
platform: 'web',
async capabilities(): Promise<EngineCapabilities> {
const backend = await resolveBackend();
return {
backend,
supportsLiveMic: false,
maxRecommendedModel: recommendModel({ backend }),
};
},
async loadModel(modelId: ModelId, onProgress?: (p: number) => void): Promise<void> {
if (loaded.has(modelId)) return;
const { pipeline } = await lib();
const webgpu = (await resolveBackend()) === 'webgpu';
const asr = await pipeline('automatic-speech-recognition', MODELS[modelId].webRepo, {
// WebGPU + fp16 when available; otherwise 8-bit weights on WASM, which
// stays small to download and runs acceptably on a plain CPU.
device: webgpu ? 'webgpu' : 'wasm',
dtype: webgpu ? 'fp16' : 'q8',
progress_callback: (e) => {
if (e.status === 'progress' && e.progress != null) onProgress?.(e.progress / 100);
},
});
loaded.set(modelId, asr);
},
isModelLoaded(modelId: ModelId): boolean {
return loaded.has(modelId);
},
async transcribeChunk(audio: PcmAudio, opts: TranscribeOptions): Promise<Segment[]> {
const asr = loaded.get(opts.modelId);
if (!asr) throw new Error(`Model "${opts.modelId}" is not loaded; call loadModel() first.`);
const out = await asr(audio.samples, {
return_timestamps: true,
// One window at a time; 30s matches Whisper's frame so it won't re-chunk.
chunk_length_s: 30,
language: opts.language,
task: opts.translate ? 'translate' : 'transcribe',
});
const segments: Segment[] = [];
for (const c of out.chunks ?? []) {
const [start, end] = c.timestamp;
if (start == null) continue;
const text = c.text.trim();
if (text.length === 0) continue;
segments.push({ start, end: end ?? start, text });
}
return segments;
},
};