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:
+165
-81
@@ -1,98 +1,182 @@
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Link, useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { AnimatedIcon } from '@/components/animated-icon';
|
||||
import { HintRow } from '@/components/hint-row';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { formatClock } from '@/lib/format';
|
||||
import { MODELS } from '@/lib/models/catalog';
|
||||
import { pickAudio } from '@/lib/pickAudio';
|
||||
import type { TranscriptMeta } from '@/lib/db';
|
||||
import { useTranscribe } from '@/stores/transcribeStore';
|
||||
import { useTranscripts } from '@/stores/transcriptsStore';
|
||||
|
||||
function getDevMenuHint() {
|
||||
if (Platform.OS === 'web') {
|
||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
export default function LibraryScreen() {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { items, loading, query, setQuery, refresh, remove } = useTranscripts();
|
||||
const job = useTranscribe();
|
||||
|
||||
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]),
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const busy = job.status === 'loading' || job.status === 'transcribing';
|
||||
|
||||
const onNew = useCallback(async () => {
|
||||
if (busy) return;
|
||||
const picked = await pickAudio();
|
||||
if (!picked) return;
|
||||
const id = await useTranscribe.getState().start(picked, { title: picked.name });
|
||||
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
|
||||
}, [busy, router]);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
<ThemedView style={styles.fill}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.flex}>
|
||||
<ThemedText type="subtitle">Wisp</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Private transcription — runs on your device, nothing uploaded.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable hitSlop={10}>
|
||||
<ThemedText type="link" themeColor="textSecondary">
|
||||
⚙ Settings
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onNew}
|
||||
disabled={busy}
|
||||
style={({ pressed }) => [
|
||||
styles.newButton,
|
||||
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.newButtonText}>+ New transcription</ThemedText>
|
||||
</Pressable>
|
||||
|
||||
{busy && <ActiveJob />}
|
||||
{job.status === 'error' && (
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<ThemedText type="smallBold">Transcription failed</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{job.error}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={(t) => void setQuery(t)}
|
||||
placeholder="Search transcripts…"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
|
||||
/>
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator style={styles.pad} />
|
||||
) : items.length === 0 ? (
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
|
||||
{query ? 'No matches.' : 'No transcripts yet. Pick an audio or video file to begin.'}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</SafeAreaView>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
<TranscriptRow key={t.id} item={t} onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveJob() {
|
||||
const { stage, progress, partial, cancel } = useTranscribe();
|
||||
const pct = Math.round(progress * 100);
|
||||
return (
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold">
|
||||
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
|
||||
</ThemedText>
|
||||
<Pressable onPress={cancel} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ProgressBar value={progress} />
|
||||
{partial.length > 0 && (
|
||||
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
|
||||
{partial.map((s) => s.text).join(' ')}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.bar, { width: `${Math.max(2, Math.min(100, value * 100))}%` }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpen: () => void; onDelete: () => void }) {
|
||||
const date = new Date(item.createdAt).toLocaleDateString();
|
||||
return (
|
||||
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
<Pressable onPress={onDelete} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">✕</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{date} · {formatClock(item.durationSec)} · {MODELS[item.modelId]?.label ?? item.modelId} · {item.segmentCount} segments
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
fill: { flex: 1 },
|
||||
content: {
|
||||
padding: Spacing.three,
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
},
|
||||
flex: { flex: 1 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
|
||||
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
|
||||
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
|
||||
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
|
||||
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
|
||||
pressed: { opacity: 0.7 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user