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
+191
View File
@@ -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' },
});