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-image": "~56.0.11",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-router": "~56.2.10",
|
"expo-router": "~56.2.10",
|
||||||
|
"expo-sharing": "~56",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-sqlite": "^56.0.5",
|
"expo-sqlite": "^56.0.5",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
@@ -943,6 +944,8 @@
|
|||||||
|
|
||||||
"expo-server": ["expo-server@56.0.5", "", {}, "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"@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=="],
|
"@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/@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/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=="],
|
"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=="],
|
"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/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=="],
|
"@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-image": "~56.0.11",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-router": "~56.2.10",
|
"expo-router": "~56.2.10",
|
||||||
|
"expo-sharing": "~56",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-sqlite": "^56.0.5",
|
"expo-sqlite": "^56.0.5",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ThemedView } from '@/components/themed-view';
|
|||||||
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
import { listModels } from '@/lib/models/catalog';
|
import { listModels } from '@/lib/models/catalog';
|
||||||
|
import { getEngine } from '@/lib/transcription';
|
||||||
|
import type { Backend } from '@/lib/types';
|
||||||
import { useAi } from '@/stores/aiStore';
|
import { useAi } from '@/stores/aiStore';
|
||||||
import { useTranscribe } from '@/stores/transcribeStore';
|
import { useTranscribe } from '@/stores/transcribeStore';
|
||||||
|
|
||||||
@@ -19,6 +21,24 @@ export default function SettingsScreen() {
|
|||||||
const setModel = useTranscribe((s) => s.setModel);
|
const setModel = useTranscribe((s) => s.setModel);
|
||||||
const models = listModels();
|
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 (
|
return (
|
||||||
<ThemedView style={styles.fill}>
|
<ThemedView style={styles.fill}>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
@@ -40,7 +60,7 @@ export default function SettingsScreen() {
|
|||||||
{selected && <ThemedText type="small" style={{ color: ACCENT }}>✓ selected</ThemedText>}
|
{selected && <ThemedText type="small" style={{ color: ACCENT }}>✓ selected</ThemedText>}
|
||||||
</View>
|
</View>
|
||||||
<ThemedText type="small" themeColor="textSecondary">
|
<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>
|
</ThemedText>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { File } from 'expo-file-system';
|
|||||||
import { decodeAudioData } from 'react-native-audio-api';
|
import { decodeAudioData } from 'react-native-audio-api';
|
||||||
|
|
||||||
import type { AudioDecoder, AudioFileInput } from './decode';
|
import type { AudioDecoder, AudioFileInput } from './decode';
|
||||||
import type { PcmAudio } from '../types';
|
import { WHISPER_SAMPLE_RATE, type PcmAudio } from '../types';
|
||||||
import { decodeWav } from './wav';
|
import { decodeWav } from './wav';
|
||||||
import { toMono16k } from './resample';
|
import { toMono16k } from './resample';
|
||||||
|
|
||||||
@@ -133,7 +133,12 @@ export const decoder: AudioDecoder = {
|
|||||||
const arrayBuffer = new Uint8Array(bytes).buffer as ArrayBuffer;
|
const arrayBuffer = new Uint8Array(bytes).buffer as ArrayBuffer;
|
||||||
let audioBuffer;
|
let audioBuffer;
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
const detail = e instanceof Error ? e.message : String(e);
|
const detail = e instanceof Error ? e.message : String(e);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -197,6 +197,62 @@ describe('decodeWav stereo de-interleaving', () => {
|
|||||||
expect(decoded.channelData[0]![0]!).toBeCloseTo(12345 / 0x7fff, 4);
|
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', () => {
|
it('decodes 32-bit IEEE float (format 3) data', () => {
|
||||||
// mono, 2 samples: 0.25 and -0.75
|
// mono, 2 samples: 0.25 and -0.75
|
||||||
const data = new Uint8Array(8);
|
const data = new Uint8Array(8);
|
||||||
|
|||||||
+32
-1
@@ -20,6 +20,10 @@ export interface DecodedWav {
|
|||||||
|
|
||||||
const WAVE_FORMAT_PCM = 1;
|
const WAVE_FORMAT_PCM = 1;
|
||||||
const WAVE_FORMAT_IEEE_FLOAT = 3;
|
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. */
|
/** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */
|
||||||
function readTag(view: DataView, offset: number): string {
|
function readTag(view: DataView, offset: number): string {
|
||||||
@@ -81,6 +85,12 @@ export function decodeWav(bytes: Uint8Array): DecodedWav {
|
|||||||
sampleRate = view.getUint32(chunkBody + 4, true);
|
sampleRate = view.getUint32(chunkBody + 4, true);
|
||||||
// bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored)
|
// bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored)
|
||||||
bitsPerSample = view.getUint16(chunkBody + 14, true);
|
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;
|
haveFmt = true;
|
||||||
} else if (chunkId === 'data') {
|
} else if (chunkId === 'data') {
|
||||||
dataOffset = chunkBody;
|
dataOffset = chunkBody;
|
||||||
@@ -124,6 +134,26 @@ export function decodeWav(bytes: Uint8Array): DecodedWav {
|
|||||||
channelData[c]![frame] = f;
|
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 (
|
} else if (
|
||||||
audioFormat === WAVE_FORMAT_IEEE_FLOAT &&
|
audioFormat === WAVE_FORMAT_IEEE_FLOAT &&
|
||||||
bitsPerSample === 32
|
bitsPerSample === 32
|
||||||
@@ -146,7 +176,8 @@ export function decodeWav(bytes: Uint8Array): DecodedWav {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported WAV format: audioFormat=${audioFormat}, ` +
|
`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');
|
const db = await SQLite.openDatabaseAsync('wisp.db');
|
||||||
await runMigrations(db);
|
await runMigrations(db);
|
||||||
return 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;
|
return dbPromise;
|
||||||
}
|
}
|
||||||
@@ -653,14 +660,17 @@ export const repo: StorageRepo = {
|
|||||||
const transcript = JSON.parse(row.json) as Transcript;
|
const transcript = JSON.parse(row.json) as Transcript;
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
const next: Transcript = { ...transcript, courseId, updatedAt };
|
const next: Transcript = { ...transcript, courseId, updatedAt };
|
||||||
await db.withTransactionAsync(async () => {
|
// Exclusive so the two writes commit atomically and can't interleave with
|
||||||
await db.runAsync(
|
// 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 = ?`,
|
`UPDATE transcripts SET courseId = ?, updatedAt = ?, json = ? WHERE id = ?`,
|
||||||
[courseId, updatedAt, JSON.stringify(next), transcriptId],
|
[courseId, updatedAt, JSON.stringify(next), transcriptId],
|
||||||
);
|
);
|
||||||
// Keep the denormalized courseId on every vector row in sync so
|
// Keep the denormalized courseId on every vector row in sync so
|
||||||
// course-scoped search keeps matching after a move.
|
// 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,
|
courseId,
|
||||||
transcriptId,
|
transcriptId,
|
||||||
]);
|
]);
|
||||||
@@ -763,22 +773,22 @@ export const repo: StorageRepo = {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
// First reassign this course's transcripts to Unsorted, THEN delete the
|
// First reassign this course's transcripts to Unsorted, THEN delete the
|
||||||
// course row, so we never leave dangling courseId references.
|
// course row, so we never leave dangling courseId references.
|
||||||
await db.withTransactionAsync(async () => {
|
await db.withExclusiveTransactionAsync(async (txn) => {
|
||||||
await db.runAsync(
|
await txn.runAsync(
|
||||||
`UPDATE transcripts SET courseId = NULL WHERE courseId = ?`,
|
`UPDATE transcripts SET courseId = NULL WHERE courseId = ?`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
// Keep denormalized vector rows consistent: their transcripts are now
|
// Keep denormalized vector rows consistent: their transcripts are now
|
||||||
// Unsorted, so their courseId must follow.
|
// 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,
|
id,
|
||||||
]);
|
]);
|
||||||
// Flashcards in this course follow their transcripts to Unsorted.
|
// Flashcards in this course follow their transcripts to Unsorted.
|
||||||
await db.runAsync(
|
await txn.runAsync(
|
||||||
`UPDATE flashcards SET courseId = NULL WHERE courseId = ?`,
|
`UPDATE flashcards SET courseId = NULL WHERE courseId = ?`,
|
||||||
[id],
|
[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],
|
[transcriptId],
|
||||||
);
|
);
|
||||||
const courseId = owner ? owner.courseId : null;
|
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
|
// Replace, not merge: clear existing rows so a re-embed with fewer
|
||||||
// segments can't leave stale rows behind.
|
// segments can't leave stale rows behind.
|
||||||
await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [
|
await txn.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [
|
||||||
transcriptId,
|
transcriptId,
|
||||||
]);
|
]);
|
||||||
for (const v of vectors) {
|
for (const v of vectors) {
|
||||||
await db.runAsync(
|
await txn.runAsync(
|
||||||
`INSERT INTO segvecs
|
`INSERT INTO segvecs
|
||||||
(transcriptId, segmentId, start, end, courseId, text, vector, model)
|
(transcriptId, segmentId, start, end, courseId, text, vector, model)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -986,9 +1000,9 @@ export const repo: StorageRepo = {
|
|||||||
srs,
|
srs,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await db.withTransactionAsync(async () => {
|
await db.withExclusiveTransactionAsync(async (txn) => {
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
await db.runAsync(
|
await txn.runAsync(
|
||||||
`INSERT INTO flashcards
|
`INSERT INTO flashcards
|
||||||
(id, transcriptId, courseId, segmentId, start, front, back,
|
(id, transcriptId, courseId, segmentId, start, front, back,
|
||||||
createdAt, srs, dueAt)
|
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';
|
// Web implementation (also the tsc base; native uses download.native.ts).
|
||||||
|
//
|
||||||
/**
|
// Triggers a client-side file download on web (no server, nothing uploaded).
|
||||||
* Trigger a client-side file download on web (no server, nothing uploaded).
|
// Async so the signature matches the native share-sheet implementation.
|
||||||
* Returns false on native, where the caller should fall back to share/save.
|
export async function downloadText(
|
||||||
*/
|
filename: string,
|
||||||
export function downloadText(filename: string, mime: string, content: string): boolean {
|
mime: string,
|
||||||
if (Platform.OS !== 'web') return false;
|
content: string,
|
||||||
|
): Promise<boolean> {
|
||||||
const blob = new Blob([content], { type: mime });
|
const blob = new Blob([content], { type: mime });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
+19
-3
@@ -4,12 +4,28 @@ import type { RefMetadata } from './types';
|
|||||||
|
|
||||||
/** Serialize references to BibTeX. Handles missing fields and []. */
|
/** Serialize references to BibTeX. Handles missing fields and []. */
|
||||||
export function toBibtex(refs: RefMetadata[]): string {
|
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 type = r.authors && r.authors.length ? 'article' : 'misc';
|
||||||
const key = bibKey(r, index);
|
|
||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
if (r.title) fields.push(` title = {${brace(r.title)}}`);
|
if (r.title) fields.push(` title = {${brace(r.title)}}`);
|
||||||
|
|||||||
@@ -159,6 +159,20 @@ function detectIso(text: string, anchorMs: number): RawMatch[] {
|
|||||||
return out;
|
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`. */
|
/** `March 3`, `March 3, 2026`, `3 March`, `on March 3rd`. */
|
||||||
function detectMonthName(text: string, anchorMs: number): RawMatch[] {
|
function detectMonthName(text: string, anchorMs: number): RawMatch[] {
|
||||||
const out: RawMatch[] = [];
|
const out: RawMatch[] = [];
|
||||||
@@ -175,6 +189,9 @@ function detectMonthName(text: string, anchorMs: number): RawMatch[] {
|
|||||||
const day = Number(m[2]);
|
const day = Number(m[2]);
|
||||||
if (month === undefined || day < 1 || day > 31) continue;
|
if (month === undefined || day < 1 || day > 31) continue;
|
||||||
const explicitYear = m[3] ? Number(m[3]) : undefined;
|
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);
|
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()];
|
const month = MONTHS[m[2]!.toLowerCase()];
|
||||||
if (month === undefined || day < 1 || day > 31) continue;
|
if (month === undefined || day < 1 || day > 31) continue;
|
||||||
const explicitYear = m[3] ? Number(m[3]) : undefined;
|
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);
|
pushResolved(out, text, m, month, day, explicitYear, anchorMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,11 @@ export function glossary(
|
|||||||
const m = sentence.match(DEFINITION_RE);
|
const m = sentence.match(DEFINITION_RE);
|
||||||
if (!m || !m[1]) continue;
|
if (!m || !m[1]) continue;
|
||||||
const term = m[1].trim();
|
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);
|
const termWords = tokenizeWords(term);
|
||||||
if (termWords.length === 0 && term.length < 3) continue;
|
if (termWords.length === 0 || term.length < 3) continue;
|
||||||
add({
|
add({
|
||||||
term,
|
term,
|
||||||
definition: sentence,
|
definition: sentence,
|
||||||
|
|||||||
@@ -79,4 +79,17 @@ describe('review', () => {
|
|||||||
const easy = review(initialSrs(NOW), 3, NOW);
|
const easy = review(initialSrs(NOW), 3, NOW);
|
||||||
expect(easy.intervalDays).toBeGreaterThan(good.intervalDays);
|
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;
|
lapses += 1;
|
||||||
intervalDays = AGAIN_INTERVAL_DAYS;
|
intervalDays = AGAIN_INTERVAL_DAYS;
|
||||||
} else if (grade === 1) {
|
} 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);
|
ease = floorEase(ease - 0.15);
|
||||||
reps += 1;
|
reps += 1;
|
||||||
intervalDays = nextInterval(srs, ease, HARD_MULTIPLIER);
|
intervalDays = hardInterval(srs);
|
||||||
} else if (grade === 2) {
|
} else if (grade === 2) {
|
||||||
// Good: standard SM-2 progression, ease unchanged.
|
// Good: standard SM-2 progression, ease unchanged.
|
||||||
reps += 1;
|
reps += 1;
|
||||||
@@ -104,6 +108,16 @@ function nextInterval(prev: SrsState, ease: number, multiplier: number): number
|
|||||||
return base * multiplier;
|
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. */
|
/** Clamp an ease factor to SM-2's minimum of 1.3. */
|
||||||
function floorEase(ease: number): number {
|
function floorEase(ease: number): number {
|
||||||
return ease < MIN_EASE ? MIN_EASE : ease;
|
return ease < MIN_EASE ? MIN_EASE : ease;
|
||||||
|
|||||||
@@ -156,13 +156,26 @@ export const engine: TranscriptionEngine = {
|
|||||||
|
|
||||||
const out = await asr(audio.samples, genOpts);
|
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[] = [];
|
const segments: Segment[] = [];
|
||||||
for (const c of out.chunks ?? []) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const [start, end] = c.timestamp;
|
const c = chunks[i]!;
|
||||||
|
const [start, rawEnd] = c.timestamp;
|
||||||
if (start == null) continue;
|
if (start == null) continue;
|
||||||
const text = c.text.trim();
|
const text = c.text.trim();
|
||||||
if (text.length === 0) continue;
|
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;
|
return segments;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ export async function transcribe(params: TranscribeParams): Promise<Segment[]> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const segs = await engine.transcribeChunk(chunkAudio, options);
|
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);
|
perChunk.push(segs);
|
||||||
|
|
||||||
// Emit a growing absolute-time partial: stitch everything decoded so far.
|
// 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. */
|
/** 'all' = every transcript · null = Unsorted · string = a specific course id. */
|
||||||
export type CourseFilter = 'all' | null | string;
|
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 {
|
interface TranscriptsState {
|
||||||
items: TranscriptMeta[];
|
items: TranscriptMeta[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -27,6 +32,7 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
|||||||
courseFilter: 'all',
|
courseFilter: 'all',
|
||||||
|
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
|
const my = ++refreshToken;
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
const repo = getRepo();
|
const repo = getRepo();
|
||||||
@@ -40,9 +46,13 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
items = cf === 'all' ? await repo.list() : await repo.listByCourse(cf);
|
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 });
|
set({ items });
|
||||||
} finally {
|
} 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