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,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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user