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 });
}
},