diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 04e39ef..e1ab416 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -163,19 +163,31 @@ function FilterChip({ label, active, onPress }: { label: string; active: boolean } function ActiveJob() { - const { stage, progress, partial, cancel } = useTranscribe(); + const { stage, progress, partial, chunkIndex, chunkCount, cancel } = useTranscribe(); 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 ( - - {stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}% - + + + {label} + Cancel + {sub && ( + {sub} + )} {partial.length > 0 && ( {partial.map((s) => s.text).join(' ')} @@ -240,6 +252,7 @@ const styles = StyleSheet.create({ captureText: { 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 }, + jobTitleRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flex: 1 }, filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: 999 }, chipActive: { color: '#fff', fontWeight: '700' }, diff --git a/src/lib/transcription/engineImpl.web.ts b/src/lib/transcription/engineImpl.web.ts index e0bfb4d..0099af6 100644 --- a/src/lib/transcription/engineImpl.web.ts +++ b/src/lib/transcription/engineImpl.web.ts @@ -64,8 +64,21 @@ async function lib(): Promise { const loaded = new Map(); 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 { try { + if (isMobileWeb()) return false; if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false; const gpu = (navigator as { gpu?: { requestAdapter(): Promise } }).gpu; const adapter = await gpu?.requestAdapter(); diff --git a/src/lib/transcription/pipeline.test.ts b/src/lib/transcription/pipeline.test.ts index 13c4b4c..65a70c6 100644 --- a/src/lib/transcription/pipeline.test.ts +++ b/src/lib/transcription/pipeline.test.ts @@ -127,16 +127,21 @@ describe('transcribe pipeline', () => { 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); + // The first transcribing event is a kickoff fired BEFORE the first chunk so + // the UI leaves the "loading" stage immediately: progress 0, no partial yet. + expect(transcribing[0]!.progress).toBe(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); - 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); // The growing partial should reach the final segment count on the last tick. diff --git a/src/lib/transcription/pipeline.ts b/src/lib/transcription/pipeline.ts index 21086e5..bd47104 100644 --- a/src/lib/transcription/pipeline.ts +++ b/src/lib/transcription/pipeline.ts @@ -26,6 +26,10 @@ export interface TranscribeProgress { * grows after each chunk during 'transcribing'. */ 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 { @@ -80,6 +84,18 @@ export async function transcribe(params: TranscribeParams): Promise { // 3. Transcribe each window. `perChunk[i]` holds chunk-local segments for // plan[i]; the stitcher offsets and merges them. 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++) { // Cooperative cancellation: check before each (potentially long) chunk. if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); @@ -99,6 +115,8 @@ export async function transcribe(params: TranscribeParams): Promise { stage: 'transcribing', progress: (i + 1) / plan.length, partial: stitchSegments(perChunk, plan.slice(0, i + 1), { overlapSec }), + chunkIndex: i + 1, + chunkCount: plan.length, }); } diff --git a/src/stores/transcribeStore.ts b/src/stores/transcribeStore.ts index aed4f3a..a0acded 100644 --- a/src/stores/transcribeStore.ts +++ b/src/stores/transcribeStore.ts @@ -28,6 +28,9 @@ interface TranscribeState { stage?: 'loading' | 'transcribing'; progress: number; partial: Segment[]; + /** During 'transcribing': how many chunks are done and how many total. */ + chunkIndex?: number; + chunkCount?: number; error?: string; modelId: ModelId; lastTranscriptId?: string; @@ -67,6 +70,8 @@ export const useTranscribe = create((set, get) => ({ stage: 'loading', progress: 0, partial: [], + chunkIndex: undefined, + chunkCount: undefined, error: undefined, lastTranscriptId: undefined, audioUrl: makeAudioUrl(input), @@ -82,7 +87,14 @@ export const useTranscribe = create((set, get) => ({ engine: getEngine(), signal: abort.signal, 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;