Transcription progress UX + use WASM on mobile web
Two issues behind "it just shows downloaded 100% / loads forever / crashes
after a couple seconds":
1. Progress was invisible during transcription. The 'transcribing' stage
only fired AFTER the first (slow) chunk, so the UI sat at "Loading
model… 100%" through the entire first inference and looked frozen.
- pipeline now emits a transcribing kickoff (progress 0) BEFORE the
first chunk and carries chunkIndex/chunkCount on every event.
- transcribeStore threads those through; the Library job card shows a
spinner, "Transcribing… N%", and "part i of N" so a long file's
progress is legible and obviously advancing.
2. Web crash on mobile. We picked WebGPU + fp16 whenever navigator.gpu
existed, but mobile WebGPU drivers crash on sustained fp16 Whisper
inference (transcribes a chunk, then the GPU process dies). Mobile web
now uses cross-origin-isolated multi-threaded WASM (slower but stable);
desktop keeps WebGPU.
Native's "loads forever" was the same missing-feedback problem — it was
grinding through chunks with no UI signal; the chunk counter now shows it.
tsc clean, 279 tests pass (pipeline progress test updated for the kickoff
event), web export OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -163,19 +163,31 @@ function FilterChip({ label, active, onPress }: { label: string; active: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActiveJob() {
|
function ActiveJob() {
|
||||||
const { stage, progress, partial, cancel } = useTranscribe();
|
const { stage, progress, partial, chunkIndex, chunkCount, cancel } = useTranscribe();
|
||||||
const pct = Math.round(progress * 100);
|
const pct = Math.round(progress * 100);
|
||||||
|
const transcribing = stage === 'transcribing';
|
||||||
|
// While transcribing, show "chunk i/N" so a long file's progress is legible
|
||||||
|
// and it's obvious work is happening between the (discrete) per-chunk updates.
|
||||||
|
const label = stage === 'loading' ? `Loading model… ${pct}%` : `Transcribing… ${pct}%`;
|
||||||
|
const sub =
|
||||||
|
transcribing && chunkCount
|
||||||
|
? `part ${Math.min((chunkIndex ?? 0) + (pct < 100 ? 1 : 0), chunkCount)} of ${chunkCount}`
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<ThemedView type="backgroundElement" style={styles.card}>
|
<ThemedView type="backgroundElement" style={styles.card}>
|
||||||
<View style={styles.rowBetween}>
|
<View style={styles.rowBetween}>
|
||||||
<ThemedText type="smallBold">
|
<View style={styles.jobTitleRow}>
|
||||||
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
|
<ActivityIndicator size="small" />
|
||||||
</ThemedText>
|
<ThemedText type="smallBold">{label}</ThemedText>
|
||||||
|
</View>
|
||||||
<Pressable onPress={cancel} hitSlop={8}>
|
<Pressable onPress={cancel} hitSlop={8}>
|
||||||
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
|
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ProgressBar value={progress} />
|
<ProgressBar value={progress} />
|
||||||
|
{sub && (
|
||||||
|
<ThemedText type="small" themeColor="textSecondary">{sub}</ThemedText>
|
||||||
|
)}
|
||||||
{partial.length > 0 && (
|
{partial.length > 0 && (
|
||||||
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
|
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
|
||||||
{partial.map((s) => s.text).join(' ')}
|
{partial.map((s) => s.text).join(' ')}
|
||||||
@@ -240,6 +252,7 @@ const styles = StyleSheet.create({
|
|||||||
captureText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
captureText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
||||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||||
|
jobTitleRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flex: 1 },
|
||||||
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
|
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
|
||||||
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: 999 },
|
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: 999 },
|
||||||
chipActive: { color: '#fff', fontWeight: '700' },
|
chipActive: { color: '#fff', fontWeight: '700' },
|
||||||
|
|||||||
@@ -64,8 +64,21 @@ async function lib(): Promise<TransformersModule> {
|
|||||||
const loaded = new Map<ModelId, AsrPipeline>();
|
const loaded = new Map<ModelId, AsrPipeline>();
|
||||||
let cachedBackend: Backend | undefined;
|
let cachedBackend: Backend | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile browsers expose WebGPU but their drivers are flaky for sustained ML
|
||||||
|
* inference — fp16 Whisper tends to crash the GPU process after a chunk or two.
|
||||||
|
* On mobile we deliberately use (cross-origin-isolated, multi-threaded) WASM,
|
||||||
|
* which is slower but reliable. Desktop keeps WebGPU.
|
||||||
|
*/
|
||||||
|
function isMobileWeb(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
return /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle/i.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
async function detectWebGpu(): Promise<boolean> {
|
async function detectWebGpu(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
if (isMobileWeb()) return false;
|
||||||
if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false;
|
if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false;
|
||||||
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
|
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
|
||||||
const adapter = await gpu?.requestAdapter();
|
const adapter = await gpu?.requestAdapter();
|
||||||
|
|||||||
@@ -127,16 +127,21 @@ describe('transcribe pipeline', () => {
|
|||||||
const transcribing = events.filter((e) => e.stage === 'transcribing');
|
const transcribing = events.filter((e) => e.stage === 'transcribing');
|
||||||
expect(transcribing.length).toBeGreaterThan(0);
|
expect(transcribing.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Monotonically non-decreasing, strictly bounded in (0, 1].
|
// The first transcribing event is a kickoff fired BEFORE the first chunk so
|
||||||
for (let i = 0; i < transcribing.length; i++) {
|
// the UI leaves the "loading" stage immediately: progress 0, no partial yet.
|
||||||
const e = transcribing[i]!;
|
expect(transcribing[0]!.progress).toBe(0);
|
||||||
expect(e.progress).toBeGreaterThan(0);
|
expect(transcribing[0]!.partial).toEqual([]);
|
||||||
|
|
||||||
|
// Every event carries the total chunk count; progress stays within [0, 1].
|
||||||
|
for (const e of transcribing) {
|
||||||
|
expect(e.chunkCount).toBeGreaterThan(0);
|
||||||
|
expect(e.progress).toBeGreaterThanOrEqual(0);
|
||||||
expect(e.progress).toBeLessThanOrEqual(1);
|
expect(e.progress).toBeLessThanOrEqual(1);
|
||||||
if (i > 0) {
|
|
||||||
expect(e.progress).toBeGreaterThan(transcribing[i - 1]!.progress);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Final transcribing event hits exactly 1.
|
// After the kickoff, per-chunk progress strictly increases and ends at 1.
|
||||||
|
for (let i = 2; i < transcribing.length; i++) {
|
||||||
|
expect(transcribing[i]!.progress).toBeGreaterThan(transcribing[i - 1]!.progress);
|
||||||
|
}
|
||||||
expect(transcribing[transcribing.length - 1]!.progress).toBe(1);
|
expect(transcribing[transcribing.length - 1]!.progress).toBe(1);
|
||||||
|
|
||||||
// The growing partial should reach the final segment count on the last tick.
|
// The growing partial should reach the final segment count on the last tick.
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface TranscribeProgress {
|
|||||||
* grows after each chunk during 'transcribing'.
|
* grows after each chunk during 'transcribing'.
|
||||||
*/
|
*/
|
||||||
partial: Segment[];
|
partial: Segment[];
|
||||||
|
/** Index of the chunk currently being / just transcribed (0-based). */
|
||||||
|
chunkIndex?: number;
|
||||||
|
/** Total number of chunks planned for this audio. */
|
||||||
|
chunkCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscribeParams {
|
export interface TranscribeParams {
|
||||||
@@ -80,6 +84,18 @@ export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
|
|||||||
// 3. Transcribe each window. `perChunk[i]` holds chunk-local segments for
|
// 3. Transcribe each window. `perChunk[i]` holds chunk-local segments for
|
||||||
// plan[i]; the stitcher offsets and merges them.
|
// plan[i]; the stitcher offsets and merges them.
|
||||||
const perChunk: Segment[][] = [];
|
const perChunk: Segment[][] = [];
|
||||||
|
|
||||||
|
// Flip to the 'transcribing' stage immediately, BEFORE the first (often slow)
|
||||||
|
// chunk runs — otherwise the UI sits at "Loading model… 100%" for the whole
|
||||||
|
// first inference and looks frozen/crashed. progress 0/N, no partial yet.
|
||||||
|
onProgress?.({
|
||||||
|
stage: 'transcribing',
|
||||||
|
progress: 0,
|
||||||
|
partial: [],
|
||||||
|
chunkIndex: 0,
|
||||||
|
chunkCount: plan.length,
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0; i < plan.length; i++) {
|
for (let i = 0; i < plan.length; i++) {
|
||||||
// Cooperative cancellation: check before each (potentially long) chunk.
|
// Cooperative cancellation: check before each (potentially long) chunk.
|
||||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||||
@@ -99,6 +115,8 @@ export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
|
|||||||
stage: 'transcribing',
|
stage: 'transcribing',
|
||||||
progress: (i + 1) / plan.length,
|
progress: (i + 1) / plan.length,
|
||||||
partial: stitchSegments(perChunk, plan.slice(0, i + 1), { overlapSec }),
|
partial: stitchSegments(perChunk, plan.slice(0, i + 1), { overlapSec }),
|
||||||
|
chunkIndex: i + 1,
|
||||||
|
chunkCount: plan.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface TranscribeState {
|
|||||||
stage?: 'loading' | 'transcribing';
|
stage?: 'loading' | 'transcribing';
|
||||||
progress: number;
|
progress: number;
|
||||||
partial: Segment[];
|
partial: Segment[];
|
||||||
|
/** During 'transcribing': how many chunks are done and how many total. */
|
||||||
|
chunkIndex?: number;
|
||||||
|
chunkCount?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
modelId: ModelId;
|
modelId: ModelId;
|
||||||
lastTranscriptId?: string;
|
lastTranscriptId?: string;
|
||||||
@@ -67,6 +70,8 @@ export const useTranscribe = create<TranscribeState>((set, get) => ({
|
|||||||
stage: 'loading',
|
stage: 'loading',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
partial: [],
|
partial: [],
|
||||||
|
chunkIndex: undefined,
|
||||||
|
chunkCount: undefined,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
lastTranscriptId: undefined,
|
lastTranscriptId: undefined,
|
||||||
audioUrl: makeAudioUrl(input),
|
audioUrl: makeAudioUrl(input),
|
||||||
@@ -82,7 +87,14 @@ export const useTranscribe = create<TranscribeState>((set, get) => ({
|
|||||||
engine: getEngine(),
|
engine: getEngine(),
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
onProgress: (p) =>
|
onProgress: (p) =>
|
||||||
set({ stage: p.stage, progress: p.progress, partial: p.partial, status: p.stage }),
|
set({
|
||||||
|
stage: p.stage,
|
||||||
|
progress: p.progress,
|
||||||
|
partial: p.partial,
|
||||||
|
status: p.stage,
|
||||||
|
chunkIndex: p.chunkIndex,
|
||||||
|
chunkCount: p.chunkCount,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const durationSec = pcm.samples.length / pcm.sampleRate;
|
const durationSec = pcm.samples.length / pcm.sampleRate;
|
||||||
|
|||||||
Reference in New Issue
Block a user