Bug-hunt sweep: fix 13 verified issues (correctness + perf)
CI / build-apk (push) Successful in 5m59s
CI / test (push) Successful in 17s
CI / deploy-web (push) Successful in 32s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 11:05:17 +02:00
parent 4bddc67e1a
commit ed1df8986f
17 changed files with 299 additions and 42 deletions
+6 -5
View File
@@ -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=="],
+1
View File
@@ -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",
+21 -1
View File
@@ -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 (
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
@@ -40,7 +60,7 @@ export default function SettingsScreen() {
{selected && <ThemedText type="small" style={{ color: ACCENT }}> selected</ThemedText>}
</View>
<ThemedText type="small" themeColor="textSecondary">
{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'}
</ThemedText>
</ThemedView>
</Pressable>
+7 -2
View File
@@ -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(
+56
View File
@@ -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);
+32 -1
View File
@@ -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)`,
);
}
+28 -14
View File
@@ -411,7 +411,14 @@ function getDb(): Promise<SQLite.SQLiteDatabase> {
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)
+33
View File
@@ -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<boolean> {
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;
}
}
+9 -8
View File
@@ -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<boolean> {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+19 -3
View File
@@ -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<string, number>();
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)}}`);
+20
View File
@@ -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 <number>" — 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);
}
+4 -2
View File
@@ -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,
+13
View File
@@ -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);
});
});
+16 -2
View File
@@ -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;
+16 -3
View File
@@ -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;
},
+7
View File
@@ -108,6 +108,13 @@ export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
};
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.
+11 -1
View File
@@ -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<TranscriptsState>((set, get) => ({
courseFilter: 'all',
refresh: async () => {
const my = ++refreshToken;
set({ loading: true });
try {
const repo = getRepo();
@@ -40,9 +46,13 @@ export const useTranscripts = create<TranscriptsState>((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 });
}
},