Bug-hunt sweep: fix 13 verified issues (correctness + perf)
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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user