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