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
+120
View File
@@ -0,0 +1,120 @@
// Drives a single active transcription: decode -> run the pipeline on-device ->
// save to the local library. Holds live progress + the partial transcript so
// the UI can "fill in" as chunks complete, plus an object URL for playback.
import { Platform } from 'react-native';
import { create } from 'zustand';
import { getDecoder, type AudioFileInput } from '@/lib/audio';
import { getRepo } from '@/lib/db';
import { DEFAULT_MODEL } from '@/lib/models/catalog';
import { getEngine } from '@/lib/transcription';
import { transcribe } from '@/lib/transcription/pipeline';
import type { ModelId, Segment } from '@/lib/types';
type Status = 'idle' | 'loading' | 'transcribing' | 'done' | 'error';
interface StartOptions {
title?: string;
language?: string;
translate?: boolean;
}
interface TranscribeState {
status: Status;
stage?: 'loading' | 'transcribing';
progress: number;
partial: Segment[];
error?: string;
modelId: ModelId;
lastTranscriptId?: string;
/** Playable source for the just-finished audio (object URL on web, uri on native). */
audioUrl?: string;
_abort?: AbortController;
setModel: (m: ModelId) => void;
start: (input: AudioFileInput, opts?: StartOptions) => Promise<string | undefined>;
cancel: () => void;
reset: () => void;
}
function makeAudioUrl(input: AudioFileInput): string | undefined {
if (Platform.OS === 'web' && input.data) {
return URL.createObjectURL(new Blob([input.data]));
}
return input.uri;
}
export const useTranscribe = create<TranscribeState>((set, get) => ({
status: 'idle',
progress: 0,
partial: [],
modelId: DEFAULT_MODEL,
setModel: (m) => set({ modelId: m }),
start: async (input, opts) => {
// Clean up any previous object URL.
const prev = get().audioUrl;
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
const abort = new AbortController();
set({
status: 'loading',
stage: 'loading',
progress: 0,
partial: [],
error: undefined,
lastTranscriptId: undefined,
audioUrl: makeAudioUrl(input),
_abort: abort,
});
const { modelId } = get();
try {
const pcm = await getDecoder().decode(input);
const segments = await transcribe({
audio: pcm,
options: { modelId, language: opts?.language, translate: opts?.translate },
engine: getEngine(),
signal: abort.signal,
onProgress: (p) =>
set({ stage: p.stage, progress: p.progress, partial: p.partial, status: p.stage }),
});
const durationSec = pcm.samples.length / pcm.sampleRate;
const saved = await getRepo().create({
title: opts?.title?.trim() || defaultTitle(),
durationSec,
modelId,
language: opts?.language,
segments,
});
set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id });
return saved.id;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
set({ status: 'idle', progress: 0 });
return undefined;
}
set({ status: 'error', error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
cancel: () => {
get()._abort?.abort();
},
reset: () => {
const prev = get().audioUrl;
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
set({ status: 'idle', stage: undefined, progress: 0, partial: [], error: undefined, audioUrl: undefined });
},
}));
function defaultTitle(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `Transcript ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
+49
View File
@@ -0,0 +1,49 @@
// Library state: the saved transcripts list + search, backed by the local
// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only —
// the record of truth lives in the repo.
import { create } from 'zustand';
import { getRepo, type TranscriptMeta } from '@/lib/db';
interface TranscriptsState {
items: TranscriptMeta[];
loading: boolean;
query: string;
refresh: () => Promise<void>;
setQuery: (q: string) => Promise<void>;
remove: (id: string) => Promise<void>;
rename: (id: string, title: string) => Promise<void>;
}
export const useTranscripts = create<TranscriptsState>((set, get) => ({
items: [],
loading: false,
query: '',
refresh: async () => {
set({ loading: true });
try {
const repo = getRepo();
const q = get().query.trim();
const items = q ? await repo.search(q) : await repo.list();
set({ items });
} finally {
set({ loading: false });
}
},
setQuery: async (q) => {
set({ query: q });
await get().refresh();
},
remove: async (id) => {
await getRepo().remove(id);
await get().refresh();
},
rename: async (id, title) => {
await getRepo().update(id, { title });
await get().refresh();
},
}));