From ed1df8986fe3b83673d8bfa975bbc7e255420af5 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 15 Jun 2026 11:05:17 +0200 Subject: [PATCH] Bug-hunt sweep: fix 13 verified issues (correctness + perf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a multi-agent bug-hunt + adversarial verification pass (0 critical/ high; 15 mediums). Fixed 13; 2 deferred as bigger refactors. Correctness: - srs: "Hard" no longer overshoots "Good" for reviewed cards (reps>=2). It compounded ease AND x1.2; now grows x1.2 off the previous interval only. + regression test. - enrich/dates: stop reading the modal verb "may" as the month May (phantom calendar events). "may" needs an ordinal/year/date-preposition now. - enrich/bib: disambiguate colliding BibTeX keys (smith2020, smith2020a, …) — duplicates corrupted reference-manager imports. - learn/glossary: junk-term guard used && (dead); now || so all-stopword terms like "there" are actually skipped. - transcription/engineImpl.web: don't collapse Whisper's null end-timestamp to a zero-length [t,t] segment; estimate from the next chunk or window duration (fixes citation/seam anchors). - transcription/pipeline: re-check the abort signal AFTER each chunk so Cancel works on single-chunk audio (it previously still saved). - db/repo.native: use withExclusiveTransactionAsync for reassign / deleteCourse / upsertVectors / createFlashcards (withTransactionAsync is not isolated on a shared connection → interleaved/half-applied writes). - db/repo.native: don't cache a rejected open/migrate promise — a transient first-open failure no longer bricks storage for the whole session. - stores/transcriptsStore: sequence-guard refresh() so overlapping focus/typing/filter refreshes can't resolve out of order and show stale results. - audio/wav: decode WAVE_FORMAT_EXTENSIBLE (0xFFFE) via its SubFormat GUID + add 24-bit PCM — common ffmpeg/Windows WAVs no longer hard-fail native import. + tests. Performance / footprint: - audio/decode.native: decode straight to 16kHz (decodeAudioData sampleRate hint) so the JS side never holds a full-rate buffer or runs the resample loop — big memory/OOM win on long lectures. - models/catalog display: Settings model sizes are now backend-aware (the no-GPU WASM path pulls ~2x fp32 weights; was advertising ~half). Feature gap: - download: native exports were silent no-ops. New download.native.ts writes to cache + opens the share sheet (expo-sharing); transcript/ICS/Anki-CSV/ BibTeX/RIS exports now work on device. Deferred (bigger): lexical-search recall ceiling (needs a full-corpus rank path on both repos); triple in-memory copy of the encoded file during transcribe (media is keyed by a not-yet-existing transcript id). Validated: tsc clean, 282 tests pass, web export clean (native deps not bundled), arm64 APK compiles with expo-sharing. Co-Authored-By: Claude Opus 4.8 --- bun.lock | 11 ++--- package.json | 1 + src/app/(tabs)/settings.tsx | 22 +++++++++- src/lib/audio/decode.native.ts | 9 +++- src/lib/audio/wav.test.ts | 56 +++++++++++++++++++++++++ src/lib/audio/wav.ts | 33 ++++++++++++++- src/lib/db/repo.native.ts | 42 ++++++++++++------- src/lib/download.native.ts | 33 +++++++++++++++ src/lib/download.ts | 17 ++++---- src/lib/enrich/bib.ts | 22 ++++++++-- src/lib/enrich/dates.ts | 20 +++++++++ src/lib/learn/glossary.ts | 6 ++- src/lib/learn/srs.test.ts | 13 ++++++ src/lib/learn/srs.ts | 18 +++++++- src/lib/transcription/engineImpl.web.ts | 19 +++++++-- src/lib/transcription/pipeline.ts | 7 ++++ src/stores/transcriptsStore.ts | 12 +++++- 17 files changed, 299 insertions(+), 42 deletions(-) create mode 100644 src/lib/download.native.ts diff --git a/bun.lock b/bun.lock index 19006fd..ede8bdb 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "expo-image": "~56.0.11", "expo-linking": "~56.0.14", "expo-router": "~56.2.10", + "expo-sharing": "~56", "expo-splash-screen": "~56.0.10", "expo-sqlite": "^56.0.5", "expo-status-bar": "~56.0.4", @@ -943,6 +944,8 @@ "expo-server": ["expo-server@56.0.5", "", {}, "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ=="], + "expo-sharing": ["expo-sharing@56.0.17", "", { "dependencies": { "@expo/config-plugins": "^56.0.8", "@expo/config-types": "^56.0.5", "@expo/plist": "^0.7.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-FqN7/UBau0PJ2O8OeMYS/fwE+6UMtdoDeGxsRoaileK0w30bXC92MuT7z4ujnk4mF9ZZBjS8axbGOgrZ6JWBEA=="], + "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="], "expo-sqlite": ["expo-sqlite@56.0.5", "", { "dependencies": { "await-lock": "^2.2.2" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-wHYRVLS5nUFEtli45wHaO+RjlRY8sQXyOSgENVk6I4zq7+FgySqjOk3YOYW6IKIMwhj5XzjMJO+pY8xKUy73Kw=="], @@ -1763,7 +1766,7 @@ "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1809,8 +1812,6 @@ "@expo/metro-config/hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="], - "@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.86.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" } }, "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA=="], @@ -1917,8 +1918,6 @@ "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -1947,6 +1946,8 @@ "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="], diff --git a/package.json b/package.json index 1b8097a..e511881 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "expo-image": "~56.0.11", "expo-linking": "~56.0.14", "expo-router": "~56.2.10", + "expo-sharing": "~56", "expo-splash-screen": "~56.0.10", "expo-sqlite": "^56.0.5", "expo-status-bar": "~56.0.4", diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 9b59310..e4d8c92 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -6,6 +6,8 @@ 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 { getEngine } from '@/lib/transcription'; +import type { Backend } from '@/lib/types'; import { useAi } from '@/stores/aiStore'; import { useTranscribe } from '@/stores/transcribeStore'; @@ -19,6 +21,24 @@ export default function SettingsScreen() { const setModel = useTranscribe((s) => s.setModel); const models = listModels(); + // Download size depends on the resolved backend: the no-WebGPU WEB path pulls + // full-precision (fp32) weights — roughly 2x the quantized figure in the + // catalog — while WebGPU (fp16) and native (ggml) are close to it. Resolve the + // backend once so the sizes we advertise aren't ~half the real download. + const [sizeMult, setSizeMult] = useState(1); + useEffect(() => { + let alive = true; + getEngine() + .capabilities() + .then((c: { backend: Backend }) => { + if (alive) setSizeMult(c.backend === 'wasm' ? 2 : c.backend === 'webgpu' ? 1.4 : 1); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + return ( @@ -40,7 +60,7 @@ export default function SettingsScreen() { {selected && ✓ selected} - {cap(m.tier)} · ~{m.approxMB} MB · {m.multilingual ? 'multilingual' : 'English-only'} + {cap(m.tier)} · ~{Math.round(m.approxMB * sizeMult)} MB · {m.multilingual ? 'multilingual' : 'English-only'} diff --git a/src/lib/audio/decode.native.ts b/src/lib/audio/decode.native.ts index e4f4bd5..335e5e8 100644 --- a/src/lib/audio/decode.native.ts +++ b/src/lib/audio/decode.native.ts @@ -24,7 +24,7 @@ import { File } from 'expo-file-system'; import { decodeAudioData } from 'react-native-audio-api'; import type { AudioDecoder, AudioFileInput } from './decode'; -import type { PcmAudio } from '../types'; +import { WHISPER_SAMPLE_RATE, type PcmAudio } from '../types'; import { decodeWav } from './wav'; import { toMono16k } from './resample'; @@ -133,7 +133,12 @@ export const decoder: AudioDecoder = { const arrayBuffer = new Uint8Array(bytes).buffer as ArrayBuffer; let audioBuffer; try { - audioBuffer = await decodeAudioData(arrayBuffer); + // Ask the native engine to resample to 16kHz DURING decode (platform DSP). + // This avoids materializing a full-rate (e.g. 44.1kHz stereo) Float32 + // buffer in JS and skips the pure-JS resample loop — a big memory/CPU win + // and OOM guard on long lectures. toMono16k below still downmixes to mono + // and short-circuits its resampler since the rate already matches. + audioBuffer = await decodeAudioData(arrayBuffer, WHISPER_SAMPLE_RATE); } catch (e) { const detail = e instanceof Error ? e.message : String(e); throw new Error( diff --git a/src/lib/audio/wav.test.ts b/src/lib/audio/wav.test.ts index d9b5bdf..d9e83ea 100644 --- a/src/lib/audio/wav.test.ts +++ b/src/lib/audio/wav.test.ts @@ -197,6 +197,62 @@ describe('decodeWav stereo de-interleaving', () => { expect(decoded.channelData[0]![0]!).toBeCloseTo(12345 / 0x7fff, 4); }); + it('decodes 24-bit PCM (format 1) data', () => { + // mono, 2 samples: +0x400000 (~+0.5) and -0x400000 (-0.5), 3 bytes LE each. + const data = new Uint8Array([0x00, 0x00, 0x40, 0x00, 0x00, 0xc0]); + const wav = buildWav({ + audioFormat: 1, + numChannels: 1, + sampleRate: 16000, + bitsPerSample: 24, + data, + }); + const decoded = decodeWav(wav); + expect(decoded.channelData.length).toBe(1); + expect(decoded.channelData[0]![0]!).toBeCloseTo(0x400000 / 0x7fffff, 3); + expect(decoded.channelData[0]![1]!).toBeCloseTo(-0.5, 3); + }); + + it('decodes WAVE_FORMAT_EXTENSIBLE (0xFFFE) wrapping 16-bit PCM', () => { + // 40-byte fmt: 16 standard + 24-byte extension whose SubFormat GUID begins + // with the real format tag (1 = PCM). + const fmt = new Uint8Array(40); + const fv = new DataView(fmt.buffer); + fv.setUint16(0, 0xfffe, true); // WAVE_FORMAT_EXTENSIBLE + fv.setUint16(2, 1, true); // channels + fv.setUint32(4, 16000, true); // sampleRate + fv.setUint32(8, 32000, true); // byteRate + fv.setUint16(12, 2, true); // blockAlign + fv.setUint16(14, 16, true); // bitsPerSample + fv.setUint16(16, 22, true); // cbSize + fv.setUint16(18, 16, true); // validBitsPerSample + fv.setUint32(20, 0, true); // channelMask + fv.setUint16(24, 1, true); // SubFormat GUID first 2 bytes -> PCM + const data = new Uint8Array(2); + new DataView(data.buffer).setInt16(0, 16383, true); // ~0.5 + + const total = 12 + (8 + 40) + (8 + 2); + const out = new Uint8Array(total); + const view = new DataView(out.buffer); + const tag = (off: number, s: string) => { + for (let i = 0; i < 4; i++) view.setUint8(off + i, s.charCodeAt(i)); + }; + tag(0, 'RIFF'); + view.setUint32(4, total - 8, true); + tag(8, 'WAVE'); + tag(12, 'fmt '); + view.setUint32(16, 40, true); + out.set(fmt, 20); + tag(60, 'data'); + view.setUint32(64, 2, true); + out.set(data, 68); + + const decoded = decodeWav(out); + expect(decoded.sampleRate).toBe(16000); + expect(decoded.channelData.length).toBe(1); + expect(decoded.channelData[0]![0]!).toBeCloseTo(16383 / 0x7fff, 4); + }); + it('decodes 32-bit IEEE float (format 3) data', () => { // mono, 2 samples: 0.25 and -0.75 const data = new Uint8Array(8); diff --git a/src/lib/audio/wav.ts b/src/lib/audio/wav.ts index 8033bc3..c2c4891 100644 --- a/src/lib/audio/wav.ts +++ b/src/lib/audio/wav.ts @@ -20,6 +20,10 @@ export interface DecodedWav { const WAVE_FORMAT_PCM = 1; const WAVE_FORMAT_IEEE_FLOAT = 3; +// Many encoders (ffmpeg, Audacity, Windows recorders) wrap PCM/float in +// WAVE_FORMAT_EXTENSIBLE; the real format tag is the first 2 bytes of the +// extension's SubFormat GUID. +const WAVE_FORMAT_EXTENSIBLE = 0xfffe; /** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */ function readTag(view: DataView, offset: number): string { @@ -81,6 +85,12 @@ export function decodeWav(bytes: Uint8Array): DecodedWav { sampleRate = view.getUint32(chunkBody + 4, true); // bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored) bitsPerSample = view.getUint16(chunkBody + 14, true); + // WAVE_FORMAT_EXTENSIBLE: the actual PCM/float tag lives in the extension's + // SubFormat GUID (first 2 bytes), at fmt-body + 24. Resolve it so common + // ffmpeg/Windows EXTENSIBLE WAVs decode instead of throwing. + if (audioFormat === WAVE_FORMAT_EXTENSIBLE && chunkBody + 26 <= bytes.byteLength) { + audioFormat = view.getUint16(chunkBody + 24, true); + } haveFmt = true; } else if (chunkId === 'data') { dataOffset = chunkBody; @@ -124,6 +134,26 @@ export function decodeWav(bytes: Uint8Array): DecodedWav { channelData[c]![frame] = f; } } + } else if (audioFormat === WAVE_FORMAT_PCM && bitsPerSample === 24) { + // 24-bit signed little-endian PCM (common in EXTENSIBLE WAVs). + const bytesPerSample = 3; + 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 o = base + c * bytesPerSample; + let v = + view.getUint8(o) | + (view.getUint8(o + 1) << 8) | + (view.getUint8(o + 2) << 16); + if (v & 0x800000) v -= 0x1000000; // sign-extend the 24th bit + channelData[c]![frame] = v < 0 ? v / 0x800000 : v / 0x7fffff; + } + } } else if ( audioFormat === WAVE_FORMAT_IEEE_FLOAT && bitsPerSample === 32 @@ -146,7 +176,8 @@ export function decodeWav(bytes: Uint8Array): DecodedWav { } else { throw new Error( `Unsupported WAV format: audioFormat=${audioFormat}, ` + - `bitsPerSample=${bitsPerSample} (supported: 16-bit PCM, 32-bit float)`, + `bitsPerSample=${bitsPerSample} ` + + `(supported: 16/24-bit PCM, 32-bit float, incl. WAVE_FORMAT_EXTENSIBLE)`, ); } diff --git a/src/lib/db/repo.native.ts b/src/lib/db/repo.native.ts index 91041d2..bbd2429 100644 --- a/src/lib/db/repo.native.ts +++ b/src/lib/db/repo.native.ts @@ -411,7 +411,14 @@ function getDb(): Promise { const db = await SQLite.openDatabaseAsync('wisp.db'); await runMigrations(db); return db; - })(); + })().catch((e) => { + // Don't cache a REJECTED promise — otherwise a transient open/migration + // failure (locked db, backgrounded mid-migration) would brick storage for + // the whole session, with every later call returning the same rejection. + // Clearing it lets the next getDb() retry from scratch. + dbPromise = undefined; + throw e; + }); } return dbPromise; } @@ -653,14 +660,17 @@ export const repo: StorageRepo = { const transcript = JSON.parse(row.json) as Transcript; const updatedAt = Date.now(); const next: Transcript = { ...transcript, courseId, updatedAt }; - await db.withTransactionAsync(async () => { - await db.runAsync( + // Exclusive so the two writes commit atomically and can't interleave with + // concurrent repo queries (withTransactionAsync is NOT isolated on a shared + // connection — see expo-sqlite docs). + await db.withExclusiveTransactionAsync(async (txn) => { + await txn.runAsync( `UPDATE transcripts SET courseId = ?, updatedAt = ?, json = ? WHERE id = ?`, [courseId, updatedAt, JSON.stringify(next), transcriptId], ); // Keep the denormalized courseId on every vector row in sync so // course-scoped search keeps matching after a move. - await db.runAsync(`UPDATE segvecs SET courseId = ? WHERE transcriptId = ?`, [ + await txn.runAsync(`UPDATE segvecs SET courseId = ? WHERE transcriptId = ?`, [ courseId, transcriptId, ]); @@ -763,22 +773,22 @@ export const repo: StorageRepo = { const db = await getDb(); // First reassign this course's transcripts to Unsorted, THEN delete the // course row, so we never leave dangling courseId references. - await db.withTransactionAsync(async () => { - await db.runAsync( + await db.withExclusiveTransactionAsync(async (txn) => { + await txn.runAsync( `UPDATE transcripts SET courseId = NULL WHERE courseId = ?`, [id], ); // Keep denormalized vector rows consistent: their transcripts are now // Unsorted, so their courseId must follow. - await db.runAsync(`UPDATE segvecs SET courseId = NULL WHERE courseId = ?`, [ + await txn.runAsync(`UPDATE segvecs SET courseId = NULL WHERE courseId = ?`, [ id, ]); // Flashcards in this course follow their transcripts to Unsorted. - await db.runAsync( + await txn.runAsync( `UPDATE flashcards SET courseId = NULL WHERE courseId = ?`, [id], ); - await db.runAsync(`DELETE FROM courses WHERE id = ?`, [id]); + await txn.runAsync(`DELETE FROM courses WHERE id = ?`, [id]); }); }, @@ -867,14 +877,18 @@ export const repo: StorageRepo = { [transcriptId], ); const courseId = owner ? owner.courseId : null; - await db.withTransactionAsync(async () => { + // Exclusive: the DELETE-then-INSERT must be atomic + isolated, else a + // concurrent searchVectors during an index rebuild can read zero vectors, + // and a crash between the DELETE and the INSERTs would lose this + // transcript's embeddings entirely. + await db.withExclusiveTransactionAsync(async (txn) => { // Replace, not merge: clear existing rows so a re-embed with fewer // segments can't leave stale rows behind. - await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [ + await txn.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [ transcriptId, ]); for (const v of vectors) { - await db.runAsync( + await txn.runAsync( `INSERT INTO segvecs (transcriptId, segmentId, start, end, courseId, text, vector, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -986,9 +1000,9 @@ export const repo: StorageRepo = { srs, }; }); - await db.withTransactionAsync(async () => { + await db.withExclusiveTransactionAsync(async (txn) => { for (const card of cards) { - await db.runAsync( + await txn.runAsync( `INSERT INTO flashcards (id, transcriptId, courseId, segmentId, start, front, back, createdAt, srs, dueAt) diff --git a/src/lib/download.native.ts b/src/lib/download.native.ts new file mode 100644 index 0000000..cdd4ed1 --- /dev/null +++ b/src/lib/download.native.ts @@ -0,0 +1,33 @@ +// Native (iOS/Android) implementation of downloadText: there's no browser +// download, so we write the content to a cache file and hand it to the OS share +// sheet (Save to Files, AirDrop, send to another app, …). Previously this path +// returned false and the export buttons (transcript, ICS, Anki CSV, BibTeX/RIS) +// silently did nothing on device. + +import * as Sharing from 'expo-sharing'; +import { writeAsStringAsync, cacheDirectory } from 'expo-file-system/legacy'; + +/** Sanitize a filename for use as a path segment (keep word chars, dot, dash). */ +function safeName(name: string): string { + const cleaned = name.replace(/[^\w.\-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned.length > 0 ? cleaned : 'export'; +} + +export async function downloadText( + filename: string, + mime: string, + content: string, +): Promise { + try { + const dir = cacheDirectory; + if (!dir || !(await Sharing.isAvailableAsync())) return false; + const uri = `${dir}${safeName(filename)}`; + await writeAsStringAsync(uri, content); + await Sharing.shareAsync(uri, { mimeType: mime, dialogTitle: filename }); + return true; + } catch { + // User-cancelled share or any IO error: report failure so callers can decide + // (today they ignore it, but the return keeps the contract honest). + return false; + } +} diff --git a/src/lib/download.ts b/src/lib/download.ts index b9a0f4e..60126a6 100644 --- a/src/lib/download.ts +++ b/src/lib/download.ts @@ -1,11 +1,12 @@ -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; +// Web implementation (also the tsc base; native uses download.native.ts). +// +// Triggers a client-side file download on web (no server, nothing uploaded). +// Async so the signature matches the native share-sheet implementation. +export async function downloadText( + filename: string, + mime: string, + content: string, +): Promise { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/src/lib/enrich/bib.ts b/src/lib/enrich/bib.ts index 872fb83..8f47d98 100644 --- a/src/lib/enrich/bib.ts +++ b/src/lib/enrich/bib.ts @@ -4,12 +4,28 @@ import type { RefMetadata } from './types'; /** Serialize references to BibTeX. Handles missing fields and []. */ export function toBibtex(refs: RefMetadata[]): string { - return refs.map((r, i) => bibEntry(r, i)).join('\n\n'); + // BibTeX entry keys MUST be unique — same-surname/same-year refs would + // otherwise collide and most processors drop the duplicate on import. Track + // emitted keys and disambiguate collisions with an a/b/c… suffix. + const used = new Map(); + return refs + .map((r, i) => { + let key = bibKey(r, i); + const seen = used.get(key); + if (seen !== undefined) { + const suffix = String.fromCharCode(97 + seen); // 0 -> 'a', 1 -> 'b', … + used.set(key, seen + 1); + key = `${key}${suffix}`; + } else { + used.set(key, 0); + } + return bibEntry(r, key); + }) + .join('\n\n'); } -function bibEntry(r: RefMetadata, index: number): string { +function bibEntry(r: RefMetadata, key: string): string { const type = r.authors && r.authors.length ? 'article' : 'misc'; - const key = bibKey(r, index); const fields: string[] = []; if (r.title) fields.push(` title = {${brace(r.title)}}`); diff --git a/src/lib/enrich/dates.ts b/src/lib/enrich/dates.ts index c9cf6d6..416aaca 100644 --- a/src/lib/enrich/dates.ts +++ b/src/lib/enrich/dates.ts @@ -159,6 +159,20 @@ function detectIso(text: string, anchorMs: number): RawMatch[] { return out; } +/** + * "may" is also the (very common) modal verb, so a bare "may " — e.g. + * "you may 5 minutes", "you may submit 3 drafts" — must NOT be read as the month + * May. Only accept it as a month with stronger context: an ordinal on the day + * ("May 5th"), an explicit year ("May 5, 2026"), or a date preposition right + * before it ("on/by/due/until/before/after May 5"). + */ +function mayLooksLikeMonth(m: RegExpExecArray, text: string, hasYear: boolean): boolean { + if (/\d\s*(?:st|nd|rd|th)\b/i.test(m[0])) return true; + if (hasYear) return true; + const before = text.slice(Math.max(0, m.index - 10), m.index).toLowerCase(); + return /\b(?:on|by|due|until|before|after|through)\s*$/.test(before); +} + /** `March 3`, `March 3, 2026`, `3 March`, `on March 3rd`. */ function detectMonthName(text: string, anchorMs: number): RawMatch[] { const out: RawMatch[] = []; @@ -175,6 +189,9 @@ function detectMonthName(text: string, anchorMs: number): RawMatch[] { const day = Number(m[2]); if (month === undefined || day < 1 || day > 31) continue; const explicitYear = m[3] ? Number(m[3]) : undefined; + if (m[1]!.toLowerCase() === 'may' && !mayLooksLikeMonth(m, text, explicitYear !== undefined)) { + continue; + } pushResolved(out, text, m, month, day, explicitYear, anchorMs); } @@ -188,6 +205,9 @@ function detectMonthName(text: string, anchorMs: number): RawMatch[] { const month = MONTHS[m[2]!.toLowerCase()]; if (month === undefined || day < 1 || day > 31) continue; const explicitYear = m[3] ? Number(m[3]) : undefined; + if (m[2]!.toLowerCase() === 'may' && !mayLooksLikeMonth(m, text, explicitYear !== undefined)) { + continue; + } pushResolved(out, text, m, month, day, explicitYear, anchorMs); } diff --git a/src/lib/learn/glossary.ts b/src/lib/learn/glossary.ts index 66f9b0a..5a945da 100644 --- a/src/lib/learn/glossary.ts +++ b/src/lib/learn/glossary.ts @@ -103,9 +103,11 @@ export function glossary( const m = sentence.match(DEFINITION_RE); if (!m || !m[1]) continue; const term = m[1].trim(); - // Skip junk subjects (pure stopword / too short). + // Skip junk subjects: a term with no content words (pure stopwords) OR one + // that's too short. (Was `&&`, which only skipped when BOTH held, so an + // all-stopword term like "there" still became a glossary entry.) const termWords = tokenizeWords(term); - if (termWords.length === 0 && term.length < 3) continue; + if (termWords.length === 0 || term.length < 3) continue; add({ term, definition: sentence, diff --git a/src/lib/learn/srs.test.ts b/src/lib/learn/srs.test.ts index 3e2b488..9759e50 100644 --- a/src/lib/learn/srs.test.ts +++ b/src/lib/learn/srs.test.ts @@ -79,4 +79,17 @@ describe('review', () => { const easy = review(initialSrs(NOW), 3, NOW); expect(easy.intervalDays).toBeGreaterThan(good.intervalDays); }); + + it('keeps "hard" shorter than "good" for a reviewed card (reps >= 2)', () => { + // Bring a card to reps=2 with two Goods (interval 6, ease 2.5). + let s = initialSrs(NOW); + s = review(s, 2, NOW); + s = review(s, 2, NOW); + expect(s.reps).toBe(2); + const hard = review(s, 1, NOW); + const good = review(s, 2, NOW); + expect(hard.intervalDays).toBeLessThan(good.intervalDays); + // And Hard still grows the interval (it's a pass, not a lapse). + expect(hard.intervalDays).toBeGreaterThan(s.intervalDays); + }); }); diff --git a/src/lib/learn/srs.ts b/src/lib/learn/srs.ts index d52d394..1340f72 100644 --- a/src/lib/learn/srs.ts +++ b/src/lib/learn/srs.ts @@ -60,10 +60,14 @@ export function review( lapses += 1; intervalDays = AGAIN_INTERVAL_DAYS; } else if (grade === 1) { - // Hard: passed, but gently grow the interval and lower ease. + // Hard: passed with difficulty. Lower ease and grow the interval only + // GENTLY (x1.2) off the PREVIOUS interval — crucially WITHOUT compounding + // the ease factor. Multiplying by ease as well would push Hard past Good for + // reviewed cards (e.g. 2.35*1.2 = 2.82 > 2.5), the opposite of what a + // struggling learner expects. ease = floorEase(ease - 0.15); reps += 1; - intervalDays = nextInterval(srs, ease, HARD_MULTIPLIER); + intervalDays = hardInterval(srs); } else if (grade === 2) { // Good: standard SM-2 progression, ease unchanged. reps += 1; @@ -104,6 +108,16 @@ function nextInterval(prev: SrsState, ease: number, multiplier: number): number return base * multiplier; } +/** + * Interval for a "Hard" pass: a gentle x1.2 growth off the previous interval, + * never compounded by ease, so Hard stays strictly shorter than Good (which + * multiplies by ease) at every stage. A brand-new/unseen card gets a 1-day step. + */ +function hardInterval(prev: SrsState): number { + if (prev.reps <= 0 || prev.intervalDays <= 0) return 1; + return prev.intervalDays * HARD_MULTIPLIER; +} + /** Clamp an ease factor to SM-2's minimum of 1.3. */ function floorEase(ease: number): number { return ease < MIN_EASE ? MIN_EASE : ease; diff --git a/src/lib/transcription/engineImpl.web.ts b/src/lib/transcription/engineImpl.web.ts index 7beea30..069cf00 100644 --- a/src/lib/transcription/engineImpl.web.ts +++ b/src/lib/transcription/engineImpl.web.ts @@ -156,13 +156,26 @@ export const engine: TranscriptionEngine = { const out = await asr(audio.samples, genOpts); + // Whisper sometimes omits the END timestamp of the last utterance in a + // window (transformers.js yields timestamp[1] === null). Don't collapse that + // to a zero-length [t, t] segment (it breaks stitch seams + citation spans): + // estimate the end from the next chunk's start, else the window's duration. + const chunks = out.chunks ?? []; + const chunkDurationSec = audio.samples.length / audio.sampleRate; const segments: Segment[] = []; - for (const c of out.chunks ?? []) { - const [start, end] = c.timestamp; + for (let i = 0; i < chunks.length; i++) { + const c = chunks[i]!; + const [start, rawEnd] = c.timestamp; if (start == null) continue; const text = c.text.trim(); if (text.length === 0) continue; - segments.push({ start, end: end ?? start, text }); + let end = rawEnd; + if (end == null) { + const nextStart = chunks[i + 1]?.timestamp[0]; + end = nextStart != null && nextStart > start ? nextStart : chunkDurationSec; + } + if (end < start) end = chunkDurationSec; + segments.push({ start, end, text }); } return segments; }, diff --git a/src/lib/transcription/pipeline.ts b/src/lib/transcription/pipeline.ts index bd47104..a8b559e 100644 --- a/src/lib/transcription/pipeline.ts +++ b/src/lib/transcription/pipeline.ts @@ -108,6 +108,13 @@ export async function transcribe(params: TranscribeParams): Promise { }; const segs = await engine.transcribeChunk(chunkAudio, options); + + // Re-check AFTER the (long) inference: the top-of-loop check can't catch a + // cancel that happens DURING transcribeChunk — and for single-chunk audio + // the loop body runs once, so without this a late Cancel would still fall + // through and save the transcript. + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + perChunk.push(segs); // Emit a growing absolute-time partial: stitch everything decoded so far. diff --git a/src/stores/transcriptsStore.ts b/src/stores/transcriptsStore.ts index 3cc8977..6a0daca 100644 --- a/src/stores/transcriptsStore.ts +++ b/src/stores/transcriptsStore.ts @@ -7,6 +7,11 @@ import { getRepo, type TranscriptMeta } from '@/lib/db'; /** 'all' = every transcript · null = Unsorted · string = a specific course id. */ export type CourseFilter = 'all' | null | string; +// Monotonic token so overlapping refreshes (focus + typing + filter toggles all +// call refresh()) can't resolve out of order and clobber the list with a stale +// query/filter's results. Only the latest-issued refresh is allowed to commit. +let refreshToken = 0; + interface TranscriptsState { items: TranscriptMeta[]; loading: boolean; @@ -27,6 +32,7 @@ export const useTranscripts = create((set, get) => ({ courseFilter: 'all', refresh: async () => { + const my = ++refreshToken; set({ loading: true }); try { const repo = getRepo(); @@ -40,9 +46,13 @@ export const useTranscripts = create((set, get) => ({ } else { items = cf === 'all' ? await repo.list() : await repo.listByCourse(cf); } + // A newer refresh was issued while we awaited — discard our stale result. + if (my !== refreshToken) return; set({ items }); } finally { - set({ loading: false }); + // Only the latest refresh clears the spinner, so it doesn't flicker off + // while a newer in-flight refresh is still running. + if (my === refreshToken) set({ loading: false }); } },