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