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
+11 -9
View File
@@ -1,15 +1,17 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import AppTabs from '@/components/app-tabs';
export default function TabLayout() {
const colorScheme = useColorScheme();
export default function RootLayout() {
const scheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ title: 'Wisp' }} />
<Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}
-180
View File
@@ -1,180 +0,0 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});
+165 -81
View File
@@ -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&nbsp;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 },
});
+66
View File
@@ -0,0 +1,66 @@
import { ScrollView, StyleSheet, Pressable, 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 { listModels } from '@/lib/models/catalog';
import { useTranscribe } from '@/stores/transcribeStore';
export default function SettingsScreen() {
const theme = useTheme();
const modelId = useTranscribe((s) => s.modelId);
const setModel = useTranscribe((s) => s.setModel);
const models = listModels();
return (
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
<ThemedText type="subtitle">Model</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Smaller models are faster and run well on any CPU; larger ones are more accurate but want a GPU.
The model downloads once, then works fully offline.
</ThemedText>
{models.map((m) => {
const selected = m.id === modelId;
return (
<Pressable key={m.id} onPress={() => setModel(m.id)}>
<ThemedView
type={selected ? 'backgroundSelected' : 'backgroundElement'}
style={[styles.card, selected && { borderColor: '#3c87f7', borderWidth: 1 }]}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold">{m.label}</ThemedText>
{selected && <ThemedText type="small" style={{ color: '#3c87f7' }}> selected</ThemedText>}
</View>
<ThemedText type="small" themeColor="textSecondary">
{cap(m.tier)} · ~{m.approxMB} MB · {m.multilingual ? 'multilingual' : 'English-only'}
</ThemedText>
</ThemedView>
</Pressable>
);
})}
<View style={styles.spacer} />
<ThemedText type="subtitle">Privacy</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Wisp transcribes entirely on your device using OpenAI&apos;s Whisper model. Your audio is never
uploaded to any server there is no account, no per-minute fee, and it keeps working with the
network off. The only download is the model file itself.
</ThemedText>
</ScrollView>
</ThemedView>
);
}
function cap(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center' },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.one },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
spacer: { height: Spacing.three },
});
+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' },
});
-32
View File
@@ -1,32 +0,0 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}
-115
View File
@@ -1,115 +0,0 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});
+118
View File
@@ -0,0 +1,118 @@
// Native (iOS/Android) audio decoder.
//
// Unlike the web, React Native has no built-in media decoder we can rely on.
// `ffmpeg-kit-react-native` — the package everyone used to reach for — was
// RETIRED by its maintainer in early 2025, so we deliberately do NOT depend on
// it. For now we support only WAV, which we can decode in pure JS via our own
// `decodeWav`. Everything else throws a clear, actionable error.
//
// TODO(audio/native): add a maintained native decoder for compressed formats
// (mp3/m4a/aac/ogg/flac). The current front-runner is
// `react-native-audio-api` (a Web-Audio-style API for RN). When added, route
// non-WAV URIs through it and downmix/resample with `toMono16k`, mirroring the
// web path.
//
// File reading uses Expo SDK 56's object-oriented `File` API
// (`new File(uri).bytes()`), which returns the raw bytes as a `Uint8Array`
// directly — no base64 round-trip needed. We still keep a base64 decode helper
// below as a documented fallback for environments/URIs where `.bytes()` isn't
// available (it reads via `.base64()` and decodes to a Uint8Array by hand).
import { File } from 'expo-file-system';
import type { AudioDecoder, AudioFileInput } from './decode';
import type { PcmAudio } from '../types';
import { decodeWav } from './wav';
import { toMono16k } from './resample';
/**
* Decode a standard base64 string into a `Uint8Array`.
*
* Kept as a small, dependency-free fallback for reading file bytes when the
* direct `File.bytes()` path is unavailable. Uses `atob` when present (RN's
* Hermes provides it) and falls back to a manual base64 table otherwise so this
* never silently breaks on a runtime missing `atob`.
*/
function base64ToBytes(base64: string): Uint8Array {
// Strip any data-URI prefix and whitespace/newlines that some encoders add.
const clean = base64.replace(/^data:[^,]*,/, '').replace(/\s/g, '');
const g = globalThis as unknown as {
atob?: (s: string) => string;
};
if (typeof g.atob === 'function') {
const binary = g.atob(clean);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i) & 0xff;
}
return out;
}
// Manual base64 decode (no `atob`). Standard alphabet, '=' padding.
const ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Length without padding tells us the exact output byte count.
const noPad = clean.replace(/=+$/, '');
const outLen = Math.floor((noPad.length * 3) / 4);
const out = new Uint8Array(outLen);
let buffer = 0;
let bits = 0;
let o = 0;
for (let i = 0; i < noPad.length; i++) {
const c = ALPHABET.indexOf(noPad[i]!);
if (c === -1) continue; // skip any stray non-alphabet char defensively
buffer = (buffer << 6) | c;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (buffer >> bits) & 0xff;
}
}
return out;
}
/**
* Read a local file URI's raw bytes.
*
* Primary path: SDK 56 `File#bytes()` (direct `Uint8Array`). If that throws for
* some reason, fall back to reading base64 and decoding it ourselves.
*/
async function readFileBytes(uri: string): Promise<Uint8Array> {
const file = new File(uri);
try {
return await file.bytes();
} catch {
// Fallback for runtimes/URIs where `.bytes()` isn't supported.
const base64 = await file.base64();
return base64ToBytes(base64);
}
}
export const decoder: AudioDecoder = {
async decode(input: AudioFileInput): Promise<PcmAudio> {
const { uri } = input;
if (!uri) {
throw new Error('Native decoder requires a file `uri`.');
}
// Sniff the container from the URI extension (case-insensitive), ignoring
// any query string / fragment that content URIs sometimes carry.
const path = uri.split(/[?#]/, 1)[0] ?? uri;
const isWav = path.toLowerCase().endsWith('.wav');
if (!isWav) {
throw new Error(
'Only WAV is supported on native for now — ffmpeg-kit-react-native ' +
'was retired in 2025; a maintained native decoder (e.g. ' +
'react-native-audio-api) is a follow-up.',
);
}
const bytes = await readFileBytes(uri);
const { sampleRate, channelData } = decodeWav(bytes);
const samples = toMono16k(channelData, sampleRate);
return { sampleRate: 16000, samples };
},
};
+40
View File
@@ -0,0 +1,40 @@
// Platform-agnostic audio decoding contract.
//
// The job of a decoder is to take some encoded audio (a browser-decodable blob
// on web, a file URI on native) and return `PcmAudio`: 16 kHz mono Float32
// samples in [-1, 1], exactly what Whisper expects.
//
// This file holds ONLY the types/interface. The actual implementations live in
// platform-specific siblings (`decode.web.ts` / `decode.native.ts`) which Metro
// resolves by platform. Consumers should import the resolved `decoder` via
// `index.ts` (`getDecoder()`), never the platform files directly.
import type { PcmAudio } from '../types';
/**
* Input to a decoder. Which field is required depends on the platform:
*
* - Web reads from in-memory `data` (an `ArrayBuffer`, e.g. from a `<input
* type="file">` / drag-and-drop `File.arrayBuffer()`). Nothing is uploaded.
* - Native reads from a `file://` (or content) `uri` on disk.
*
* `name` is an optional original filename, used only for diagnostics / to sniff
* the container extension; decoders must not depend on it being present.
*/
export interface AudioFileInput {
/** Encoded bytes held in memory. Required on web. */
data?: ArrayBuffer;
/** On-disk location of the encoded file. Required on native. */
uri?: string;
/** Original filename, if known (used for extension sniffing / errors). */
name?: string;
}
/**
* Decodes encoded audio into Whisper-ready PCM (16 kHz, mono, [-1, 1]).
* Implementations are platform-specific; see `decode.web.ts` /
* `decode.native.ts`.
*/
export interface AudioDecoder {
decode(input: AudioFileInput): Promise<PcmAudio>;
}
+61
View File
@@ -0,0 +1,61 @@
// Web audio decoder.
//
// Runs FULLY CLIENT-SIDE: the encoded bytes never leave the browser. We hand
// them to the platform's `AudioContext.decodeAudioData`, which can natively
// decode whatever container/codec the browser supports — typically mp3, m4a/mp4
// (AAC), wav, ogg/opus, and (in most modern browsers) flac. The browser does
// the heavy codec work; we then downmix + resample the PCM to 16 kHz mono.
import type { AudioDecoder, AudioFileInput } from './decode';
import type { PcmAudio } from '../types';
import { toMono16k } from './resample';
/**
* Resolve a usable `AudioContext` constructor. Standard browsers expose
* `window.AudioContext`; older Safari only exposes the `webkit`-prefixed one.
*/
function getAudioContextCtor(): typeof AudioContext {
const ctor =
window.AudioContext ||
// Safari fallback; not in the lib DOM types, hence the cast.
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!ctor) {
throw new Error(
'Web Audio API is unavailable: no AudioContext in this environment.',
);
}
return ctor;
}
export const decoder: AudioDecoder = {
async decode(input: AudioFileInput): Promise<PcmAudio> {
const { data } = input;
if (!data) {
throw new Error('Web decoder requires `data` (an ArrayBuffer).');
}
const Ctor = getAudioContextCtor();
const ctx = new Ctor();
try {
// `decodeAudioData` may *detach* (neuter) the ArrayBuffer it's given, so
// we pass a copy via `slice(0)` to keep the caller's buffer reusable.
const buf = await ctx.decodeAudioData(data.slice(0));
// Pull out every channel's Float32 PCM. `getChannelData` returns a view
// backed by the AudioBuffer; `toMono16k` only reads from it, and we close
// the context only after the synchronous downmix/resample completes.
const channelData: Float32Array[] = Array.from(
{ length: buf.numberOfChannels },
(_unused, c) => buf.getChannelData(c),
);
const samples = toMono16k(channelData, buf.sampleRate);
return { sampleRate: 16000, samples };
} finally {
// Always release the AudioContext (browsers cap how many can be live).
// `close()` returns a promise; we don't need to await it before returning.
void ctx.close();
}
},
};
+3
View File
@@ -0,0 +1,3 @@
// Native (iOS/Android) resolution of the platform decoder. Metro picks this on
// native builds.
export { decoder } from './decode.native';
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks decodeImpl.web.ts / decodeImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { decoder } from './decode.web';
+2
View File
@@ -0,0 +1,2 @@
// Web resolution of the platform decoder. Metro picks this on web builds.
export { decoder } from './decode.web';
+15
View File
@@ -0,0 +1,15 @@
// Public entry point for audio decoding.
//
// `./decodeImpl` is resolved by Metro to the right platform sibling
// (`decodeImpl.web.ts` on web, `decodeImpl.native.ts` on iOS/Android), each of
// which re-exports the matching `decoder`. Consumers call `getDecoder()` and
// stay platform-agnostic; they should never import `decode.web` / `decode.native`
// directly.
import { decoder } from './decodeImpl';
/** Return the platform-resolved audio decoder. */
export const getDecoder = () => decoder;
// Re-export the shared types (`AudioDecoder`, `AudioFileInput`).
export * from './decode';
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { resampleLinear, downmixToMono, toMono16k } from './resample';
describe('resampleLinear', () => {
it('returns an equal-valued but distinct copy when fromRate === toRate', () => {
const input = new Float32Array([0.1, -0.2, 0.3, 0.4]);
const out = resampleLinear(input, 16000, 16000);
expect(out).not.toBe(input); // different instance
expect(Array.from(out)).toEqual(Array.from(input)); // same values
// Mutating the copy must not affect the original.
out[0] = 999;
expect(input[0]).toBeCloseTo(0.1, 6);
});
it('returns empty for empty input regardless of rates', () => {
expect(resampleLinear(new Float32Array(0), 8000, 16000).length).toBe(0);
expect(resampleLinear(new Float32Array(0), 16000, 16000).length).toBe(0);
expect(resampleLinear(new Float32Array(0), 44100, 16000).length).toBe(0);
});
it('upsamples 4 samples from 8000 to 16000 into 8 samples', () => {
const input = new Float32Array([0, 1, 2, 3]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(8); // round(4 * 16000 / 8000) = 8
});
it('output length matches Math.round(len * toRate / fromRate) when downsampling', () => {
const input = new Float32Array(10);
const out = resampleLinear(input, 16000, 8000);
expect(out.length).toBe(5); // round(10 * 8000 / 16000) = 5
});
it('keeps a linear ramp monotonic (non-decreasing) after upsampling', () => {
const n = 16;
const input = new Float32Array(n);
for (let i = 0; i < n; i++) input[i] = i; // 0..15 ramp
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(32);
for (let i = 1; i < out.length; i++) {
expect(out[i]!).toBeGreaterThanOrEqual(out[i - 1]!);
}
// Linear interpolation of a perfect ramp preserves endpoints.
expect(out[0]!).toBeCloseTo(0, 6);
});
it('linearly interpolates the midpoint correctly', () => {
// [0, 10] upsampled x2 -> first output stays 0, then a value between 0 and 10.
const input = new Float32Array([0, 10]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(4);
expect(out[0]!).toBeCloseTo(0, 6);
// srcPos for i=1 is 1 * (8000/16000) = 0.5 -> midpoint between 0 and 10.
expect(out[1]!).toBeCloseTo(5, 6);
});
it('never reads past the end (clamps tail) when upsampling', () => {
const input = new Float32Array([1, 2, 3]);
const out = resampleLinear(input, 1000, 3000);
// No NaN / undefined leakage at the tail.
for (let i = 0; i < out.length; i++) {
expect(Number.isFinite(out[i]!)).toBe(true);
}
expect(out[0]!).toBeCloseTo(1, 6);
});
it('handles a single-sample input', () => {
const input = new Float32Array([0.42]);
const out = resampleLinear(input, 8000, 16000);
expect(out.length).toBe(2);
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.42, 6);
}
});
});
describe('downmixToMono', () => {
it('returns empty Float32Array for no channels', () => {
const out = downmixToMono([]);
expect(out).toBeInstanceOf(Float32Array);
expect(out.length).toBe(0);
});
it('returns a distinct copy for a single channel', () => {
const ch = new Float32Array([0.1, 0.2, 0.3]);
const out = downmixToMono([ch]);
expect(out).not.toBe(ch);
expect(Array.from(out)).toEqual(Array.from(ch));
out[0] = 999;
expect(ch[0]).toBeCloseTo(0.1, 6);
});
it('averages two constant channels (1.0 and 0.0) to all 0.5', () => {
const a = new Float32Array([1, 1, 1, 1]);
const b = new Float32Array([0, 0, 0, 0]);
const out = downmixToMono([a, b]);
expect(out.length).toBe(4);
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.5, 6);
}
});
it('averages three channels sample-wise', () => {
const a = new Float32Array([3, 6, 9]);
const b = new Float32Array([0, 0, 0]);
const c = new Float32Array([0, 3, 0]);
const out = downmixToMono([a, b, c]);
expect(out[0]!).toBeCloseTo(1, 6); // (3+0+0)/3
expect(out[1]!).toBeCloseTo(3, 6); // (6+0+3)/3
expect(out[2]!).toBeCloseTo(3, 6); // (9+0+0)/3
});
});
describe('toMono16k', () => {
it('downmixes then doubles the length when source is 8000 Hz', () => {
const a = new Float32Array([1, 1, 1, 1]);
const b = new Float32Array([0, 0, 0, 0]);
const out = toMono16k([a, b], 8000);
expect(out.length).toBe(8); // round(4 * 16000 / 8000) = 8
// Constant 0.5 mono signal stays 0.5 after resampling.
for (let i = 0; i < out.length; i++) {
expect(out[i]!).toBeCloseTo(0.5, 6);
}
});
it('returns empty for no channels', () => {
expect(toMono16k([], 8000).length).toBe(0);
});
it('returns same length when source is already 16000 Hz', () => {
const a = new Float32Array([0.2, 0.4, 0.6]);
const out = toMono16k([a], 16000);
expect(out.length).toBe(3);
expect(Array.from(out)).toEqual(Array.from(a));
});
});
+109
View File
@@ -0,0 +1,109 @@
// Pure audio resampling + channel downmixing utilities.
//
// These are dependency-free numeric helpers used by the decode pipeline to get
// arbitrary PCM into the 16 kHz mono shape Whisper expects. No platform code,
// no Node builtins — just Float32Array math.
import { WHISPER_SAMPLE_RATE } from '../types';
/**
* Resample `input` from `fromRate` to `toRate` using linear interpolation.
*
* - If `fromRate === toRate`, returns a *copy* (never the same instance).
* - Output length is `Math.round(input.length * toRate / fromRate)`.
* - Empty input yields an empty Float32Array.
*
* Linear interpolation maps each output index `i` back to the continuous source
* position `i * fromRate / toRate`, then blends the two neighboring input
* samples. The last source sample is clamped so we never read past the end.
*/
export function resampleLinear(
input: Float32Array,
fromRate: number,
toRate: number,
): Float32Array {
if (input.length === 0) {
return new Float32Array(0);
}
if (fromRate === toRate) {
// Copy: callers rely on getting a distinct array instance.
return input.slice();
}
const outLength = Math.round((input.length * toRate) / fromRate);
if (outLength <= 0) {
return new Float32Array(0);
}
const output = new Float32Array(outLength);
const ratio = fromRate / toRate;
const lastIndex = input.length - 1;
for (let i = 0; i < outLength; i++) {
const srcPos = i * ratio;
let i0 = Math.floor(srcPos);
if (i0 > lastIndex) {
i0 = lastIndex;
}
const frac = srcPos - i0;
const i1 = i0 < lastIndex ? i0 + 1 : lastIndex;
// Guarded indexed access for noUncheckedIndexedAccess: i0/i1 are clamped to
// [0, lastIndex], so these are always defined, but we coalesce to be safe.
const s0 = input[i0] ?? 0;
const s1 = input[i1] ?? 0;
output[i] = s0 + (s1 - s0) * frac;
}
return output;
}
/**
* Average a set of equal-length channels into a single mono channel.
*
* - `[]` -> empty Float32Array.
* - A single channel -> a copy (distinct instance).
* - Otherwise the per-sample mean across all channels.
*
* Channels are assumed to share the same length; the first channel's length
* defines the output length.
*/
export function downmixToMono(channels: Float32Array[]): Float32Array {
if (channels.length === 0) {
return new Float32Array(0);
}
const first = channels[0]!;
if (channels.length === 1) {
return first.slice();
}
const length = first.length;
const out = new Float32Array(length);
const channelCount = channels.length;
for (let c = 0; c < channelCount; c++) {
const channel = channels[c]!;
for (let i = 0; i < length; i++) {
out[i] = (out[i] ?? 0) + (channel[i] ?? 0);
}
}
for (let i = 0; i < length; i++) {
out[i] = (out[i] ?? 0) / channelCount;
}
return out;
}
/**
* Downmix the given channels to mono, then resample to 16 kHz (Whisper rate).
* Convenience composition of {@link downmixToMono} and {@link resampleLinear}.
*/
export function toMono16k(
channels: Float32Array[],
fromRate: number,
): Float32Array {
const mono = downmixToMono(channels);
return resampleLinear(mono, fromRate, WHISPER_SAMPLE_RATE);
}
+290
View File
@@ -0,0 +1,290 @@
import { describe, it, expect } from 'vitest';
import { decodeWav, encodeWav } from './wav';
/** Build a minimal, valid WAV byte buffer by hand for decode tests. */
function buildWav(opts: {
audioFormat: number;
numChannels: number;
sampleRate: number;
bitsPerSample: number;
/** Raw bytes for the data chunk body. */
data: Uint8Array;
/** When true, write the data chunk before the fmt chunk. */
dataFirst?: boolean;
/** Extra unknown chunks (id must be 4 chars) inserted after fmt. */
extraChunks?: { id: string; body: Uint8Array }[];
}): Uint8Array {
const { audioFormat, numChannels, sampleRate, bitsPerSample, data } = opts;
const blockAlign = (numChannels * bitsPerSample) / 8;
const byteRate = sampleRate * blockAlign;
const fmtBody = new Uint8Array(16);
const fmtView = new DataView(fmtBody.buffer);
fmtView.setUint16(0, audioFormat, true);
fmtView.setUint16(2, numChannels, true);
fmtView.setUint32(4, sampleRate, true);
fmtView.setUint32(8, byteRate, true);
fmtView.setUint16(12, blockAlign, true);
fmtView.setUint16(14, bitsPerSample, true);
type Chunk = { id: string; body: Uint8Array };
const fmtChunk: Chunk = { id: 'fmt ', body: fmtBody };
const dataChunk: Chunk = { id: 'data', body: data };
const chunks: Chunk[] = [];
if (opts.dataFirst) {
chunks.push(dataChunk, fmtChunk);
} else {
chunks.push(fmtChunk);
if (opts.extraChunks) chunks.push(...opts.extraChunks);
chunks.push(dataChunk);
}
// Compute total size: 12-byte descriptor + each chunk (8-byte header + body
// + pad byte for odd-length bodies).
let bodyTotal = 0;
for (const c of chunks) {
bodyTotal += 8 + c.body.length + (c.body.length % 2);
}
const total = 12 + bodyTotal;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
const writeTag = (off: number, tag: string) => {
for (let i = 0; i < 4; i++) view.setUint8(off + i, tag.charCodeAt(i));
};
writeTag(0, 'RIFF');
view.setUint32(4, total - 8, true);
writeTag(8, 'WAVE');
let off = 12;
for (const c of chunks) {
writeTag(off, c.id);
view.setUint32(off + 4, c.body.length, true);
out.set(c.body, off + 8);
off += 8 + c.body.length;
if (c.body.length % 2 === 1) off += 1; // pad byte (already zero)
}
return out;
}
describe('encodeWav / decodeWav round-trip', () => {
it('round-trips mono 16 kHz samples within int16 tolerance', () => {
const n = 2048;
const input = new Float32Array(n);
for (let i = 0; i < n; i++) {
input[i] = Math.sin((2 * Math.PI * 440 * i) / 16000) * 0.9;
}
const encoded = encodeWav(input, 16000);
const decoded = decodeWav(encoded);
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(1);
const out = decoded.channelData[0]!;
expect(out.length).toBe(n);
for (let i = 0; i < n; i++) {
expect(Math.abs(out[i]! - input[i]!)).toBeLessThanOrEqual(1 / 32768);
}
});
it('produces a 44-byte header + 2 bytes per sample', () => {
const input = new Float32Array([0, 0.5, -0.5, 1, -1]);
const encoded = encodeWav(input, 16000);
expect(encoded.byteLength).toBe(44 + input.length * 2);
});
it('clamps out-of-range samples to [-1, 1]', () => {
const input = new Float32Array([2, -2, 5, -9]);
const decoded = decodeWav(encodeWav(input, 16000));
const out = decoded.channelData[0]!;
expect(out[0]!).toBeCloseTo(1, 4);
expect(out[1]!).toBeCloseTo(-1, 4);
expect(out[2]!).toBeCloseTo(1, 4);
expect(out[3]!).toBeCloseTo(-1, 4);
});
it('handles an empty sample buffer', () => {
const decoded = decodeWav(encodeWav(new Float32Array(0), 16000));
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]!.length).toBe(0);
});
it('preserves a non-16k sample rate', () => {
const decoded = decodeWav(encodeWav(new Float32Array([0.1, -0.1]), 44100));
expect(decoded.sampleRate).toBe(44100);
});
});
describe('decodeWav stereo de-interleaving', () => {
it('splits an interleaved 16-bit stereo data chunk into 2 channels', () => {
// 3 frames; L = {100, 300, 500}, R = {-100, -300, -500} (int16 values).
const lVals = [100, 300, 500];
const rVals = [-100, -300, -500];
const data = new Uint8Array(3 * 2 * 2);
const dv = new DataView(data.buffer);
for (let f = 0; f < 3; f++) {
dv.setInt16(f * 4, lVals[f]!, true);
dv.setInt16(f * 4 + 2, rVals[f]!, true);
}
const wav = buildWav({
audioFormat: 1,
numChannels: 2,
sampleRate: 16000,
bitsPerSample: 16,
data,
});
const decoded = decodeWav(wav);
expect(decoded.sampleRate).toBe(16000);
expect(decoded.channelData.length).toBe(2);
const left = decoded.channelData[0]!;
const right = decoded.channelData[1]!;
expect(left.length).toBe(3);
expect(right.length).toBe(3);
for (let f = 0; f < 3; f++) {
expect(left[f]!).toBeCloseTo(lVals[f]! / 0x7fff, 5);
expect(right[f]!).toBeCloseTo(rVals[f]! / 0x8000, 5);
}
});
it('decodes chunks in any order (data before fmt)', () => {
const data = new Uint8Array(4);
const dv = new DataView(data.buffer);
dv.setInt16(0, 16383, true); // ~0.5
dv.setInt16(2, -16384, true); // -0.5
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 8000,
bitsPerSample: 16,
data,
dataFirst: true,
});
const decoded = decodeWav(wav);
expect(decoded.sampleRate).toBe(8000);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(16383 / 0x7fff, 4);
expect(decoded.channelData[0]![1]!).toBeCloseTo(-16384 / 0x8000, 4);
});
it('skips unknown chunks (including odd-length ones)', () => {
const data = new Uint8Array(2);
new DataView(data.buffer).setInt16(0, 12345, true);
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data,
extraChunks: [
{ id: 'LIST', body: new Uint8Array([1, 2, 3]) }, // odd length -> pad byte
{ id: 'fact', body: new Uint8Array([0, 0, 0, 0]) },
],
});
const decoded = decodeWav(wav);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]!.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(12345 / 0x7fff, 4);
});
it('decodes 32-bit IEEE float (format 3) data', () => {
// mono, 2 samples: 0.25 and -0.75
const data = new Uint8Array(8);
const dv = new DataView(data.buffer);
dv.setFloat32(0, 0.25, true);
dv.setFloat32(4, -0.75, true);
const wav = buildWav({
audioFormat: 3,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 32,
data,
});
const decoded = decodeWav(wav);
expect(decoded.channelData.length).toBe(1);
expect(decoded.channelData[0]![0]!).toBeCloseTo(0.25, 6);
expect(decoded.channelData[0]![1]!).toBeCloseTo(-0.75, 6);
});
});
describe('decodeWav error handling', () => {
it('throws on a tiny non-WAVE buffer', () => {
expect(() => decodeWav(new Uint8Array([1, 2, 3]))).toThrow();
});
it("throws when 'RIFF' magic is missing", () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data: new Uint8Array([0, 0]),
});
wav[0] = 'X'.charCodeAt(0);
expect(() => decodeWav(wav)).toThrow(/RIFF/);
});
it("throws when the form is not 'WAVE'", () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 16,
data: new Uint8Array([0, 0]),
});
wav[8] = 'A'.charCodeAt(0);
wav[9] = 'V'.charCodeAt(0);
wav[10] = 'I'.charCodeAt(0);
wav[11] = ' '.charCodeAt(0);
expect(() => decodeWav(wav)).toThrow(/WAVE/);
});
it('throws on an unsupported sample format (8-bit PCM)', () => {
const wav = buildWav({
audioFormat: 1,
numChannels: 1,
sampleRate: 16000,
bitsPerSample: 8,
data: new Uint8Array([0, 0]),
});
expect(() => decodeWav(wav)).toThrow(/Unsupported/);
});
it("throws when the 'data' chunk is missing", () => {
// Build a RIFF/WAVE with only a fmt chunk.
const fmtBody = new Uint8Array(16);
const fmtView = new DataView(fmtBody.buffer);
fmtView.setUint16(0, 1, true);
fmtView.setUint16(2, 1, true);
fmtView.setUint32(4, 16000, true);
fmtView.setUint32(8, 32000, true);
fmtView.setUint16(12, 2, true);
fmtView.setUint16(14, 16, true);
const total = 12 + 8 + 16;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
const writeTag = (off: number, tag: string) => {
for (let i = 0; i < 4; i++) view.setUint8(off + i, tag.charCodeAt(i));
};
writeTag(0, 'RIFF');
view.setUint32(4, total - 8, true);
writeTag(8, 'WAVE');
writeTag(12, 'fmt ');
view.setUint32(16, 16, true);
out.set(fmtBody, 20);
expect(() => decodeWav(out)).toThrow(/data/);
});
});
+214
View File
@@ -0,0 +1,214 @@
// Minimal, dependency-free WAV (RIFF/WAVE) codec for PCM audio.
//
// Supports decoding:
// - 16-bit integer PCM (fmt audioFormat === 1)
// - 32-bit IEEE float PCM (fmt audioFormat === 3)
// Chunks may appear in any order; unknown chunks are skipped. Channels are
// de-interleaved into one Float32Array per channel, with samples in [-1, 1].
//
// Encoding produces a mono, 16-bit PCM WAVE file.
//
// All multi-byte header fields are little-endian (per the WAVE spec). We use
// DataView so endianness is explicit and never relies on the host platform.
/** A decoded WAVE file: sample rate plus one Float32Array per channel. */
export interface DecodedWav {
sampleRate: number;
/** One entry per channel; each holds that channel's samples in [-1, 1]. */
channelData: Float32Array[];
}
const WAVE_FORMAT_PCM = 1;
const WAVE_FORMAT_IEEE_FLOAT = 3;
/** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */
function readTag(view: DataView, offset: number): string {
return (
String.fromCharCode(view.getUint8(offset)) +
String.fromCharCode(view.getUint8(offset + 1)) +
String.fromCharCode(view.getUint8(offset + 2)) +
String.fromCharCode(view.getUint8(offset + 3))
);
}
/**
* Parse a RIFF/WAVE byte buffer.
*
* @throws if the buffer is too small, is not a RIFF container, is not a WAVE
* form, lacks fmt/data chunks, or uses an unsupported sample format.
*/
export function decodeWav(bytes: Uint8Array): DecodedWav {
// A valid WAVE needs at least the 12-byte RIFF/WAVE descriptor.
if (bytes.byteLength < 12) {
throw new Error('Invalid WAV: file too small to contain a RIFF header');
}
const view = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
if (readTag(view, 0) !== 'RIFF') {
throw new Error("Invalid WAV: missing 'RIFF' magic");
}
if (readTag(view, 8) !== 'WAVE') {
throw new Error("Invalid WAV: not a 'WAVE' file");
}
let audioFormat = -1;
let numChannels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = -1;
let dataLength = 0;
let haveFmt = false;
let haveData = false;
// Walk the chunk list starting right after the 12-byte descriptor.
let offset = 12;
while (offset + 8 <= bytes.byteLength) {
const chunkId = readTag(view, offset);
const chunkSize = view.getUint32(offset + 4, true);
const chunkBody = offset + 8;
if (chunkId === 'fmt ') {
if (chunkBody + 16 > bytes.byteLength) {
throw new Error('Invalid WAV: truncated fmt chunk');
}
audioFormat = view.getUint16(chunkBody, true);
numChannels = view.getUint16(chunkBody + 2, true);
sampleRate = view.getUint32(chunkBody + 4, true);
// bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored)
bitsPerSample = view.getUint16(chunkBody + 14, true);
haveFmt = true;
} else if (chunkId === 'data') {
dataOffset = chunkBody;
// Clamp to the actual buffer in case the declared size overruns it.
dataLength = Math.min(chunkSize, bytes.byteLength - chunkBody);
haveData = true;
}
// Unknown chunks fall through and are skipped.
// Chunks are word-aligned: an odd size is followed by a pad byte.
let advance = chunkSize;
if (advance % 2 === 1) advance += 1;
offset = chunkBody + advance;
}
if (!haveFmt) {
throw new Error("Invalid WAV: missing 'fmt ' chunk");
}
if (!haveData) {
throw new Error("Invalid WAV: missing 'data' chunk");
}
if (numChannels < 1) {
throw new Error(`Invalid WAV: bad channel count ${numChannels}`);
}
const channelData: Float32Array[] = [];
if (audioFormat === WAVE_FORMAT_PCM && bitsPerSample === 16) {
const bytesPerSample = 2;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
const int16 = view.getInt16(base + c * bytesPerSample, true);
// Asymmetric int16 range: divide negatives by 32768, positives by 32767.
const f = int16 < 0 ? int16 / 0x8000 : int16 / 0x7fff;
channelData[c]![frame] = f;
}
}
} else if (
audioFormat === WAVE_FORMAT_IEEE_FLOAT &&
bitsPerSample === 32
) {
const bytesPerSample = 4;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
channelData[c]![frame] = view.getFloat32(
base + c * bytesPerSample,
true,
);
}
}
} else {
throw new Error(
`Unsupported WAV format: audioFormat=${audioFormat}, ` +
`bitsPerSample=${bitsPerSample} (supported: 16-bit PCM, 32-bit float)`,
);
}
return { sampleRate, channelData };
}
/**
* Encode mono samples (in [-1, 1]) into a 16-bit PCM WAVE file.
* Out-of-range samples are clamped before quantization.
*/
export function encodeWav(
samples: Float32Array,
sampleRate: number,
): Uint8Array {
const numChannels = 1;
const bitsPerSample = 16;
const bytesPerSample = bitsPerSample / 8;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const headerSize = 44;
const totalSize = headerSize + dataSize;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const writeTag = (offset: number, tag: string): void => {
for (let i = 0; i < tag.length; i++) {
view.setUint8(offset + i, tag.charCodeAt(i));
}
};
// RIFF descriptor
writeTag(0, 'RIFF');
view.setUint32(4, totalSize - 8, true); // chunkSize = file size minus first 8
writeTag(8, 'WAVE');
// fmt chunk (16 bytes of PCM format data)
writeTag(12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk body size
view.setUint16(20, WAVE_FORMAT_PCM, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
// data chunk
writeTag(36, 'data');
view.setUint32(40, dataSize, true);
let offset = headerSize;
for (let i = 0; i < samples.length; i++) {
let s = samples[i]!;
// Clamp to [-1, 1] to avoid integer overflow/wraparound on quantize.
if (s > 1) s = 1;
else if (s < -1) s = -1;
// Match the asymmetric int16 range used when decoding.
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
view.setInt16(offset, int16, true);
offset += bytesPerSample;
}
return new Uint8Array(buffer);
}
+13
View File
@@ -0,0 +1,13 @@
// Public entry point for the storage layer.
//
// `./repoImpl` has no concrete file — Metro/RN picks repoImpl.web.ts or
// repoImpl.native.ts by platform extension, so the right backend is bundled
// for each target. Consumers import getRepo() and the StorageRepo/schema types
// from here and never touch a platform-specific module directly.
import { repo } from './repoImpl';
/** Accessor for the platform-resolved storage repo. */
export const getRepo = () => repo;
export * from './repo';
export * from './schema';
+233
View File
@@ -0,0 +1,233 @@
// Native storage implementation backed by expo-sqlite (SDK 56 async API).
//
// Mirrors the web/Dexie repo's contract exactly. The full transcript body is
// stored as JSON in the 'json' column; metadata lives in dedicated columns so
// list()/search() can be answered from columns alone without parsing every
// body. Search uses a LIKE over the lowercased 'searchText' column.
//
// API reference: https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync).
import * as SQLite from 'expo-sqlite';
import type { Segment } from '../types';
import { newId } from '../ids';
import {
parseDraft,
zSegment,
type TranscriptDraft,
type TranscriptMeta,
type Transcript,
} from './schema';
import type { StorageRepo } from './repo';
// Row shape as returned by getAllAsync from the metadata columns. SQLite has no
// boolean/undefined: optional language comes back as string | null.
interface MetaRow {
id: string;
title: string;
createdAt: number;
updatedAt: number;
durationSec: number;
segmentCount: number;
modelId: string;
language: string | null;
}
// A meta row plus the JSON body, for get().
interface FullRow extends MetaRow {
json: string;
}
// ---------------------------------------------------------------------------
// Pure derivation helpers
// ---------------------------------------------------------------------------
/** Lowercased haystack of title + every segment's text, for LIKE search. */
function deriveSearchText(title: string, segments: Segment[]): string {
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}
/** Map a metadata row to the public TranscriptMeta (drop null language). */
function rowToMeta(r: MetaRow): TranscriptMeta {
return {
id: r.id,
title: r.title,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
durationSec: r.durationSec,
modelId: r.modelId as TranscriptMeta['modelId'],
segmentCount: r.segmentCount,
...(r.language !== null ? { language: r.language } : {}),
};
}
// ---------------------------------------------------------------------------
// Lazy, memoized database handle + schema bootstrap
// ---------------------------------------------------------------------------
let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;
function getDb(): Promise<SQLite.SQLiteDatabase> {
// Open once and reuse; create the table on first open. The async open returns
// a promise we cache so concurrent callers share a single connection.
if (!dbPromise) {
dbPromise = (async () => {
const db = await SQLite.openDatabaseAsync('wisp.db');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY NOT NULL,
json TEXT NOT NULL,
searchText TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
durationSec REAL NOT NULL,
segmentCount INTEGER NOT NULL,
modelId TEXT NOT NULL,
language TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcripts_createdAt
ON transcripts (createdAt);
`);
return db;
})();
}
return dbPromise;
}
// The metadata columns we select for list()/search(), kept in one place.
const META_COLUMNS =
'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language';
// ---------------------------------------------------------------------------
// Repo implementation
// ---------------------------------------------------------------------------
export const repo: StorageRepo = {
async list(): Promise<TranscriptMeta[]> {
const db = await getDb();
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
);
return rows.map(rowToMeta);
},
async get(id: string): Promise<Transcript | undefined> {
const db = await getDb();
const row = await db.getFirstAsync<FullRow>(
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
[id],
);
if (!row) return undefined;
// The JSON body is the authoritative full Transcript (already validated on
// write), so we return it directly rather than reconstructing from columns.
return JSON.parse(row.json) as Transcript;
},
async create(draft: TranscriptDraft): Promise<Transcript> {
const valid = parseDraft(draft);
const now = Date.now();
const transcript: Transcript = {
id: newId('t_'),
title: valid.title,
createdAt: now,
updatedAt: now,
durationSec: valid.durationSec,
modelId: valid.modelId,
...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length,
segments: valid.segments,
};
const db = await getDb();
await db.runAsync(
`INSERT INTO transcripts
(id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, modelId, language)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
transcript.id,
JSON.stringify(transcript),
deriveSearchText(transcript.title, transcript.segments),
transcript.createdAt,
transcript.updatedAt,
transcript.durationSec,
transcript.segmentCount,
transcript.modelId,
// expo-sqlite accepts null but not undefined for params.
transcript.language ?? null,
],
);
return transcript;
},
async update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const db = await getDb();
const existingRow = await db.getFirstAsync<FullRow>(
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
[id],
);
if (!existingRow) {
throw new Error(`transcript not found: ${id}`);
}
const existing = JSON.parse(existingRow.json) as Transcript;
// Re-validate segments on update so non-finite times can never slip in.
const nextSegments =
patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s))
: existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
const updated: Transcript = {
...existing,
title: nextTitle,
segments: nextSegments,
segmentCount: nextSegments.length,
updatedAt: Date.now(),
};
await db.runAsync(
`UPDATE transcripts
SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?
WHERE id = ?`,
[
JSON.stringify(updated),
deriveSearchText(nextTitle, nextSegments),
updated.updatedAt,
updated.segmentCount,
updated.title,
id,
],
);
return updated;
},
async remove(id: string): Promise<void> {
const db = await getDb();
await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]);
},
async search(query: string): Promise<TranscriptMeta[]> {
const db = await getDb();
const needle = query.toLowerCase().trim();
if (!needle) {
// Empty query => everything, newest first (matches the web repo).
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
);
return rows.map(rowToMeta);
}
// Escape LIKE wildcards (% and _) and the escape char itself so user input
// is matched literally, then use ESCAPE to declare the escape character.
const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
const rows = await db.getAllAsync<MetaRow>(
`SELECT ${META_COLUMNS} FROM transcripts
WHERE searchText LIKE ? ESCAPE '\\'
ORDER BY createdAt DESC`,
[`%${escaped}%`],
);
return rows.map(rowToMeta);
},
};
+150
View File
@@ -0,0 +1,150 @@
// Contract tests for the storage layer, run against the web (Dexie) repo.
//
// fake-indexeddb/auto polyfills IndexedDB into the Node global so Dexie works
// under vitest. We test the Dexie repo as the reference implementation of the
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
// imported here because expo-sqlite cannot load outside a device/simulator.
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach } from 'vitest';
import { repo } from './repo.web';
import { parseDraft, type TranscriptDraft } from './schema';
// Helper: a minimal valid draft, overridable per test.
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
return {
title: 'My Recording',
durationSec: 12.5,
modelId: 'tiny.en',
segments: [
{ start: 0, end: 2, text: 'hello world' },
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
],
...over,
};
}
// Wipe storage between tests so ordering/search assertions are deterministic.
beforeEach(async () => {
const all = await repo.list();
await Promise.all(all.map((m) => repo.remove(m.id)));
});
describe('StorageRepo (Dexie web impl)', () => {
it('create -> get round-trips segments and metadata', async () => {
const created = await repo.create(makeDraft());
expect(created.id).toMatch(/^t_/);
expect(created.createdAt).toBeTypeOf('number');
expect(created.updatedAt).toBe(created.createdAt);
expect(created.segmentCount).toBe(2);
const fetched = await repo.get(created.id);
expect(fetched).toBeDefined();
expect(fetched!.title).toBe('My Recording');
expect(fetched!.durationSec).toBe(12.5);
expect(fetched!.modelId).toBe('tiny.en');
// Segments survive the round-trip exactly.
expect(fetched!.segments).toEqual([
{ start: 0, end: 2, text: 'hello world' },
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
]);
});
it('get returns undefined for an unknown id', async () => {
expect(await repo.get('t_nope')).toBeUndefined();
});
it('list returns metadatas newest-first (without segments)', async () => {
const a = await repo.create(makeDraft({ title: 'first' }));
// Force distinct, increasing createdAt timestamps.
await new Promise((r) => setTimeout(r, 2));
const b = await repo.create(makeDraft({ title: 'second' }));
await new Promise((r) => setTimeout(r, 2));
const c = await repo.create(makeDraft({ title: 'third' }));
const metas = await repo.list();
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
// Metas omit the segment body.
expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
expect(metas[0]!.segmentCount).toBe(2);
});
it('update changes title + segments and re-derives searchText for search', async () => {
const created = await repo.create(
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
);
const originalUpdatedAt = created.updatedAt;
await new Promise((r) => setTimeout(r, 2));
const updated = await repo.update(created.id, {
title: 'Brand New Title',
segments: [
{ start: 0, end: 1, text: 'banana' },
{ start: 1, end: 2, text: 'cherry' },
],
});
expect(updated.title).toBe('Brand New Title');
expect(updated.segmentCount).toBe(2);
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
// Old text is no longer findable...
expect(await repo.search('apple')).toHaveLength(0);
// ...and the new text + new title are.
const byText = await repo.search('cherry');
expect(byText.map((m) => m.id)).toEqual([created.id]);
const byTitle = await repo.search('brand new');
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
});
it('update throws for an unknown id', async () => {
await expect(repo.update('t_missing', { title: 'x' })).rejects.toThrow();
});
it('search matches title and segment text case-insensitively', async () => {
const t = await repo.create(
makeDraft({
title: 'Quarterly MEETING notes',
segments: [{ start: 0, end: 3, text: 'Discuss the BUDGET forecast' }],
}),
);
// Title match, different case.
expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
// Segment-text match, different case.
expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
// Non-match.
expect(await repo.search('zzz-not-present')).toHaveLength(0);
});
it('remove deletes a transcript', async () => {
const created = await repo.create(makeDraft());
expect(await repo.get(created.id)).toBeDefined();
await repo.remove(created.id);
expect(await repo.get(created.id)).toBeUndefined();
expect(await repo.list()).toHaveLength(0);
});
it('parseDraft rejects a NaN duration', () => {
expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
});
it('parseDraft rejects a non-finite segment time', () => {
expect(() =>
parseDraft(makeDraft({ segments: [{ start: 0, end: Infinity, text: 'x' }] })),
).toThrow();
expect(() =>
parseDraft(makeDraft({ segments: [{ start: NaN, end: 1, text: 'x' }] })),
).toThrow();
});
it('create rejects an invalid draft (defense in depth)', async () => {
// create() calls parseDraft internally, so bad input never persists.
await expect(
repo.create(makeDraft({ durationSec: -1 })),
).rejects.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
// The platform-agnostic storage contract.
//
// Both the web (Dexie/IndexedDB) and native (expo-sqlite) implementations
// satisfy this interface, so the rest of the app depends only on StorageRepo
// and never on a specific backend. The correct implementation is selected at
// build time via platform file extensions (see repoImpl.web.ts / repoImpl.native.ts).
import type { TranscriptDraft, TranscriptMeta, Transcript } from './schema';
export interface StorageRepo {
/** All transcript metadatas, newest first (by createdAt desc). */
list(): Promise<TranscriptMeta[]>;
/** Full transcript by id, or undefined if it does not exist. */
get(id: string): Promise<Transcript | undefined>;
/** Validate the draft and persist a new transcript, returning the stored row. */
create(draft: TranscriptDraft): Promise<Transcript>;
/**
* Patch a transcript's title and/or segments. Re-derives any denormalized
* fields (searchText, segmentCount, updatedAt) and re-validates segments.
* Rejects if the id does not exist.
*/
update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript>;
/** Delete by id. No-op if absent. */
remove(id: string): Promise<void>;
/**
* Case-insensitive substring search over title + segment text.
* Returns metadatas, newest first.
*/
search(query: string): Promise<TranscriptMeta[]>;
}
+150
View File
@@ -0,0 +1,150 @@
// Web storage implementation backed by Dexie (IndexedDB).
//
// We keep ONE object store ('transcripts') keyed by id. Each record holds the
// full Transcript plus a derived, lowercased 'searchText' (title + all segment
// text) used for case-insensitive substring search. Search is done in-memory
// (filter over all rows) because IndexedDB has no native substring index;
// transcript counts are small enough on-device that this is fine.
import Dexie from 'dexie';
import type { Segment } from '../types';
import { newId } from '../ids';
import {
parseDraft,
zSegment,
type TranscriptDraft,
type TranscriptMeta,
type Transcript,
} from './schema';
import type { StorageRepo } from './repo';
// The shape persisted in IndexedDB: a full Transcript plus the derived
// searchText column. searchText is never returned to callers.
interface StoredTranscript extends Transcript {
searchText: string;
}
// ---------------------------------------------------------------------------
// Pure derivation helpers (shared by create/update within this file)
// ---------------------------------------------------------------------------
/** Lowercased haystack of title + every segment's text, for substring search. */
function deriveSearchText(title: string, segments: Segment[]): string {
// Join with newlines so adjacent segments don't accidentally fuse into a
// word that matches a query spanning the boundary.
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}
/** Strip the derived searchText, returning the public Transcript view. */
function toTranscript(row: StoredTranscript): Transcript {
const { searchText: _searchText, ...rest } = row;
return rest;
}
/** Strip both searchText and segments, returning the lightweight meta view. */
function toMeta(row: StoredTranscript): TranscriptMeta {
const { searchText: _searchText, segments: _segments, ...meta } = row;
return meta;
}
// ---------------------------------------------------------------------------
// Dexie database
// ---------------------------------------------------------------------------
class WispDexie extends Dexie {
// Only 'id' and 'createdAt' need to be indexed: id is the primary key,
// createdAt powers the newest-first ordering. searchText is matched by an
// in-memory filter, so it does not need an index.
transcripts!: Dexie.Table<StoredTranscript, string>;
constructor() {
super('wisp');
this.version(1).stores({
transcripts: 'id, createdAt',
});
}
}
const db = new WispDexie();
// ---------------------------------------------------------------------------
// Repo implementation
// ---------------------------------------------------------------------------
export const repo: StorageRepo = {
async list(): Promise<TranscriptMeta[]> {
// Sort by createdAt then reverse for newest-first.
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
return rows.map(toMeta);
},
async get(id: string): Promise<Transcript | undefined> {
const row = await db.transcripts.get(id);
return row ? toTranscript(row) : undefined;
},
async create(draft: TranscriptDraft): Promise<Transcript> {
// Validate+throw before doing anything else.
const valid = parseDraft(draft);
const now = Date.now();
const stored: StoredTranscript = {
id: newId('t_'),
title: valid.title,
createdAt: now,
updatedAt: now,
durationSec: valid.durationSec,
modelId: valid.modelId,
// Preserve optional language only when present (avoid storing undefined).
...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length,
segments: valid.segments,
searchText: deriveSearchText(valid.title, valid.segments),
};
await db.transcripts.put(stored);
return toTranscript(stored);
},
async update(
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const existing = await db.transcripts.get(id);
if (!existing) {
throw new Error(`transcript not found: ${id}`);
}
// Re-validate segments on every write so an update can never persist
// non-finite times that a create() would have rejected.
const nextSegments =
patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s))
: existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
const updated: StoredTranscript = {
...existing,
title: nextTitle,
segments: nextSegments,
// Re-derive denormalized fields so search() and list() stay correct.
segmentCount: nextSegments.length,
searchText: deriveSearchText(nextTitle, nextSegments),
updatedAt: Date.now(),
};
await db.transcripts.put(updated);
return toTranscript(updated);
},
async remove(id: string): Promise<void> {
await db.transcripts.delete(id);
},
async search(query: string): Promise<TranscriptMeta[]> {
const needle = query.toLowerCase().trim();
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
// Empty query => return everything (newest first), matching common list UX.
const matched = needle
? rows.filter((r) => r.searchText.includes(needle))
: rows;
return matched.map(toMeta);
},
};
+4
View File
@@ -0,0 +1,4 @@
// Platform shim (native): re-exports the expo-sqlite repo.
// Metro/RN resolves './repoImpl' to this file on iOS/Android via the
// .native extension, keeping expo-sqlite out of the web bundle.
export { repo } from './repo.native';
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks repoImpl.web.ts / repoImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { repo } from './repo.web';
+3
View File
@@ -0,0 +1,3 @@
// Platform shim (web): re-exports the Dexie/IndexedDB repo.
// Metro/RN resolves './repoImpl' to this file on web via the .web extension.
export { repo } from './repo.web';
+122
View File
@@ -0,0 +1,122 @@
// Zod schemas + domain types for stored transcripts.
//
// This module is the single source of truth for what a "valid" transcript
// looks like before it ever touches storage. Every write path (web Dexie repo,
// native SQLite repo) funnels user/engine-produced data through these schemas
// so malformed numbers (NaN/Infinity) and bad shapes can never be persisted.
//
// IMPORTANT: relative imports only inside src/lib (vitest has no '@/*' alias).
import { z } from 'zod';
import type { Segment, ModelId } from '../types';
// ---------------------------------------------------------------------------
// Reusable number guards
// ---------------------------------------------------------------------------
// A finite number: rejects NaN and ±Infinity. `z.number()` alone accepts both,
// which is dangerous for durations/timestamps that get serialized to JSON/SQL.
const zFinite = z.number().refine(Number.isFinite, {
message: 'must be a finite number (no NaN/Infinity)',
});
// A finite number that is also >= 0, for durations and time offsets.
const zFiniteNonNeg = zFinite.refine((n) => n >= 0, {
message: 'must be >= 0',
});
// ---------------------------------------------------------------------------
// Segment
// ---------------------------------------------------------------------------
/**
* One transcribed segment as accepted from a draft.
* Mirrors {@link Segment} from ../types but with runtime validation:
* - start/end: finite and >= 0 (seconds, absolute to the media)
* - text: any string (may be empty in pathological cases)
* - confidence: optional, finite and within [0, 1]
*/
export const zSegment = z.object({
start: zFiniteNonNeg,
end: zFiniteNonNeg,
text: z.string(),
confidence: zFinite
.refine((n) => n >= 0 && n <= 1, { message: 'confidence must be in [0,1]' })
.optional(),
});
// The 6 supported model ids, as a Zod enum. Kept in lockstep with ModelId in
// ../types. A compile-time assertion below guarantees they never drift.
export const zModelId = z.enum([
'tiny.en',
'tiny',
'base.en',
'base',
'small.en',
'small',
]);
// Compile-time guard: if ../types ModelId changes, this assignment fails to
// typecheck, forcing zModelId to be updated. (No runtime cost.)
type _ModelIdMatches = ModelId extends z.infer<typeof zModelId>
? z.infer<typeof zModelId> extends ModelId
? true
: never
: never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _modelIdMatches: _ModelIdMatches = true;
// ---------------------------------------------------------------------------
// Transcript draft (input to create())
// ---------------------------------------------------------------------------
/**
* The unsaved shape a caller hands to the repo to create a transcript.
* No id/timestamps/segmentCount yet — the repo derives those.
*/
export const zTranscriptDraft = z.object({
title: z.string(),
durationSec: zFiniteNonNeg,
modelId: zModelId,
language: z.string().optional(),
segments: z.array(zSegment),
});
/** Inferred TS type for a validated draft. */
export type TranscriptDraft = z.infer<typeof zTranscriptDraft>;
/**
* Validate an unknown input as a TranscriptDraft, throwing (ZodError) on any
* violation. This is the throwing gatekeeper the repos call on every write.
*/
export function parseDraft(input: unknown): TranscriptDraft {
return zTranscriptDraft.parse(input);
}
// ---------------------------------------------------------------------------
// Stored types (output of the repo)
// ---------------------------------------------------------------------------
/**
* Lightweight metadata for a stored transcript — everything except the
* (potentially large) segments array. Returned by list()/search() so the UI
* can render lists without loading full bodies.
*/
export interface TranscriptMeta {
id: string;
title: string;
/** Creation time, ms since epoch (runtime clock). */
createdAt: number;
/** Last-update time, ms since epoch (runtime clock). */
updatedAt: number;
durationSec: number;
modelId: ModelId;
language?: string;
/** Number of segments in the body (denormalized for list views). */
segmentCount: number;
}
/** A full stored transcript: metadata plus the segment body. */
export interface Transcript extends TranscriptMeta {
segments: Segment[];
}
+19
View File
@@ -0,0 +1,19 @@
import { Platform } from 'react-native';
/**
* Trigger a client-side file download on web (no server, nothing uploaded).
* Returns false on native, where the caller should fall back to share/save.
*/
export function downloadText(filename: string, mime: string, content: string): boolean {
if (Platform.OS !== 'web') return false;
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return true;
}
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest';
import type { Segment } from '../types';
import {
toSrt,
toVtt,
toTxt,
toMarkdown,
toJson,
formatTranscript,
EXPORT_META,
type ExportFormat,
type TranscriptExport,
} from './index';
// A small fixture spanning a sub-minute, a multi-minute, and an hour-plus time.
const segments: Segment[] = [
{ start: 0, end: 2.5, text: 'Hello world.' },
{ start: 2.5, end: 65.25, text: ' Leading and trailing space. ' },
{ start: 3661.123, end: 3663.5, text: 'After one hour.' },
];
describe('toSrt', () => {
it('numbers cues sequentially starting at 1', () => {
const out = toSrt(segments);
const lines = out.split('\n');
expect(lines[0]).toBe('1');
// Cues are separated by a blank line; indices appear as standalone lines.
expect(out).toContain('\n\n2\n');
expect(out).toContain('\n\n3\n');
});
it('uses HH:MM:SS,mmm timecodes with a comma separator', () => {
const out = toSrt(segments);
expect(out).toContain('00:00:00,000 --> 00:00:02,500');
expect(out).toContain('00:00:02,500 --> 00:01:05,250');
});
it('renders hour-plus timestamps correctly', () => {
const out = toSrt(segments);
// 3661.123s -> 01:01:01,123 ; 3663.5s -> 01:01:03,500
expect(out).toContain('01:01:01,123 --> 01:01:03,500');
});
it('trims segment text', () => {
const out = toSrt(segments);
expect(out).toContain('Leading and trailing space.');
expect(out).not.toContain(' Leading and trailing space. ');
});
it('separates cues by exactly one blank line and has no trailing newline', () => {
const out = toSrt([
{ start: 0, end: 1, text: 'a' },
{ start: 1, end: 2, text: 'b' },
]);
expect(out).toBe('1\n00:00:00,000 --> 00:00:01,000\na\n\n2\n00:00:01,000 --> 00:00:02,000\nb');
});
it('returns empty string for empty input', () => {
expect(toSrt([])).toBe('');
});
});
describe('toVtt', () => {
it('starts with the WEBVTT header followed by a blank line', () => {
const out = toVtt(segments);
expect(out.startsWith('WEBVTT\n\n')).toBe(true);
});
it('uses a "." millisecond separator in cues', () => {
const out = toVtt(segments);
expect(out).toContain('00:00:00.000 --> 00:00:02.500');
expect(out).toContain('01:01:01.123 --> 01:01:03.500');
// No SRT-style comma separators should appear.
expect(out).not.toMatch(/\d{2}:\d{2}:\d{2},\d{3}/);
});
it('trims segment text', () => {
const out = toVtt(segments);
expect(out).toContain('Leading and trailing space.');
});
it('returns exactly the header for empty input', () => {
expect(toVtt([])).toBe('WEBVTT\n\n');
});
});
describe('toTxt', () => {
it('emits one trimmed segment per line', () => {
const out = toTxt(segments);
expect(out).toBe('Hello world.\nLeading and trailing space.\nAfter one hour.');
});
it('contains no timestamp-like substrings', () => {
const out = toTxt(segments);
// No HH:MM:SS, no m:ss clocks, and no bracketed times.
expect(out).not.toMatch(/\d{2}:\d{2}:\d{2}/);
expect(out).not.toMatch(/\d+:\d{2}/);
expect(out).not.toMatch(/\[/);
});
it('returns empty string for empty input', () => {
expect(toTxt([])).toBe('');
});
});
describe('toMarkdown', () => {
it('renders bold clock prefixes using formatClock', () => {
const out = toMarkdown(segments);
// Prefix uses the segment START, formatted by formatClock.
expect(out).toContain('**[0:00]** Hello world.');
expect(out).toContain('**[0:02]** Leading and trailing space.');
// Hour-plus start uses h:mm:ss.
expect(out).toContain('**[1:01:01]** After one hour.');
});
it('prepends a "# {title}" heading when a title is provided', () => {
const out = toMarkdown(segments, { title: 'My Talk' });
expect(out.startsWith('# My Talk\n\n')).toBe(true);
expect(out).toContain('**[0:00]** Hello world.');
});
it('omits the heading when no title is given', () => {
const out = toMarkdown(segments);
expect(out.startsWith('#')).toBe(false);
});
it('emits just the heading for empty input with a title', () => {
expect(toMarkdown([], { title: 'Empty' })).toBe('# Empty');
});
it('returns empty string for empty input without a title', () => {
expect(toMarkdown([])).toBe('');
});
});
describe('toJson', () => {
it('round-trips via JSON.parse with version, segments and durationSec', () => {
const out = toJson(segments, { title: 'Talk' });
const parsed = JSON.parse(out) as TranscriptExport;
expect(parsed.version).toBe(1);
expect(parsed.title).toBe('Talk');
expect(parsed.segments).toEqual(segments);
// durationSec is the last (max) end.
expect(parsed.durationSec).toBe(3663.5);
});
it('computes durationSec as the max segment end, not necessarily the last', () => {
const out = toJson([
{ start: 0, end: 10, text: 'a' },
{ start: 1, end: 4, text: 'b' },
]);
expect((JSON.parse(out) as TranscriptExport).durationSec).toBe(10);
});
it('is pretty-printed with 2-space indentation', () => {
const out = toJson(segments);
expect(out).toContain('\n "version": 1');
});
it('omits title when not provided', () => {
const out = toJson(segments);
const parsed = JSON.parse(out) as TranscriptExport;
expect('title' in parsed).toBe(false);
});
it('yields durationSec 0 and empty segments for empty input', () => {
const parsed = JSON.parse(toJson([])) as TranscriptExport;
expect(parsed.durationSec).toBe(0);
expect(parsed.segments).toEqual([]);
expect(parsed.version).toBe(1);
});
});
describe('formatTranscript dispatch', () => {
const cases: ExportFormat[] = ['srt', 'vtt', 'txt', 'md', 'json'];
it('dispatches to each formatter identically to calling it directly', () => {
expect(formatTranscript(segments, 'srt')).toBe(toSrt(segments));
expect(formatTranscript(segments, 'vtt')).toBe(toVtt(segments));
expect(formatTranscript(segments, 'txt')).toBe(toTxt(segments));
expect(formatTranscript(segments, 'md', { title: 'T' })).toBe(toMarkdown(segments, { title: 'T' }));
expect(formatTranscript(segments, 'json', { title: 'T' })).toBe(toJson(segments, { title: 'T' }));
});
it('produces non-throwing output for every format on empty input', () => {
for (const fmt of cases) {
expect(() => formatTranscript([], fmt)).not.toThrow();
}
expect(formatTranscript([], 'srt')).toBe('');
expect(formatTranscript([], 'vtt')).toBe('WEBVTT\n\n');
});
});
describe('EXPORT_META', () => {
it('has an entry with ext/mime/label for every format', () => {
const formats: ExportFormat[] = ['srt', 'vtt', 'txt', 'md', 'json'];
for (const fmt of formats) {
const meta = EXPORT_META[fmt];
expect(meta.ext).toBeTruthy();
expect(meta.mime).toContain('/');
expect(meta.label).toBeTruthy();
}
});
it('maps the file extension to match the format key', () => {
expect(EXPORT_META.srt.ext).toBe('srt');
expect(EXPORT_META.vtt.ext).toBe('vtt');
expect(EXPORT_META.json.mime).toBe('application/json');
});
});
+56
View File
@@ -0,0 +1,56 @@
// Export-format registry and dispatcher. Re-exports each formatter and maps a
// format id to its serializer plus file metadata (extension / MIME / label).
import type { Segment } from '../types';
import { toSrt } from './srt';
import { toVtt } from './vtt';
import { toTxt } from './txt';
import { toMarkdown } from './md';
import { toJson } from './json';
export { toSrt } from './srt';
export { toVtt } from './vtt';
export { toTxt } from './txt';
export { toMarkdown } from './md';
export { toJson } from './json';
export type { TranscriptExport } from './json';
/** Supported export formats. */
export type ExportFormat = 'srt' | 'vtt' | 'txt' | 'md' | 'json';
/** File metadata for each format, for download / share UIs. */
export const EXPORT_META: Record<ExportFormat, { ext: string; mime: string; label: string }> = {
srt: { ext: 'srt', mime: 'application/x-subrip', label: 'SubRip (.srt)' },
vtt: { ext: 'vtt', mime: 'text/vtt', label: 'WebVTT (.vtt)' },
txt: { ext: 'txt', mime: 'text/plain', label: 'Plain text (.txt)' },
md: { ext: 'md', mime: 'text/markdown', label: 'Markdown (.md)' },
json: { ext: 'json', mime: 'application/json', label: 'JSON (.json)' },
};
/**
* Serialize segments using the requested format. `opts.title` is honored by the
* Markdown and JSON formatters and ignored by the others.
*/
export function formatTranscript(
segments: Segment[],
format: ExportFormat,
opts?: { title?: string },
): string {
switch (format) {
case 'srt':
return toSrt(segments);
case 'vtt':
return toVtt(segments);
case 'txt':
return toTxt(segments);
case 'md':
return toMarkdown(segments, opts);
case 'json':
return toJson(segments, opts);
default: {
// Exhaustiveness guard: if `ExportFormat` grows, this fails to compile.
const never: never = format;
throw new Error(`Unknown export format: ${String(never)}`);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// JSON (.json) export. Stable, versioned, machine-readable transcript shape.
import type { Segment } from '../types';
/** Serializable transcript envelope. `version` is bumped on breaking changes. */
export interface TranscriptExport {
version: 1;
title?: string;
/** Total duration in seconds = the largest segment end (0 if no segments). */
durationSec: number;
segments: Segment[];
}
/**
* Render segments as pretty-printed (2-space) JSON.
* `durationSec` is the maximum segment `end` (0 when there are no segments).
* A `title` is included only when provided and non-empty.
*/
export function toJson(segments: Segment[], opts?: { title?: string }): string {
let durationSec = 0;
for (const seg of segments) {
if (seg.end > durationSec) durationSec = seg.end;
}
const title = opts?.title?.trim();
const payload: TranscriptExport = {
version: 1,
...(title ? { title } : {}),
durationSec,
segments,
};
return JSON.stringify(payload, null, 2);
}
+19
View File
@@ -0,0 +1,19 @@
// Markdown (.md) export. Optional title heading, then `**[m:ss]** text` lines.
import type { Segment } from '../types';
import { formatClock } from '../format';
/**
* Render segments as Markdown. Each segment becomes a line like
* `**[m:ss]** text` using {@link formatClock}. When `opts.title` is provided a
* `# {title}` heading is emitted first, separated from the body by a blank line.
*/
export function toMarkdown(segments: Segment[], opts?: { title?: string }): string {
const lines = segments.map((seg) => `**[${formatClock(seg.start)}]** ${seg.text.trim()}`);
const body = lines.join('\n');
const title = opts?.title?.trim();
if (title) {
return body.length > 0 ? `# ${title}\n\n${body}` : `# ${title}`;
}
return body;
}
+25
View File
@@ -0,0 +1,25 @@
// SubRip (.srt) export. Numbered cues with `HH:MM:SS,mmm` timecodes.
import type { Segment } from '../types';
import { formatTimecode } from '../format';
/**
* Render segments as SubRip subtitles.
*
* Each cue is:
* <index>
* HH:MM:SS,mmm --> HH:MM:SS,mmm
* <text>
* with a blank line between cues. Empty input yields an empty string.
*/
export function toSrt(segments: Segment[]): string {
if (segments.length === 0) return '';
const cues = segments.map((seg, i) => {
const index = i + 1;
const start = formatTimecode(seg.start, ',');
const end = formatTimecode(seg.end, ',');
const text = seg.text.trim();
return `${index}\n${start} --> ${end}\n${text}`;
});
return cues.join('\n\n');
}
+12
View File
@@ -0,0 +1,12 @@
// Plain-text (.txt) export. One trimmed segment per line, no timestamps.
import type { Segment } from '../types';
/**
* Render segments as plain text: each segment's trimmed text on its own line.
* No timestamps, no numbering. Empty input yields an empty string.
*/
export function toTxt(segments: Segment[]): string {
if (segments.length === 0) return '';
return segments.map((seg) => seg.text.trim()).join('\n');
}
+22
View File
@@ -0,0 +1,22 @@
// WebVTT (.vtt) export. Header + cues with `HH:MM:SS.mmm` timecodes.
import type { Segment } from '../types';
import { formatTimecode } from '../format';
const HEADER = 'WEBVTT\n\n';
/**
* Render segments as WebVTT. Always starts with the `WEBVTT` header followed by
* a blank line. Cues use `.` as the millisecond separator. Empty input yields
* just the header.
*/
export function toVtt(segments: Segment[]): string {
if (segments.length === 0) return HEADER;
const cues = segments.map((seg) => {
const start = formatTimecode(seg.start, '.');
const end = formatTimecode(seg.end, '.');
const text = seg.text.trim();
return `${start} --> ${end}\n${text}`;
});
return HEADER + cues.join('\n\n') + '\n';
}
+31
View File
@@ -0,0 +1,31 @@
// Time formatting shared by the export formatters and the UI. Pure & tested.
/**
* Format seconds as a subtitle timecode `HH:MM:SS<sep>mmm`.
* @param sep millisecond separator: `,` for SRT, `.` for WebVTT.
*/
export function formatTimecode(totalSeconds: number, sep: ',' | '.' = '.'): string {
const safe = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
const ms = Math.round(safe * 1000);
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}${sep}${pad(millis, 3)}`;
}
/** Short human clock for UI / Markdown: `m:ss` or `h:mm:ss`. */
export function formatClock(totalSeconds: number): string {
const safe = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
const whole = Math.floor(safe);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const seconds = whole % 60;
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
+6
View File
@@ -0,0 +1,6 @@
// Small id helper for transcripts/jobs. Sortable-ish (time prefix) + random tail.
export function newId(prefix = ''): string {
const time = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 10);
return `${prefix}${time}${rand}`;
}
+295
View File
@@ -0,0 +1,295 @@
import { describe, it, expect } from 'vitest';
import {
createJob,
isTerminal,
reduceJob,
jobProgress,
type Job,
type JobEvent,
} from './queue';
describe('createJob', () => {
it('starts pending with zeroed counters and no error', () => {
const job = createJob('abc');
expect(job).toEqual({
id: 'abc',
status: 'pending',
chunkIndex: 0,
chunkCount: 0,
});
expect(job.error).toBeUndefined();
});
it('preserves the given id', () => {
expect(createJob('xyz-123').id).toBe('xyz-123');
});
});
describe('isTerminal', () => {
it('is false for in-flight states', () => {
expect(isTerminal(createJob('a'))).toBe(false);
expect(isTerminal({ ...createJob('a'), status: 'decoding' })).toBe(false);
expect(isTerminal({ ...createJob('a'), status: 'transcribing' })).toBe(
false,
);
});
it('is true for done, error, and cancelled', () => {
expect(isTerminal({ ...createJob('a'), status: 'done' })).toBe(true);
expect(isTerminal({ ...createJob('a'), status: 'error' })).toBe(true);
expect(isTerminal({ ...createJob('a'), status: 'cancelled' })).toBe(true);
});
});
describe('reduceJob — full happy path', () => {
it('pending -> decoding -> transcribing(3) -> chunkDone 1,2,3 -> complete, progress 1', () => {
let job = createJob('job1');
expect(job.status).toBe('pending');
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'startDecode' });
expect(job.status).toBe('decoding');
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
expect(job.status).toBe('transcribing');
expect(job.chunkCount).toBe(3);
expect(job.chunkIndex).toBe(0);
expect(jobProgress(job)).toBe(0);
job = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job.chunkIndex).toBe(1);
expect(jobProgress(job)).toBeCloseTo(1 / 3, 10);
job = reduceJob(job, { type: 'chunkDone', index: 2 });
expect(job.chunkIndex).toBe(2);
expect(jobProgress(job)).toBeCloseTo(2 / 3, 10);
job = reduceJob(job, { type: 'chunkDone', index: 3 });
expect(job.chunkIndex).toBe(3);
expect(jobProgress(job)).toBe(1);
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(3);
expect(isTerminal(job)).toBe(true);
expect(jobProgress(job)).toBe(1);
});
it('complete forces chunkIndex up to chunkCount even if chunks were not all reported', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 5 });
job = reduceJob(job, { type: 'chunkDone', index: 2 });
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(5);
expect(jobProgress(job)).toBe(1);
});
it('complete with zero chunkCount yields done with progress 1', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'complete' });
expect(job.status).toBe('done');
expect(job.chunkIndex).toBe(0);
expect(job.chunkCount).toBe(0);
// done short-circuits to 1 regardless of counts.
expect(jobProgress(job)).toBe(1);
});
});
describe('reduceJob — fail', () => {
it('sets error status, captures the message, and is terminal', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startDecode' });
job = reduceJob(job, { type: 'fail', error: 'decode boom' });
expect(job.status).toBe('error');
expect(job.error).toBe('decode boom');
expect(isTerminal(job)).toBe(true);
});
it('can fail straight from transcribing', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
job = reduceJob(job, { type: 'chunkDone', index: 1 });
job = reduceJob(job, { type: 'fail', error: 'gpu lost' });
expect(job.status).toBe('error');
expect(job.error).toBe('gpu lost');
// partial progress is retained on failure
expect(job.chunkIndex).toBe(1);
expect(job.chunkCount).toBe(4);
});
});
describe('reduceJob — cancel', () => {
it('moves to cancelled and is terminal', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startDecode' });
job = reduceJob(job, { type: 'cancel' });
expect(job.status).toBe('cancelled');
expect(isTerminal(job)).toBe(true);
});
it('can cancel from pending', () => {
const job = reduceJob(createJob('j'), { type: 'cancel' });
expect(job.status).toBe('cancelled');
});
});
describe('reduceJob — events after terminal are ignored', () => {
const terminalStatuses: Array<Job['status']> = ['done', 'error', 'cancelled'];
const followups: JobEvent[] = [
{ type: 'startDecode' },
{ type: 'startTranscribe', chunkCount: 9 },
{ type: 'chunkDone', index: 5 },
{ type: 'complete' },
{ type: 'fail', error: 'late failure' },
{ type: 'cancel' },
];
for (const status of terminalStatuses) {
for (const ev of followups) {
it(`${status} job ignores ${ev.type} (same reference returned)`, () => {
const terminal: Job = {
id: 'j',
status,
chunkIndex: 2,
chunkCount: 4,
...(status === 'error' ? { error: 'orig' } : {}),
};
const result = reduceJob(terminal, ev);
// Unchanged AND same reference (no allocation).
expect(result).toBe(terminal);
expect(result).toEqual(terminal);
});
}
}
});
describe('reduceJob — purity (no mutation, new object)', () => {
it('does not mutate its input', () => {
const job = createJob('j');
const snapshot = { ...job };
const next = reduceJob(job, { type: 'startDecode' });
expect(job).toEqual(snapshot);
expect(next).not.toBe(job);
expect(next.status).toBe('decoding');
// original untouched
expect(job.status).toBe('pending');
});
it('returns a new object for every state-changing event', () => {
let job = createJob('j');
const seen = new Set<Job>();
seen.add(job);
const events: JobEvent[] = [
{ type: 'startDecode' },
{ type: 'startTranscribe', chunkCount: 2 },
{ type: 'chunkDone', index: 1 },
{ type: 'chunkDone', index: 2 },
{ type: 'complete' },
];
for (const ev of events) {
const next = reduceJob(job, ev);
expect(seen.has(next)).toBe(false);
seen.add(next);
job = next;
}
});
it('chunkDone does not mutate the input when clamping/ignoring', () => {
const job: Job = {
id: 'j',
status: 'transcribing',
chunkIndex: 3,
chunkCount: 3,
};
const snapshot = { ...job };
const next = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job).toEqual(snapshot); // input untouched
expect(next).not.toBe(job); // still a fresh object
expect(next.chunkIndex).toBe(3); // monotonic: stays at 3
});
});
describe('reduceJob — chunkDone clamping and monotonicity', () => {
it('clamps index to chunkCount', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
job = reduceJob(job, { type: 'chunkDone', index: 99 });
expect(job.chunkIndex).toBe(3);
expect(jobProgress(job)).toBe(1);
});
it('never moves backward (monotonic) across chunkDone events', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 10 });
const sequence = [3, 1, 5, 2, 5, 6, 4, 10, 7];
let prevProgress = jobProgress(job);
let prevIndex = job.chunkIndex;
for (const idx of sequence) {
job = reduceJob(job, { type: 'chunkDone', index: idx });
expect(job.chunkIndex).toBeGreaterThanOrEqual(prevIndex);
expect(jobProgress(job)).toBeGreaterThanOrEqual(prevProgress);
prevIndex = job.chunkIndex;
prevProgress = jobProgress(job);
}
expect(job.chunkIndex).toBe(10);
expect(jobProgress(job)).toBe(1);
});
it('treats a smaller index as a no-op for progress', () => {
let job = createJob('j');
job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
job = reduceJob(job, { type: 'chunkDone', index: 3 });
job = reduceJob(job, { type: 'chunkDone', index: 1 });
expect(job.chunkIndex).toBe(3);
});
});
describe('jobProgress', () => {
it('is 0 before chunkCount is known', () => {
expect(jobProgress(createJob('j'))).toBe(0);
expect(
jobProgress({ ...createJob('j'), status: 'decoding' }),
).toBe(0);
});
it('is the chunk ratio while transcribing', () => {
const job: Job = {
id: 'j',
status: 'transcribing',
chunkIndex: 1,
chunkCount: 4,
};
expect(jobProgress(job)).toBe(0.25);
});
it('is 1 for a done job regardless of counts', () => {
expect(
jobProgress({ id: 'j', status: 'done', chunkIndex: 0, chunkCount: 0 }),
).toBe(1);
});
it('reflects partial progress for error/cancelled (not forced to 1)', () => {
const errored: Job = {
id: 'j',
status: 'error',
chunkIndex: 1,
chunkCount: 4,
error: 'x',
};
expect(jobProgress(errored)).toBe(0.25);
const cancelled: Job = {
id: 'j',
status: 'cancelled',
chunkIndex: 2,
chunkCount: 4,
};
expect(jobProgress(cancelled)).toBe(0.5);
});
});
+111
View File
@@ -0,0 +1,111 @@
// Pure, framework-free state machine for a single transcription job.
//
// A job moves through a fixed lifecycle:
// pending -> decoding -> transcribing -> done
// with `error` and `cancelled` as alternative terminal states reachable from
// any non-terminal state. The reducer is PURE: it never mutates its input and
// always returns a fresh object. Events delivered to a terminal job are
// ignored (the same reference is returned), so callers can fire-and-forget.
/** Lifecycle states a job can occupy. */
export type JobStatus =
| 'pending'
| 'decoding'
| 'transcribing'
| 'done'
| 'error'
| 'cancelled';
/** Immutable snapshot of a job's progress through the pipeline. */
export interface Job {
/** Stable, caller-assigned identity. */
id: string;
/** Current lifecycle state. */
status: JobStatus;
/** Number of chunks fully transcribed so far. */
chunkIndex: number;
/** Total chunks to transcribe; 0 until `startTranscribe`. */
chunkCount: number;
/** Human-readable failure reason; present only when status === 'error'. */
error?: string;
}
/** Events that drive a job forward. */
export type JobEvent =
| { type: 'startDecode' }
| { type: 'startTranscribe'; chunkCount: number }
/** `index` = total chunks now completed (cumulative, not a delta). */
| { type: 'chunkDone'; index: number }
| { type: 'complete' }
| { type: 'fail'; error: string }
| { type: 'cancel' };
/** Create a fresh job in the initial `pending` state. */
export function createJob(id: string): Job {
return { id, status: 'pending', chunkIndex: 0, chunkCount: 0 };
}
/** True once the job can no longer change state. */
export function isTerminal(job: Job): boolean {
return (
job.status === 'done' ||
job.status === 'error' ||
job.status === 'cancelled'
);
}
/**
* Pure reducer. Returns a NEW Job for any state-changing event; returns the
* SAME reference (unchanged) when the job is already terminal. Never mutates
* `job`.
*/
export function reduceJob(job: Job, ev: JobEvent): Job {
// Terminal jobs absorb all further events with no change.
if (isTerminal(job)) return job;
switch (ev.type) {
case 'startDecode':
return { ...job, status: 'decoding' };
case 'startTranscribe':
return {
...job,
status: 'transcribing',
chunkCount: ev.chunkCount,
};
case 'chunkDone': {
// Progress is monotonic (never goes backward) and never exceeds the
// known chunk count.
const advanced = Math.max(job.chunkIndex, ev.index);
const clamped = Math.min(advanced, job.chunkCount);
return { ...job, chunkIndex: clamped };
}
case 'complete':
return { ...job, status: 'done', chunkIndex: job.chunkCount };
case 'fail':
return { ...job, status: 'error', error: ev.error };
case 'cancel':
return { ...job, status: 'cancelled' };
default: {
// Exhaustiveness guard: if a new event type is added without handling,
// this fails to compile.
const _exhaustive: never = ev;
return _exhaustive;
}
}
}
/**
* Fractional progress in [0, 1]. A `done` job is always 1. Otherwise it is the
* ratio of completed chunks to total, or 0 when the total is not yet known.
*/
export function jobProgress(job: Job): number {
if (job.status === 'done') return 1;
if (job.chunkCount > 0) return job.chunkIndex / job.chunkCount;
return 0;
}
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';
import type { ModelId, Backend } from '../types';
import {
MODELS,
DEFAULT_MODEL,
recommendModel,
modelsByTier,
listModels,
type ModelInfo,
type ModelTier,
type DeviceCaps,
} from './catalog';
const ALL_IDS: ModelId[] = [
'tiny.en',
'tiny',
'base.en',
'base',
'small.en',
'small',
];
describe('MODELS catalog', () => {
it('contains exactly the six expected ids', () => {
const keys = Object.keys(MODELS).sort();
expect(keys).toEqual([...ALL_IDS].sort());
});
it('each entry id field matches its key', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(info.id).toBe(id);
}
});
it('populates required descriptive fields with sane values', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(typeof info.label).toBe('string');
expect(info.label.length).toBeGreaterThan(0);
expect(info.approxMB).toBeGreaterThan(0);
expect(info.webRepo.length).toBeGreaterThan(0);
expect(info.ggml.length).toBeGreaterThan(0);
}
});
it('webRepo and ggml follow the documented naming conventions', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
expect(info.webRepo).toBe(`Xenova/whisper-${id}`);
expect(info.ggml).toBe(`ggml-${id}.bin`);
}
});
it('marks .en ids as English-only and the rest as multilingual', () => {
for (const id of ALL_IDS) {
const info = MODELS[id];
if (id.endsWith('.en')) {
expect(info.multilingual).toBe(false);
} else {
expect(info.multilingual).toBe(true);
}
}
});
it('assigns tiers by family: tiny=fast, base=balanced, small=accurate', () => {
const expected: Record<ModelId, ModelTier> = {
'tiny.en': 'fast',
tiny: 'fast',
'base.en': 'balanced',
base: 'balanced',
'small.en': 'accurate',
small: 'accurate',
};
for (const id of ALL_IDS) {
expect(MODELS[id].tier).toBe(expected[id]);
}
});
it('uses the documented rough size buckets', () => {
expect(MODELS['tiny.en'].approxMB).toBe(75);
expect(MODELS.tiny.approxMB).toBe(75);
expect(MODELS['base.en'].approxMB).toBe(145);
expect(MODELS.base.approxMB).toBe(145);
expect(MODELS['small.en'].approxMB).toBe(480);
expect(MODELS.small.approxMB).toBe(480);
});
});
describe('DEFAULT_MODEL', () => {
it('is tiny.en', () => {
expect(DEFAULT_MODEL).toBe('tiny.en');
});
it('is a real catalog entry', () => {
expect(MODELS[DEFAULT_MODEL]).toBeDefined();
});
});
describe('recommendModel', () => {
const gpuBackends: Backend[] = ['webgpu', 'coreml', 'metal'];
const cpuBackends: Backend[] = ['cpu', 'wasm'];
it('recommends base.en for every GPU-class backend', () => {
for (const backend of gpuBackends) {
expect(recommendModel({ backend })).toBe('base.en');
}
});
it('recommends tiny.en for every CPU-class backend', () => {
for (const backend of cpuBackends) {
expect(recommendModel({ backend })).toBe('tiny.en');
}
});
it('recommends tiny.en when lowMemory, even on a GPU backend', () => {
for (const backend of gpuBackends) {
expect(recommendModel({ backend, lowMemory: true })).toBe('tiny.en');
}
});
it('recommends tiny.en when lowMemory on a CPU backend', () => {
for (const backend of cpuBackends) {
expect(recommendModel({ backend, lowMemory: true })).toBe('tiny.en');
}
});
it('treats lowMemory:false the same as omitting it', () => {
expect(recommendModel({ backend: 'webgpu', lowMemory: false })).toBe(
'base.en',
);
expect(recommendModel({ backend: 'cpu', lowMemory: false })).toBe(
'tiny.en',
);
});
it('never recommends a small.* model for any input', () => {
const allBackends: Backend[] = [...gpuBackends, ...cpuBackends];
for (const backend of allBackends) {
for (const lowMemory of [undefined, false, true]) {
const caps: DeviceCaps = { backend, lowMemory };
const picked = recommendModel(caps);
expect(picked.startsWith('small')).toBe(false);
}
}
});
it('always returns an id present in the catalog', () => {
const allBackends: Backend[] = [...gpuBackends, ...cpuBackends];
for (const backend of allBackends) {
const picked = recommendModel({ backend });
expect(MODELS[picked]).toBeDefined();
}
});
});
describe('modelsByTier', () => {
it('returns the tiny models for the fast tier', () => {
const fast = modelsByTier('fast');
const ids = fast.map((m) => m.id).sort();
expect(ids).toEqual(['tiny', 'tiny.en']);
});
it('returns the base models for the balanced tier', () => {
const balanced = modelsByTier('balanced');
const ids = balanced.map((m) => m.id).sort();
expect(ids).toEqual(['base', 'base.en']);
});
it('returns the small models for the accurate tier', () => {
const accurate = modelsByTier('accurate');
const ids = accurate.map((m) => m.id).sort();
expect(ids).toEqual(['small', 'small.en']);
});
it('returns entries whose tier matches the query', () => {
const tiers: ModelTier[] = ['fast', 'balanced', 'accurate'];
for (const tier of tiers) {
for (const info of modelsByTier(tier)) {
expect(info.tier).toBe(tier);
}
}
});
});
describe('listModels', () => {
it('lists all six models', () => {
const list = listModels();
expect(list).toHaveLength(6);
});
it('returns full ModelInfo objects with matching ids', () => {
for (const info of listModels()) {
const typed: ModelInfo = info;
expect(MODELS[typed.id]).toEqual(typed);
}
});
it('covers exactly the catalog ids with no duplicates', () => {
const ids = listModels().map((m) => m.id);
expect(new Set(ids).size).toBe(ids.length);
expect([...ids].sort()).toEqual([...ALL_IDS].sort());
});
});
+129
View File
@@ -0,0 +1,129 @@
// Whisper model catalog plus a small device-aware recommendation policy.
// Pure data + functions: no platform, no I/O. Downstream UI and the engine
// loader read from MODELS to know download sizes, repos, and ggml filenames.
import type { ModelId, Backend } from '../types';
/** Rough capability bucket for a model, used for UX grouping and sorting. */
export type ModelTier = 'fast' | 'balanced' | 'accurate';
/** Static metadata describing a single Whisper model variant. */
export interface ModelInfo {
/** Canonical model id (matches its key in {@link MODELS}). */
id: ModelId;
/** Human-friendly display name. */
label: string;
/** Speed/accuracy bucket. */
tier: ModelTier;
/** True for the general multilingual variants; false for `.en` (English-only). */
multilingual: boolean;
/** Approximate on-disk / download size in megabytes (rough, for UX). */
approxMB: number;
/** Hugging Face repo for the web/transformers.js backend, e.g. 'Xenova/whisper-tiny.en'. */
webRepo: string;
/** ggml binary filename for the native whisper.cpp backend, e.g. 'ggml-tiny.en.bin'. */
ggml: string;
}
/**
* The full catalog of supported models, keyed by id. Tiers:
* tiny* -> 'fast', base* -> 'balanced', small* -> 'accurate'.
* `.en` ids are English-only (multilingual=false); the bare ids are multilingual.
*/
export const MODELS: Record<ModelId, ModelInfo> = {
'tiny.en': {
id: 'tiny.en',
label: 'Tiny (English)',
tier: 'fast',
multilingual: false,
approxMB: 75,
webRepo: 'Xenova/whisper-tiny.en',
ggml: 'ggml-tiny.en.bin',
},
tiny: {
id: 'tiny',
label: 'Tiny',
tier: 'fast',
multilingual: true,
approxMB: 75,
webRepo: 'Xenova/whisper-tiny',
ggml: 'ggml-tiny.bin',
},
'base.en': {
id: 'base.en',
label: 'Base (English)',
tier: 'balanced',
multilingual: false,
approxMB: 145,
webRepo: 'Xenova/whisper-base.en',
ggml: 'ggml-base.en.bin',
},
base: {
id: 'base',
label: 'Base',
tier: 'balanced',
multilingual: true,
approxMB: 145,
webRepo: 'Xenova/whisper-base',
ggml: 'ggml-base.bin',
},
'small.en': {
id: 'small.en',
label: 'Small (English)',
tier: 'accurate',
multilingual: false,
approxMB: 480,
webRepo: 'Xenova/whisper-small.en',
ggml: 'ggml-small.en.bin',
},
small: {
id: 'small',
label: 'Small',
tier: 'accurate',
multilingual: true,
approxMB: 480,
webRepo: 'Xenova/whisper-small',
ggml: 'ggml-small.bin',
},
};
/** The safe out-of-the-box default: fast, small download, no language baggage. */
export const DEFAULT_MODEL: ModelId = 'tiny.en';
/** What we know about the current device's compute path and memory pressure. */
export interface DeviceCaps {
backend: Backend;
/** When true, prefer the smallest model regardless of backend. */
lowMemory?: boolean;
}
/** Backends that have meaningful GPU-class acceleration. */
const GPU_BACKENDS: ReadonlySet<Backend> = new Set<Backend>([
'webgpu',
'coreml',
'metal',
]);
/**
* Pick a sensible default model for a device.
* Policy:
* - lowMemory -> 'tiny.en' (always wins)
* - GPU-class backend (webgpu/coreml/metal) -> 'base.en'
* - CPU-class backend (cpu/wasm) -> 'tiny.en'
* Never recommends a 'small.*' model as a default.
*/
export function recommendModel(caps: DeviceCaps): ModelId {
if (caps.lowMemory) return 'tiny.en';
if (GPU_BACKENDS.has(caps.backend)) return 'base.en';
return 'tiny.en';
}
/** All models in a given tier, in catalog (declaration) order. */
export function modelsByTier(tier: ModelTier): ModelInfo[] {
return listModels().filter((m) => m.tier === tier);
}
/** All models as a flat list, in catalog (declaration) order. */
export function listModels(): ModelInfo[] {
return Object.values(MODELS);
}
+27
View File
@@ -0,0 +1,27 @@
// Cross-platform audio/video file picker that returns an AudioFileInput the
// decoder understands: an ArrayBuffer on web, a file uri on native.
import * as DocumentPicker from 'expo-document-picker';
import { Platform } from 'react-native';
import type { AudioFileInput } from '@/lib/audio';
export type PickedAudio = AudioFileInput & { name: string };
export async function pickAudio(): Promise<PickedAudio | null> {
const res = await DocumentPicker.getDocumentAsync({
type: ['audio/*', 'video/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (res.canceled || res.assets.length === 0) return null;
const asset = res.assets[0]!;
const name = asset.name || 'recording';
if (Platform.OS === 'web') {
// On web the uri is a blob: URL — read it into an ArrayBuffer for WebAudio.
const data = await (await fetch(asset.uri)).arrayBuffer();
return { data, name };
}
return { uri: asset.uri, name };
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { planChunks } from './chunking';
const SR = 16000;
describe('planChunks', () => {
it('returns [] for empty or invalid lengths', () => {
expect(planChunks(0)).toEqual([]);
expect(planChunks(-5)).toEqual([]);
});
it('emits a single chunk covering everything when it fits one window', () => {
const total = 10 * SR;
const p = planChunks(total, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(p).toHaveLength(1);
expect(p[0]).toMatchObject({
index: 0,
startSample: 0,
endSample: total,
isFirst: true,
isLast: true,
});
expect(p[0]!.endSec).toBeCloseTo(10);
});
it('windows long audio with the right stride and full coverage', () => {
const total = 100 * SR;
const windowSamples = 28 * SR;
const stride = (28 - 2) * SR;
const p = planChunks(total, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(p.length).toBeGreaterThan(1);
p.forEach((c, i) => {
expect(c.index).toBe(i);
expect(c.startSample).toBe(i * stride);
expect(c.endSample).toBe(Math.min(i * stride + windowSamples, total));
});
// exactly one first, exactly one last; last reaches the end
expect(p.filter((c) => c.isFirst)).toHaveLength(1);
expect(p.filter((c) => c.isLast)).toHaveLength(1);
expect(p[p.length - 1]!.endSample).toBe(total);
// adjacent chunks genuinely overlap
for (let i = 1; i < p.length; i++) {
expect(p[i]!.startSample).toBeLessThan(p[i - 1]!.endSample);
}
});
it('rejects nonsensical window/overlap', () => {
expect(() => planChunks(SR, { windowSec: 0 })).toThrow();
expect(() => planChunks(SR, { windowSec: 5, overlapSec: 5 })).toThrow();
expect(() => planChunks(SR, { windowSec: 5, overlapSec: -1 })).toThrow();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { WHISPER_SAMPLE_RATE } from '../types';
/** One planned window over the audio. Sample ranges are half-open [start, end). */
export interface ChunkPlanItem {
index: number;
startSample: number;
endSample: number;
startSec: number;
endSec: number;
isFirst: boolean;
isLast: boolean;
}
export interface ChunkPlanOptions {
/** Window length in seconds. Whisper's encoder works on 30s frames; keep < 30. */
windowSec?: number;
/** Overlap between adjacent windows, in seconds. */
overlapSec?: number;
sampleRate?: number;
}
/**
* Plan fixed-size overlapping windows over `totalSamples`.
*
* Whisper can only attend to ~30s at a time, so long media must be windowed.
* Adjacent windows overlap by `overlapSec` so the stitcher (see stitch.ts) can
* drop words that Whisper truncates/hallucinates at a window edge. Every chunk
* starts at `index * stride` where `stride = windowSamples - overlapSamples`,
* and the final chunk is clamped to `totalSamples`.
*/
export function planChunks(totalSamples: number, opts: ChunkPlanOptions = {}): ChunkPlanItem[] {
const sampleRate = opts.sampleRate ?? WHISPER_SAMPLE_RATE;
const windowSec = opts.windowSec ?? 28;
const overlapSec = opts.overlapSec ?? 2;
if (!(totalSamples > 0)) return [];
if (!(windowSec > 0)) throw new Error('windowSec must be > 0');
if (overlapSec < 0 || overlapSec >= windowSec) {
throw new Error('overlapSec must be in [0, windowSec)');
}
const windowSamples = Math.max(1, Math.round(windowSec * sampleRate));
const overlapSamples = Math.round(overlapSec * sampleRate);
const stride = windowSamples - overlapSamples; // > 0 by the guard above
if (totalSamples <= windowSamples) {
return [makeItem(0, 0, totalSamples, sampleRate, totalSamples)];
}
const items: ChunkPlanItem[] = [];
for (let index = 0; ; index++) {
const start = index * stride;
const end = Math.min(start + windowSamples, totalSamples);
items.push(makeItem(index, start, end, sampleRate, totalSamples));
if (end >= totalSamples) break;
}
return items;
}
function makeItem(
index: number,
startSample: number,
endSample: number,
sampleRate: number,
totalSamples: number,
): ChunkPlanItem {
return {
index,
startSample,
endSample,
startSec: startSample / sampleRate,
endSec: endSample / sampleRate,
isFirst: startSample === 0,
isLast: endSample >= totalSamples,
};
}
+60
View File
@@ -0,0 +1,60 @@
// The transcription engine interface: the single abstraction that hides the
// platform-specific Whisper backend (transformers.js on web, whisper.rn on
// native) behind one stable contract. The pure orchestration pipeline
// (pipeline.ts) is written entirely against this interface so it can be unit
// tested with a fake engine and never imports any heavy/native dependency.
//
// IMPORTANT contract detail: `transcribeChunk` returns CHUNK-LOCAL times
// (0-based, relative to the start of the chunk it was handed). The pipeline is
// responsible for offsetting those into absolute media time and stitching the
// overlapping windows together (see pipeline.ts + stitch.ts).
import type {
Backend,
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
/**
* What an engine discovered about the device it is running on. Used by the UI
* to gate model choices and to decide whether live-mic capture is available.
*/
export interface EngineCapabilities {
/** The compute backend the engine resolved to (e.g. 'webgpu', 'cpu'). */
backend: Backend;
/** Whether this engine can transcribe from a live microphone stream. */
supportsLiveMic: boolean;
/** Largest model we'd recommend running on this device/backend. */
maxRecommendedModel: ModelId;
}
/**
* A platform-agnostic Whisper engine. Both `engineImpl.web.ts` and
* `engineImpl.native.ts` export a singleton `engine` satisfying this.
*/
export interface TranscriptionEngine {
/** Which platform family this engine targets. */
readonly platform: 'web' | 'native';
/** Probe the device. May be async (e.g. WebGPU adapter request). */
capabilities(): Promise<EngineCapabilities>;
/**
* Ensure `modelId` is loaded and ready. `onProgress` (if given) receives a
* fraction in [0, 1] during download/initialization. Implementations should
* be idempotent: calling again for an already-loaded model is a no-op.
*/
loadModel(modelId: ModelId, onProgress?: (p: number) => void): Promise<void>;
/** Synchronous check: is this model already loaded in memory? */
isModelLoaded(modelId: ModelId): boolean;
/**
* Transcribe a single already-decoded 16 kHz mono chunk.
* Returns segments with CHUNK-LOCAL times (seconds, 0-based). The caller
* offsets and stitches; engines must NOT add the chunk's media offset.
*/
transcribeChunk(audio: PcmAudio, opts: TranscribeOptions): Promise<Segment[]>;
}
+137
View File
@@ -0,0 +1,137 @@
// NATIVE-ONLY transcription engine, backed by whisper.rn (React Native binding
// of whisper.cpp). This module imports a native module (whisper.rn) and Expo's
// file system, so it is NEVER imported by any vitest test (only the mock-based
// pipeline.test.ts is run). It is selected at runtime by platform-aware code.
//
// whisper.rn API used here (v0.6.0, verified against
// node_modules/whisper.rn/lib/typescript/index.d.ts +
// .../NativeRNWhisper.d.ts and the transcribeData source in src/index.ts):
//
// initWhisper({ filePath }): Promise<WhisperContext>
// - `filePath` is a string path to a ggml .bin model file.
//
// WhisperContext.transcribeData(
// data: string | ArrayBuffer, // base64 float32 PCM, or raw ArrayBuffer
// options?: TranscribeFileOptions, // { language?, translate?, ... }
// ): { stop: () => Promise<void>; promise: Promise<TranscribeResult> }
// - We pass the Float32Array's ArrayBuffer directly (the binding accepts an
// ArrayBuffer of raw float32 PCM; see transcribeData in src/index.ts).
// - It returns an object with a `promise`, NOT a bare promise.
//
// TranscribeResult = {
// result: string; language: string; isAborted: boolean;
// segments: Array<{ text: string; t0: number; t1: number }>;
// }
// - t0/t1 are whisper.cpp timestamps in CENTISECONDS (1/100 s), so we divide
// by 100 to get seconds. They are chunk-local because we hand the context
// one window at a time.
import { initWhisper } from 'whisper.rn';
import type { WhisperContext } from 'whisper.rn';
import { Paths, File } from 'expo-file-system';
import type {
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
import { MODELS, recommendModel } from '../models/catalog';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
/** Loaded whisper.cpp contexts, keyed by model id. */
const loaded = new Map<ModelId, WhisperContext>();
/**
* Resolve the on-device path of a model's ggml file.
*
* Models live under `<documentDirectory>/models/<ggml-filename>`. The document
* directory survives app restarts and OS storage pressure, which is what we want
* for multi-hundred-MB model files.
*
* TODO: the model DOWNLOAD UI / fetcher is NOT built yet. This function only
* computes where the file *should* be; the .bin must already exist there
* (e.g. side-loaded during development) or loadModel() will reject. Wire a
* downloader (File.downloadFileAsync into Paths.document/'models') before
* shipping.
*
* whisper.rn's initWhisper expects a plain filesystem path. Expo's File `.uri`
* is a `file://` URI, so we strip the scheme.
*/
function modelPathFor(id: ModelId): string {
const file = new File(Paths.document, 'models', MODELS[id].ggml);
// e.g. 'file:///data/.../Documents/models/ggml-tiny.en.bin' -> '/data/.../ggml-tiny.en.bin'
return file.uri.replace(/^file:\/\//, '');
}
export const engine: TranscriptionEngine = {
platform: 'native',
async capabilities(): Promise<EngineCapabilities> {
// CoreML (iOS Neural Engine) and Metal acceleration are decided at the
// native build/runtime level (e.g. ContextOptions.useGpu / useCoreMLIos),
// not something we can reliably probe from JS here. Default to the
// conservative 'cpu' backend for model gating; an accelerated build still
// runs fine, it just won't be recommended a larger model by this heuristic.
const backend = 'cpu' as const;
return {
backend,
// whisper.rn supports realtime/live-mic transcription on device.
supportsLiveMic: true,
maxRecommendedModel: recommendModel({ backend }),
};
},
async loadModel(modelId: ModelId): Promise<void> {
if (loaded.has(modelId)) return; // idempotent
const filePath = modelPathFor(modelId);
// initWhisper rejects if the file is missing/corrupt. See the TODO in
// modelPathFor: until the download UI exists, the .bin must already be there.
const ctx = await initWhisper({ filePath });
loaded.set(modelId, ctx);
},
isModelLoaded(modelId: ModelId): boolean {
return loaded.has(modelId);
},
async transcribeChunk(
audio: PcmAudio,
opts: TranscribeOptions,
): Promise<Segment[]> {
const ctx = loaded.get(opts.modelId);
if (!ctx) {
throw new Error(
`Model "${opts.modelId}" is not loaded; call loadModel() first.`,
);
}
// Pass the raw float32 PCM as an ArrayBuffer. We hand over a tight copy of
// exactly this chunk's samples: `audio.samples` may be a subarray VIEW into
// a larger buffer (the pipeline slices with subarray), so `.buffer` alone
// would expose the whole backing buffer. `.slice()` yields a standalone
// Float32Array whose `.buffer` is exactly these samples.
const pcm = audio.samples.slice();
// transcribeData returns { stop, promise }; await the promise.
const { promise } = ctx.transcribeData(pcm.buffer, {
// language undefined => whisper.rn auto-detects ('auto').
language: opts.language,
translate: opts.translate ?? false,
});
const result = await promise;
// Map whisper.cpp segments (centiseconds) -> our Segment[] (chunk-local s).
const segments: Segment[] = [];
for (const s of result.segments) {
const text = s.text.trim();
if (text.length === 0) continue;
segments.push({
start: s.t0 / 100, // centiseconds -> seconds
end: s.t1 / 100,
text,
});
}
return segments;
},
};
+3
View File
@@ -0,0 +1,3 @@
// Base resolver for TypeScript. Metro picks engineImpl.web.ts / engineImpl.native.ts
// by platform extension at build time; tsc resolves this file (defaults to web).
export { engine } from './engineImpl.web';
+138
View File
@@ -0,0 +1,138 @@
// WEB-ONLY transcription engine, backed by transformers.js (@huggingface/
// transformers) running Whisper in the browser (WebGPU when available, else
// multi-threaded WASM).
//
// WHY WE LOAD IT FROM A CDN AT RUNTIME (not a static import):
// transformers.js depends on onnxruntime-web, which uses a *computed* dynamic
// import (`import(/*webpackIgnore*/ a)`) and ships WASM — Metro (Expo's web
// bundler) cannot statically bundle either and fails the build. So we never let
// Metro see the package: we load the ESM build from a CDN at runtime via a
// dynamic import hidden behind `new Function` (so Metro's static analyzer can't
// trip over it). The browser resolves it natively. This keeps the JS bundle
// small and is the standard way to run transformers.js under Metro/Expo web.
//
// NOTE: the page must be cross-origin isolated (COOP + COEP) for multi-threaded
// WASM; we use COEP: credentialless so the CDN script and the Hugging Face model
// files (CORS-enabled) load without requiring CORP headers. See docker/nginx.conf.
//
// This module is web-only and is NEVER imported by any vitest test.
import { MODELS, recommendModel } from '../models/catalog';
import type { Backend, ModelId, PcmAudio, Segment, TranscribeOptions } from '../types';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
// Pin the transformers.js version we load at runtime.
const TRANSFORMERS_CDN = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0';
// `new Function` hides the dynamic import() specifier from Metro's bundler so it
// never tries to resolve/transform transformers.js or onnxruntime-web.
const runtimeImport = new Function('u', 'return import(u)') as (u: string) => Promise<TransformersModule>;
// Minimal structural types for the bits of transformers.js we use.
interface AsrChunk {
timestamp: [number, number | null];
text: string;
}
interface AsrOutput {
text: string;
chunks?: AsrChunk[];
}
type AsrPipeline = (audio: Float32Array, opts: Record<string, unknown>) => Promise<AsrOutput>;
interface PipelineOptions {
device?: string;
dtype?: string;
progress_callback?: (e: { status?: string; progress?: number }) => void;
}
interface TransformersModule {
pipeline: (task: string, model: string, opts?: PipelineOptions) => Promise<AsrPipeline>;
env: { allowLocalModels: boolean };
}
let libPromise: Promise<TransformersModule> | null = null;
async function lib(): Promise<TransformersModule> {
if (!libPromise) {
libPromise = runtimeImport(TRANSFORMERS_CDN).then((m) => {
// Never read models off the local filesystem in the browser.
m.env.allowLocalModels = false;
return m;
});
}
return libPromise;
}
/** Loaded ASR pipelines, keyed by model id. */
const loaded = new Map<ModelId, AsrPipeline>();
let cachedBackend: Backend | undefined;
async function detectWebGpu(): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false;
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
const adapter = await gpu?.requestAdapter();
return adapter != null;
} catch {
return false;
}
}
async function resolveBackend(): Promise<Backend> {
if (cachedBackend) return cachedBackend;
cachedBackend = (await detectWebGpu()) ? 'webgpu' : 'wasm';
return cachedBackend;
}
export const engine: TranscriptionEngine = {
platform: 'web',
async capabilities(): Promise<EngineCapabilities> {
const backend = await resolveBackend();
return {
backend,
supportsLiveMic: false,
maxRecommendedModel: recommendModel({ backend }),
};
},
async loadModel(modelId: ModelId, onProgress?: (p: number) => void): Promise<void> {
if (loaded.has(modelId)) return;
const { pipeline } = await lib();
const webgpu = (await resolveBackend()) === 'webgpu';
const asr = await pipeline('automatic-speech-recognition', MODELS[modelId].webRepo, {
// WebGPU + fp16 when available; otherwise 8-bit weights on WASM, which
// stays small to download and runs acceptably on a plain CPU.
device: webgpu ? 'webgpu' : 'wasm',
dtype: webgpu ? 'fp16' : 'q8',
progress_callback: (e) => {
if (e.status === 'progress' && e.progress != null) onProgress?.(e.progress / 100);
},
});
loaded.set(modelId, asr);
},
isModelLoaded(modelId: ModelId): boolean {
return loaded.has(modelId);
},
async transcribeChunk(audio: PcmAudio, opts: TranscribeOptions): Promise<Segment[]> {
const asr = loaded.get(opts.modelId);
if (!asr) throw new Error(`Model "${opts.modelId}" is not loaded; call loadModel() first.`);
const out = await asr(audio.samples, {
return_timestamps: true,
// One window at a time; 30s matches Whisper's frame so it won't re-chunk.
chunk_length_s: 30,
language: opts.language,
task: opts.translate ? 'translate' : 'transcribe',
});
const segments: Segment[] = [];
for (const c of out.chunks ?? []) {
const [start, end] = c.timestamp;
if (start == null) continue;
const text = c.text.trim();
if (text.length === 0) continue;
segments.push({ start, end: end ?? start, text });
}
return segments;
},
};
+13
View File
@@ -0,0 +1,13 @@
// Public entry point for the transcription engine.
//
// `./engineImpl` is resolved by Metro to engineImpl.web.ts or
// engineImpl.native.ts by platform extension; the base engineImpl.ts re-export
// (web) is what TypeScript resolves for typechecking. Consumers call
// getEngine() and stay platform-agnostic.
import { engine } from './engineImpl';
/** Return the platform-resolved transcription engine. */
export const getEngine = () => engine;
export * from './engine';
export * from './pipeline';
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect } from 'vitest';
import { transcribe } from './pipeline';
import type { TranscribeProgress } from './pipeline';
import type { EngineCapabilities, TranscriptionEngine } from './engine';
import type {
ModelId,
PcmAudio,
Segment,
TranscribeOptions,
} from '../types';
import { WHISPER_SAMPLE_RATE } from '../types';
import { planChunks } from './chunking';
/**
* A fully in-memory fake engine. It records calls and returns exactly one
* segment per chunk so the test can assert how the pipeline offsets/stitches
* chunk-local times into absolute time.
*/
class FakeEngine implements TranscriptionEngine {
readonly platform = 'web' as const;
/** Toggles whether loadModel is exercised. */
loaded: boolean;
loadModelCalls = 0;
transcribeCalls = 0;
lastLoadProgress: number[] = [];
constructor(opts: { loaded: boolean } = { loaded: true }) {
this.loaded = opts.loaded;
}
async capabilities(): Promise<EngineCapabilities> {
return {
backend: 'wasm',
supportsLiveMic: false,
maxRecommendedModel: 'tiny.en',
};
}
async loadModel(
_modelId: ModelId,
onProgress?: (p: number) => void,
): Promise<void> {
this.loadModelCalls++;
// Report a couple of fractional progress ticks then complete.
onProgress?.(0.5);
onProgress?.(1);
this.lastLoadProgress.push(1);
this.loaded = true;
}
isModelLoaded(_modelId: ModelId): boolean {
return this.loaded;
}
async transcribeChunk(
audio: PcmAudio,
_opts: TranscribeOptions,
): Promise<Segment[]> {
// One chunk-local segment per chunk, labelled by call index so we can
// verify ordering and absolute-time offsetting after stitching. We place it
// squarely in the chunk's interior (mid-window) so the stitcher's
// mid-overlap cut keeps exactly one copy per chunk rather than dropping a
// boundary-hugging segment. Times are chunk-LOCAL (the engine contract).
const idx = this.transcribeCalls++;
const lenSec = audio.samples.length / audio.sampleRate;
const mid = lenSec / 2;
return [{ start: mid, end: mid + 0.5, text: `chunk${idx}` }];
}
}
/** Build a silent PcmAudio of `seconds` length at 16 kHz. */
function makeAudio(seconds: number): PcmAudio {
return {
sampleRate: WHISPER_SAMPLE_RATE,
samples: new Float32Array(Math.round(seconds * WHISPER_SAMPLE_RATE)),
};
}
const OPTIONS: TranscribeOptions = { modelId: 'tiny.en' };
describe('transcribe pipeline', () => {
it('returns stitched, absolute-time segments (one per chunk)', async () => {
// 60s with the default 28s window / 2s overlap -> multiple chunks.
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const plan = planChunks(audio.samples.length, {
windowSec: 28,
overlapSec: 2,
sampleRate: WHISPER_SAMPLE_RATE,
});
const segments = await transcribe({ audio, options: OPTIONS, engine });
// One segment per chunk survives stitching (each is well inside its window).
expect(engine.transcribeCalls).toBe(plan.length);
expect(segments).toHaveLength(plan.length);
// Each segment was offset by its chunk's media start time. The fake emits a
// chunk-local segment at the window midpoint; after offsetting, segment i
// should start at chunk.startSec + (window/2), i.e. strictly inside chunk i.
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const chunk = plan[i]!;
const localMid = (chunk.endSec - chunk.startSec) / 2;
expect(seg.text).toBe(`chunk${i}`);
expect(seg.start).toBeCloseTo(chunk.startSec + localMid, 5);
}
// Output must be sorted ascending by start.
for (let i = 1; i < segments.length; i++) {
expect(segments[i]!.start).toBeGreaterThanOrEqual(segments[i - 1]!.start);
}
});
it('reports increasing transcribing progress ending at 1', async () => {
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const events: TranscribeProgress[] = [];
await transcribe({
audio,
options: OPTIONS,
engine,
onProgress: (p) => events.push(p),
});
const transcribing = events.filter((e) => e.stage === 'transcribing');
expect(transcribing.length).toBeGreaterThan(0);
// Monotonically non-decreasing, strictly bounded in (0, 1].
for (let i = 0; i < transcribing.length; i++) {
const e = transcribing[i]!;
expect(e.progress).toBeGreaterThan(0);
expect(e.progress).toBeLessThanOrEqual(1);
if (i > 0) {
expect(e.progress).toBeGreaterThan(transcribing[i - 1]!.progress);
}
}
// Final transcribing event hits exactly 1.
expect(transcribing[transcribing.length - 1]!.progress).toBe(1);
// The growing partial should reach the final segment count on the last tick.
const lastPartial = transcribing[transcribing.length - 1]!.partial;
expect(lastPartial.length).toBeGreaterThan(0);
});
it('calls loadModel (with load progress) when the model is not loaded', async () => {
const audio = makeAudio(10); // single chunk is fine here
const engine = new FakeEngine({ loaded: false });
const loadingEvents: TranscribeProgress[] = [];
await transcribe({
audio,
options: OPTIONS,
engine,
onProgress: (p) => {
if (p.stage === 'loading') loadingEvents.push(p);
},
});
expect(engine.loadModelCalls).toBe(1);
// Loading events have an empty partial and a fraction in [0, 1].
expect(loadingEvents.length).toBeGreaterThan(0);
for (const e of loadingEvents) {
expect(e.partial).toEqual([]);
expect(e.progress).toBeGreaterThanOrEqual(0);
expect(e.progress).toBeLessThanOrEqual(1);
}
// Last loading tick reaches 1.
expect(loadingEvents[loadingEvents.length - 1]!.progress).toBe(1);
});
it('does NOT call loadModel when the model is already loaded', async () => {
const audio = makeAudio(10);
const engine = new FakeEngine({ loaded: true });
await transcribe({ audio, options: OPTIONS, engine });
expect(engine.loadModelCalls).toBe(0);
});
it('throws AbortError when the signal is already aborted', async () => {
const audio = makeAudio(60);
const engine = new FakeEngine({ loaded: true });
const controller = new AbortController();
controller.abort();
await expect(
transcribe({
audio,
options: OPTIONS,
engine,
signal: controller.signal,
}),
).rejects.toMatchObject({ name: 'AbortError' });
// Nothing was transcribed because we aborted before the first chunk.
expect(engine.transcribeCalls).toBe(0);
});
});
+107
View File
@@ -0,0 +1,107 @@
// The shared, pure orchestration pipeline. Given a decoded PcmAudio and a
// TranscriptionEngine, it:
// 1. loads the model if needed (reporting load progress),
// 2. plans overlapping windows over the audio (chunking.ts),
// 3. transcribes each window via the engine (chunk-local times),
// 4. stitches the per-chunk results into one absolute-time transcript,
// emitting a growing `partial` after every chunk for live UI.
//
// This module is intentionally free of any platform/native imports so it can be
// exercised end-to-end in vitest with a fake engine (see pipeline.test.ts).
import { WHISPER_SAMPLE_RATE } from '../types';
import type { PcmAudio, Segment, TranscribeOptions } from '../types';
import { planChunks } from './chunking';
import { stitchSegments } from './stitch';
import type { TranscriptionEngine } from './engine';
/** Progress event emitted while transcribing. */
export interface TranscribeProgress {
/** 'loading' while the model downloads/initializes, then 'transcribing'. */
stage: 'loading' | 'transcribing';
/** Fraction in [0, 1] for the current stage. */
progress: number;
/**
* Best-effort transcript so far, in absolute time. Empty during 'loading';
* grows after each chunk during 'transcribing'.
*/
partial: Segment[];
}
export interface TranscribeParams {
audio: PcmAudio;
options: TranscribeOptions;
engine: TranscriptionEngine;
onProgress?: (p: TranscribeProgress) => void;
/** Abort mid-run; throws DOMException('Aborted','AbortError'). */
signal?: AbortSignal;
/** Window length in seconds (default 28; Whisper attends to ~30s). */
windowSec?: number;
/** Overlap between adjacent windows in seconds (default 2). */
overlapSec?: number;
}
/** Default window/overlap, matching planChunks/stitchSegments conventions. */
const DEFAULT_WINDOW_SEC = 28;
const DEFAULT_OVERLAP_SEC = 2;
/**
* Transcribe a full PcmAudio into absolute-time segments. See module comment.
*/
export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
const {
audio,
options,
engine,
onProgress,
signal,
windowSec = DEFAULT_WINDOW_SEC,
overlapSec = DEFAULT_OVERLAP_SEC,
} = params;
const { modelId } = options;
// 1. Load the model if it isn't already resident. Load progress maps onto the
// 'loading' stage; `partial` is empty because we have no segments yet.
if (!engine.isModelLoaded(modelId)) {
await engine.loadModel(modelId, (p) =>
onProgress?.({ stage: 'loading', progress: p, partial: [] }),
);
}
// 2. Plan overlapping windows. We pin the sample rate to Whisper's 16 kHz;
// PcmAudio is guaranteed to already be at that rate by its type.
const plan = planChunks(audio.samples.length, {
windowSec,
overlapSec,
sampleRate: WHISPER_SAMPLE_RATE,
});
// 3. Transcribe each window. `perChunk[i]` holds chunk-local segments for
// plan[i]; the stitcher offsets and merges them.
const perChunk: Segment[][] = [];
for (let i = 0; i < plan.length; i++) {
// Cooperative cancellation: check before each (potentially long) chunk.
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const chunk = plan[i]!;
// subarray is a zero-copy view; the engine only reads it, never mutates.
const chunkAudio: PcmAudio = {
sampleRate: WHISPER_SAMPLE_RATE,
samples: audio.samples.subarray(chunk.startSample, chunk.endSample),
};
const segs = await engine.transcribeChunk(chunkAudio, options);
perChunk.push(segs);
// Emit a growing absolute-time partial: stitch everything decoded so far.
onProgress?.({
stage: 'transcribing',
progress: (i + 1) / plan.length,
partial: stitchSegments(perChunk, plan.slice(0, i + 1), { overlapSec }),
});
}
// 4. Final stitch over the full plan.
return stitchSegments(perChunk, plan, { overlapSec });
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { stitchSegments } from './stitch';
import { planChunks } from './chunking';
import type { Segment } from '../types';
const SR = 16000;
describe('stitchSegments', () => {
it('passes a single chunk through unchanged (offset 0)', () => {
const plan = planChunks(10 * SR, { sampleRate: SR });
const segs: Segment[] = [
{ start: 0, end: 2, text: 'hello' },
{ start: 2, end: 4, text: 'world' },
];
const out = stitchSegments([segs], plan);
expect(out.map((s) => s.text)).toEqual(['hello', 'world']);
expect(out[0]).toMatchObject({ start: 0, end: 2 });
});
it('offsets chunk-local times into absolute time', () => {
const plan = planChunks(60 * SR, { windowSec: 28, overlapSec: 2, sampleRate: SR });
expect(plan.length).toBeGreaterThanOrEqual(2);
const perChunk: Segment[][] = plan.map((_, i) => [{ start: 1, end: 2, text: `c${i}` }]);
const out = stitchSegments(perChunk, plan);
expect(out.find((s) => s.text === 'c0')!.start).toBeCloseTo(1);
const c1 = plan[1]!;
expect(out.find((s) => s.text === 'c1')!.start).toBeCloseTo(c1.startSec + 1);
});
it('keeps a word duplicated across the overlap only once', () => {
const plan = planChunks(60 * SR, { windowSec: 28, overlapSec: 2, sampleRate: SR });
const c0 = plan[0]!;
const c1 = plan[1]!;
const seamAbs = c1.startSec + 0.5; // inside the 2s overlap, below the mid cut
const perChunk: Segment[][] = plan.map(() => []);
perChunk[0] = [{ start: seamAbs - c0.startSec - 0.25, end: seamAbs - c0.startSec + 0.25, text: 'seam' }];
perChunk[1] = [{ start: seamAbs - c1.startSec - 0.25, end: seamAbs - c1.startSec + 0.25, text: 'seam' }];
const out = stitchSegments(perChunk, plan, { overlapSec: 2 });
expect(out.filter((s) => s.text === 'seam')).toHaveLength(1);
});
it('produces monotonic, non-overlapping output', () => {
const plan = planChunks(10 * SR, { sampleRate: SR });
const segs: Segment[] = [
{ start: 0, end: 3, text: 'a' },
{ start: 2.5, end: 5, text: 'b' },
];
const out = stitchSegments([segs], plan);
for (let i = 1; i < out.length; i++) {
expect(out[i]!.start).toBeGreaterThanOrEqual(out[i - 1]!.start);
expect(out[i - 1]!.end).toBeLessThanOrEqual(out[i]!.start + 1e-9);
}
});
it('tolerates missing/empty chunk entries', () => {
const plan = planChunks(60 * SR, { sampleRate: SR });
const out = stitchSegments([], plan);
expect(out).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { Segment } from '../types';
import type { ChunkPlanItem } from './chunking';
export interface StitchOptions {
/** Overlap (seconds) between adjacent chunks; should match planChunks. */
overlapSec?: number;
}
/**
* Merge per-chunk, CHUNK-LOCAL segment lists into one absolute-time transcript.
*
* `perChunk[i]` holds the segments engine returned for chunk `i`, with times
* relative to that chunk's start. We offset each by `plan[i].startSec`, then at
* every seam choose a cut point in the middle of the overlap and assign each
* segment to a chunk by its midpoint: the earlier chunk owns everything before
* the cut, the later chunk everything after. Because Whisper truncates and
* occasionally hallucinates at a window edge, a mid-overlap cut drops the
* duplicated/garbled boundary copy and keeps the clean one. Finally we sort and
* clamp so the output is monotonic and non-overlapping (nice for the editor).
*/
export function stitchSegments(
perChunk: Segment[][],
plan: ChunkPlanItem[],
opts: StitchOptions = {},
): Segment[] {
const overlapSec = opts.overlapSec ?? 2;
const out: Segment[] = [];
for (let i = 0; i < plan.length; i++) {
const chunk = plan[i];
const segs = perChunk[i];
if (!chunk || !segs) continue;
const offset = chunk.startSec;
// Below the cut shared with the previous chunk → that chunk already owns it.
const lowerCut = i > 0 ? chunk.startSec + overlapSec / 2 : -Infinity;
// At/after the cut shared with the next chunk → the next chunk will own it.
const next = plan[i + 1];
const upperCut = next ? next.startSec + overlapSec / 2 : Infinity;
for (const s of segs) {
const start = s.start + offset;
const end = s.end + offset;
const mid = (start + end) / 2;
if (mid < lowerCut || mid >= upperCut) continue;
out.push({ ...s, start, end });
}
}
out.sort((a, b) => a.start - b.start || a.end - b.end);
// Clamp so each segment ends no later than the next one starts.
for (let i = 0; i < out.length - 1; i++) {
const cur = out[i]!;
const nxt = out[i + 1]!;
if (cur.end > nxt.start) cur.end = nxt.start;
}
return out;
}
+39
View File
@@ -0,0 +1,39 @@
// Ambient module shim for whisper.rn.
//
// Why this exists: whisper.rn@0.6.0 ships an `exports` map containing only a
// `"./*"` subpath pattern and NO `"."` (root) entry. Under this project's
// TypeScript settings (moduleResolution: 'bundler' with the 'react-native'
// custom condition, inherited from expo/tsconfig.base), the bare specifier
// `import ... from 'whisper.rn'` cannot be resolved by tsc:
// "Export specifier '.' does not exist in package.json scope".
// Metro/Babel resolve it fine at runtime (they honor the package's `main` /
// `react-native` fields), so this is purely a *type*-resolution gap.
//
// This shim declares the `'whisper.rn'` module and aliases the library's real,
// shipped declarations (lib/typescript/index.d.ts) through an inline `import()`
// type that points at a relative path, bypassing the `exports` map.
//
// IMPORTANT: this file must stay a GLOBAL/ambient script (no top-level `import`
// or `export` statements) so that `declare module 'whisper.rn'` *creates* the
// module rather than *augmenting* a non-resolvable one. We therefore reference
// the upstream types via inline `import('...')` type expressions. The relative
// path is from this file (src/lib/transcription) up to the repo root, then into
// node_modules. It changes no runtime behavior and touches no shared files.
// Remove this whole file once whisper.rn publishes a `"."` export entry.
declare module 'whisper.rn' {
type _Upstream = typeof import('../../../node_modules/whisper.rn/lib/typescript/index');
export type WhisperContext =
import('../../../node_modules/whisper.rn/lib/typescript/index').WhisperContext;
export type ContextOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').ContextOptions;
export type TranscribeOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeOptions;
export type TranscribeResult =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeResult;
export type TranscribeFileOptions =
import('../../../node_modules/whisper.rn/lib/typescript/index').TranscribeFileOptions;
export const initWhisper: _Upstream['initWhisper'];
}
+49
View File
@@ -0,0 +1,49 @@
// Core domain types shared across the whole transcription engine.
// Kept tiny and dependency-free so every pure module can import from here
// without pulling in React Native, Expo, or any platform code.
/** Whisper always works at 16 kHz mono. */
export const WHISPER_SAMPLE_RATE = 16000 as const;
/**
* A contiguous transcribed segment. Times are in SECONDS and, once a media
* file has been fully processed, ABSOLUTE to the whole media (post-stitch).
* Engine implementations emit chunk-local times (0-based per chunk); the pure
* pipeline offsets and stitches them into absolute time.
*/
export interface Segment {
/** Inclusive start time in seconds. */
start: number;
/** Exclusive end time in seconds. */
end: number;
/** Recognized text for this segment (already trimmed). */
text: string;
/** Optional confidence in [0,1], derived from avg token logprob when available. */
confidence?: number;
}
/** Decoded audio ready for Whisper: 16 kHz, mono, normalized to [-1, 1]. */
export interface PcmAudio {
readonly sampleRate: typeof WHISPER_SAMPLE_RATE;
readonly samples: Float32Array;
}
/** The Whisper model variants we expose. `.en` = English-only (smaller/faster). */
export type ModelId =
| 'tiny.en'
| 'tiny'
| 'base.en'
| 'base'
| 'small.en'
| 'small';
export interface TranscribeOptions {
modelId: ModelId;
/** ISO language code; undefined => auto-detect (multilingual models only). */
language?: string;
/** Translate the result to English (multilingual models only). */
translate?: boolean;
}
/** Which compute backend an engine resolved to, for UX and model gating. */
export type Backend = 'coreml' | 'metal' | 'cpu' | 'webgpu' | 'wasm';
+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();
},
}));
+8
View File
@@ -0,0 +1,8 @@
// Ambient declarations so `tsc` understands CSS imports that Metro handles
// natively (the Expo template uses a global stylesheet + CSS modules on web).
declare module '*.css';
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}