Build Wisp: on-device transcription studio (web + native, one codebase)
Private, offline speech-to-text that runs Whisper on the user's own device — free, no account, no per-minute fees. Replaces Otter.ai / Rev. - Pure, tested engine: chunking, overlap timestamp-stitching, exports (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests). - Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch. - Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated. - UI: library + search, import, live-progress transcription, synced click-to-seek editor, multi-format export; model picker + privacy in settings. - Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM; Docker (nginx) image + Traefik compose for wisp.briggen.dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-9
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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
@@ -1,98 +1,182 @@
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Link, useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { AnimatedIcon } from '@/components/animated-icon';
|
||||
import { HintRow } from '@/components/hint-row';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { formatClock } from '@/lib/format';
|
||||
import { MODELS } from '@/lib/models/catalog';
|
||||
import { pickAudio } from '@/lib/pickAudio';
|
||||
import type { TranscriptMeta } from '@/lib/db';
|
||||
import { useTranscribe } from '@/stores/transcribeStore';
|
||||
import { useTranscripts } from '@/stores/transcriptsStore';
|
||||
|
||||
function getDevMenuHint() {
|
||||
if (Platform.OS === 'web') {
|
||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
export default function LibraryScreen() {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { items, loading, query, setQuery, refresh, remove } = useTranscripts();
|
||||
const job = useTranscribe();
|
||||
|
||||
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]),
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const busy = job.status === 'loading' || job.status === 'transcribing';
|
||||
|
||||
const onNew = useCallback(async () => {
|
||||
if (busy) return;
|
||||
const picked = await pickAudio();
|
||||
if (!picked) return;
|
||||
const id = await useTranscribe.getState().start(picked, { title: picked.name });
|
||||
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
|
||||
}, [busy, router]);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
<ThemedView style={styles.fill}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.flex}>
|
||||
<ThemedText type="subtitle">Wisp</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Private transcription — runs on your device, nothing uploaded.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable hitSlop={10}>
|
||||
<ThemedText type="link" themeColor="textSecondary">
|
||||
⚙ Settings
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={onNew}
|
||||
disabled={busy}
|
||||
style={({ pressed }) => [
|
||||
styles.newButton,
|
||||
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
|
||||
]}>
|
||||
<ThemedText style={styles.newButtonText}>+ New transcription</ThemedText>
|
||||
</Pressable>
|
||||
|
||||
{busy && <ActiveJob />}
|
||||
{job.status === 'error' && (
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<ThemedText type="smallBold">Transcription failed</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{job.error}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={(t) => void setQuery(t)}
|
||||
placeholder="Search transcripts…"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
|
||||
/>
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator style={styles.pad} />
|
||||
) : items.length === 0 ? (
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
|
||||
{query ? 'No matches.' : 'No transcripts yet. Pick an audio or video file to begin.'}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</SafeAreaView>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
<TranscriptRow key={t.id} item={t} onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveJob() {
|
||||
const { stage, progress, partial, cancel } = useTranscribe();
|
||||
const pct = Math.round(progress * 100);
|
||||
return (
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold">
|
||||
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
|
||||
</ThemedText>
|
||||
<Pressable onPress={cancel} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ProgressBar value={progress} />
|
||||
{partial.length > 0 && (
|
||||
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
|
||||
{partial.map((s) => s.text).join(' ')}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.bar, { width: `${Math.max(2, Math.min(100, value * 100))}%` }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpen: () => void; onDelete: () => void }) {
|
||||
const date = new Date(item.createdAt).toLocaleDateString();
|
||||
return (
|
||||
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
<Pressable onPress={onDelete} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">✕</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{date} · {formatClock(item.durationSec)} · {MODELS[item.modelId]?.label ?? item.modelId} · {item.segmentCount} segments
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
fill: { flex: 1 },
|
||||
content: {
|
||||
padding: Spacing.three,
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
},
|
||||
flex: { flex: 1 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
|
||||
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
|
||||
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
|
||||
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
|
||||
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
|
||||
pressed: { opacity: 0.7 },
|
||||
});
|
||||
|
||||
@@ -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'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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Native (iOS/Android) resolution of the platform decoder. Metro picks this on
|
||||
// native builds.
|
||||
export { decoder } from './decode.native';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Web resolution of the platform decoder. Metro picks this on web builds.
|
||||
export { decoder } from './decode.web';
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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'];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,120 @@
|
||||
// Drives a single active transcription: decode -> run the pipeline on-device ->
|
||||
// save to the local library. Holds live progress + the partial transcript so
|
||||
// the UI can "fill in" as chunks complete, plus an object URL for playback.
|
||||
import { Platform } from 'react-native';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getDecoder, type AudioFileInput } from '@/lib/audio';
|
||||
import { getRepo } from '@/lib/db';
|
||||
import { DEFAULT_MODEL } from '@/lib/models/catalog';
|
||||
import { getEngine } from '@/lib/transcription';
|
||||
import { transcribe } from '@/lib/transcription/pipeline';
|
||||
import type { ModelId, Segment } from '@/lib/types';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'transcribing' | 'done' | 'error';
|
||||
|
||||
interface StartOptions {
|
||||
title?: string;
|
||||
language?: string;
|
||||
translate?: boolean;
|
||||
}
|
||||
|
||||
interface TranscribeState {
|
||||
status: Status;
|
||||
stage?: 'loading' | 'transcribing';
|
||||
progress: number;
|
||||
partial: Segment[];
|
||||
error?: string;
|
||||
modelId: ModelId;
|
||||
lastTranscriptId?: string;
|
||||
/** Playable source for the just-finished audio (object URL on web, uri on native). */
|
||||
audioUrl?: string;
|
||||
_abort?: AbortController;
|
||||
|
||||
setModel: (m: ModelId) => void;
|
||||
start: (input: AudioFileInput, opts?: StartOptions) => Promise<string | undefined>;
|
||||
cancel: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function makeAudioUrl(input: AudioFileInput): string | undefined {
|
||||
if (Platform.OS === 'web' && input.data) {
|
||||
return URL.createObjectURL(new Blob([input.data]));
|
||||
}
|
||||
return input.uri;
|
||||
}
|
||||
|
||||
export const useTranscribe = create<TranscribeState>((set, get) => ({
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
partial: [],
|
||||
modelId: DEFAULT_MODEL,
|
||||
|
||||
setModel: (m) => set({ modelId: m }),
|
||||
|
||||
start: async (input, opts) => {
|
||||
// Clean up any previous object URL.
|
||||
const prev = get().audioUrl;
|
||||
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
|
||||
const abort = new AbortController();
|
||||
set({
|
||||
status: 'loading',
|
||||
stage: 'loading',
|
||||
progress: 0,
|
||||
partial: [],
|
||||
error: undefined,
|
||||
lastTranscriptId: undefined,
|
||||
audioUrl: makeAudioUrl(input),
|
||||
_abort: abort,
|
||||
});
|
||||
|
||||
const { modelId } = get();
|
||||
try {
|
||||
const pcm = await getDecoder().decode(input);
|
||||
const segments = await transcribe({
|
||||
audio: pcm,
|
||||
options: { modelId, language: opts?.language, translate: opts?.translate },
|
||||
engine: getEngine(),
|
||||
signal: abort.signal,
|
||||
onProgress: (p) =>
|
||||
set({ stage: p.stage, progress: p.progress, partial: p.partial, status: p.stage }),
|
||||
});
|
||||
|
||||
const durationSec = pcm.samples.length / pcm.sampleRate;
|
||||
const saved = await getRepo().create({
|
||||
title: opts?.title?.trim() || defaultTitle(),
|
||||
durationSec,
|
||||
modelId,
|
||||
language: opts?.language,
|
||||
segments,
|
||||
});
|
||||
|
||||
set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id });
|
||||
return saved.id;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
set({ status: 'idle', progress: 0 });
|
||||
return undefined;
|
||||
}
|
||||
set({ status: 'error', error: err instanceof Error ? err.message : String(err) });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
cancel: () => {
|
||||
get()._abort?.abort();
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const prev = get().audioUrl;
|
||||
if (prev && Platform.OS === 'web' && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
set({ status: 'idle', stage: undefined, progress: 0, partial: [], error: undefined, audioUrl: undefined });
|
||||
},
|
||||
}));
|
||||
|
||||
function defaultTitle(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `Transcript ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Library state: the saved transcripts list + search, backed by the local
|
||||
// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only —
|
||||
// the record of truth lives in the repo.
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getRepo, type TranscriptMeta } from '@/lib/db';
|
||||
|
||||
interface TranscriptsState {
|
||||
items: TranscriptMeta[];
|
||||
loading: boolean;
|
||||
query: string;
|
||||
refresh: () => Promise<void>;
|
||||
setQuery: (q: string) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
rename: (id: string, title: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
query: '',
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const repo = getRepo();
|
||||
const q = get().query.trim();
|
||||
const items = q ? await repo.search(q) : await repo.list();
|
||||
set({ items });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setQuery: async (q) => {
|
||||
set({ query: q });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
remove: async (id) => {
|
||||
await getRepo().remove(id);
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
rename: async (id, title) => {
|
||||
await getRepo().update(id, { title });
|
||||
await get().refresh();
|
||||
},
|
||||
}));
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user