Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
f83cbac
feat(pro): autolink the pro native library via react-native.config.js
dishit-wednesday Jun 27, 2026
e4443b8
feat(locket): core whisper file-transcription support (chunkable opts…
dishit-wednesday Jun 27, 2026
dd7f01b
feat(locket): add 'whisper' remote-server provider type
dishit-wednesday Jun 27, 2026
5748d1c
feat(core): persistent + in-memory debug logging for export
dishit-wednesday Jun 27, 2026
0b28261
feat(ios): background-audio mode for locket recorder
dishit-wednesday Jun 27, 2026
59e4439
chore(ios): pod install for OffgridPro
dishit-wednesday Jun 27, 2026
eb9aea1
chore: bump pro submodule to feat/locket (locket feature)
dishit-wednesday Jun 27, 2026
e72ac68
feat(transcription): offset/duration chunking, live segments, CoreML …
dishit-wednesday Jun 28, 2026
52cdfdf
feat(nav): Memory tab (Pro recorder/paywall) + move Settings to Home …
dishit-wednesday Jun 28, 2026
db0a6ae
feat(chat): summarize oversized transcript/document attachments
dishit-wednesday Jun 28, 2026
4d7ab20
chore: bump pro submodule (transcription slicing + attach-to-chat)
dishit-wednesday Jun 28, 2026
3cbe0b8
feat(chat): stream transcript summaries live + bigger Summarize chip
dishit-wednesday Jun 29, 2026
f2e7716
chore: bump pro submodule (meeting intelligence + transcription work)
dishit-wednesday Jun 29, 2026
d050a81
fix(whisper): iOS streaming lifetime patch + PATCH-LIVE marker (whisp…
dishit-wednesday Jun 29, 2026
0009c8b
feat(rag): recordings knowledge base - chunk metadata, indexText, see…
dishit-wednesday Jun 29, 2026
ec76b64
perf(chat): keep tool/date prompt prefix cacheable, fixing TTFT
dishit-wednesday Jun 29, 2026
2687940
feat(whisper): CoreML asset guard, force English for .en models, tdrz…
dishit-wednesday Jun 29, 2026
e928e0b
chore: on-device memory-snapshot logging util
dishit-wednesday Jun 29, 2026
f121648
perf(summary): disable model thinking + cap map chunks so summaries s…
dishit-wednesday Jun 29, 2026
edf8325
feat(whisper): log memory footprint around model load
dishit-wednesday Jun 29, 2026
bb143cb
feat(nav): rename Memory tab to Recorder
dishit-wednesday Jun 29, 2026
ff0c194
test(whisper): assert loadModel called with options arg
dishit-wednesday Jun 29, 2026
cc7c0a4
polish(recorder): redesign the Recorder paywall
dishit-wednesday Jun 29, 2026
8f04e4e
chore: bump pro submodule (recorder + meeting intelligence + UI polish)
dishit-wednesday Jun 29, 2026
1cb80c3
test: fix stale assertions + add CoreML/RAG metadata coverage
dishit-wednesday Jul 1, 2026
f31a935
fix(whisper): free VAD PCM buffer to stop long-file OOM
dishit-wednesday Jul 1, 2026
2c2ab81
feat(pro): temporary reset-license testing controls
dishit-wednesday Jul 1, 2026
acab070
chore: bump pro submodule (recorder UI polish + cleanup classifier)
dishit-wednesday Jul 1, 2026
e94d86a
fix(chat): extract ChatInput action button to satisfy max-lines
dishit-wednesday Jul 1, 2026
bfd6c40
chore: point pro submodule at canonical off-grid-ai/mobile-pro
dishit-wednesday Jul 1, 2026
9ec2cfc
refactor(whisper): extract buildTranscribeOpts + split model catalogue
dishit-wednesday Jul 1, 2026
8c93f1f
test(locket): split-planning unit tests + whisper.rn VAD types
dishit-wednesday Jul 6, 2026
e8b7ba6
chore(locket): bump pro submodule (compression + recovery-hydration f…
dishit-wednesday Jul 6, 2026
cb979e3
feat: optional summary prompt overrides (systemPrompt/combinePrompt)
dishit-wednesday Jul 7, 2026
cf2752e
chore(deps): add @notifee/react-native for local scheduled notifications
dishit-wednesday Jul 7, 2026
5c6bc85
chore(locket): bump pro submodule to feat/locket-enhance (v2 enhancem…
dishit-wednesday Jul 7, 2026
aef5ee5
test(locket): review-fix coverage + bump pro submodule
dishit-wednesday Jul 7, 2026
3dd609a
test(locket): kill-safety recovery coverage + bump pro submodule
dishit-wednesday Jul 7, 2026
e2111eb
feat: multi-backend summary dispatch (LiteRT/remote/local)
dishit-wednesday Jul 7, 2026
f3e4ac4
chore: bump pro submodule (multi-backend summaries, meeting reminders…
dishit-wednesday Jul 7, 2026
4526e3d
feat: home.recorder slot + render RecorderHomeCard on Home
dishit-wednesday Jul 7, 2026
a3e2ad5
feat: replace recorder bottom tab with Settings tab
dishit-wednesday Jul 7, 2026
20a3583
chore: bump pro submodule (locket single-screen detail, auto-prune, h…
dishit-wednesday Jul 7, 2026
85c5103
chore(ios): onnxruntime-objc modular headers + bump pro (VAD-gated re…
dishit-wednesday Jul 8, 2026
34c009b
feat: home header cleanup + onnxruntime VAD pod
dishit-wednesday Jul 8, 2026
5b2fa29
chore: bump pro submodule (locket Today/Days/search, batch transcribe…
dishit-wednesday Jul 8, 2026
e412a39
chore: bump pro submodule (VAD gate tightening, VAD comparison tool, …
dishit-wednesday Jul 8, 2026
dc7afd1
chore: bump pro submodule (VAD inference-failure crash guard)
dishit-wednesday Jul 9, 2026
7fa7b22
Merge pull request #502 from off-grid-ai/feat/vad-gated-recorder
dishit-wednesday Jul 9, 2026
60e77cd
Merge pull request #469 from off-grid-ai/feat/locket-pro-enhance
dishit-wednesday Jul 9, 2026
a944066
Merge dev into feat/locket-pro (catch-up reconciliation, 451 commits …
dishit-wednesday Jul 9, 2026
1140016
chore: bump pro submodule (speech-only auto-trim tuning + 0:00 guard …
dishit-wednesday Jul 9, 2026
741e1ab
chore: bump pro submodule (remove diagnostic Check-speech buttons)
dishit-wednesday Jul 9, 2026
cf458ce
chore: bump pro submodule to feat/locket tip (trim + button removal +…
dishit-wednesday Jul 9, 2026
decceca
chore: gitignore dev insights sim harness + scratch screenshots
dishit-wednesday Jul 10, 2026
abe93b1
feat(summarizer/llm): grammar, repeat_penalty, streaming + abort for …
dishit-wednesday Jul 10, 2026
4e7ff47
feat(dev): GBNF grammar test harness across chat, tools, and LiteRT
dishit-wednesday Jul 10, 2026
2ad817c
feat(whisper): iOS CoreML encoder for ANE-accelerated encode
dishit-wednesday Jul 10, 2026
77185b8
chore(assets): update ggml-hexagon HTP libs + add silero VAD model
dishit-wednesday Jul 10, 2026
fb04c84
fix(llm): remove iOS Metal <4GB offload cap
dishit-wednesday Jul 10, 2026
92b9caf
chore: bump pro submodule (locket insights pipeline + hub/detail scre…
dishit-wednesday Jul 10, 2026
2a909a4
feat(summarizer): dynamic context sizing + token streaming + abort
dishit-wednesday Jul 12, 2026
9865b30
feat(litert): expose getContextTokens for backend-aware chunk sizing
dishit-wednesday Jul 12, 2026
0cfecfe
fix(whisper): fall back to another on-disk model when the active one …
dishit-wednesday Jul 12, 2026
1ed573d
chore(pro): bump submodule (DayStrip, day-scoped Insights, card schem…
dishit-wednesday Jul 12, 2026
52d4b86
chore: bump pro submodule to pushed feat/continuous-smart-recorder ti…
dishit-wednesday Jul 12, 2026
1fe0940
test(whisper): update store/service tests for model fallback + CoreML…
dishit-wednesday Jul 12, 2026
62754cd
fix(llm): reset streaming accumulators before ungrammared grammar-retry
dishit-wednesday Jul 12, 2026
4c73b24
chore(pro): bump submodule to review fixes (scheduler teardown + auto…
dishit-wednesday Jul 12, 2026
ce30b39
fix(whisper): drop deleted model from presentModelIds on active-model…
dishit-wednesday Jul 12, 2026
0feedca
chore(pro): bump submodule to off-thread checkpoint scan fix (audio-d…
dishit-wednesday Jul 12, 2026
f7d524f
chore(pro): bump submodule (permissions + player trim + wip) & add ha…
dishit-wednesday Jul 13, 2026
bac57f4
fix(whisper): pass maxContext=0 to stop repetition/hallucination on n…
dishit-wednesday Jul 13, 2026
156b819
Merge remote-tracking branch 'upstream/feat/locket-pro' into feat/vad…
dishit-wednesday Jul 13, 2026
31b9f34
feat(ui): add a global Toast component and mount it in App
dishit-wednesday Jul 13, 2026
15cdbde
feat(insights): route summaries to a remote provider when one is active
dishit-wednesday Jul 13, 2026
ff9a273
feat(whisper): accept an optional vocabulary hint as the initial prompt
dishit-wednesday Jul 13, 2026
244c7dc
fix(litert): drain fbjni finalizer at conversation teardown to avoid …
dishit-wednesday Jul 13, 2026
1a5d48f
chore(pro): bump submodule (insights transcribe, vocabulary, settings…
dishit-wednesday Jul 13, 2026
13b563e
feat(models): memory-aware text-model selector
dishit-wednesday Jul 13, 2026
6a24d79
feat(models): text-only load option to skip the vision mmproj clip
dishit-wednesday Jul 13, 2026
48a958e
chore(pro): bump submodule (memory-aware auto-load + battery-exemptio…
dishit-wednesday Jul 13, 2026
f9ef008
chore(pro): bump submodule to locket unified feed (+ residency helper…
dishit-wednesday Jul 14, 2026
a43a71b
chore: remove accidentally-committed scratch files (android/temp.txt,…
dishit-wednesday Jul 14, 2026
aa94f68
fix(summarizer): abort() also stops the active remote provider
dishit-wednesday Jul 14, 2026
18cd101
fix(litert): strip temporary DIAG-UAF logging from the conversation h…
dishit-wednesday Jul 14, 2026
98681a2
chore(pro): bump submodule to review fixes (brief-card tidy, battery …
dishit-wednesday Jul 14, 2026
e8fc497
fix(whisper): keep non-ASCII transcripts + guard overlapping file tra…
dishit-wednesday Jul 14, 2026
6b285fa
feat(services): export modelResidencyManager for a single-source RAM …
dishit-wednesday Jul 14, 2026
3133d64
chore(docs): remove pro/locket planning + handoff docs from open-core
dishit-wednesday Jul 15, 2026
79f0ee8
Merge pull request #532 from off-grid-ai/feat/vad-gated-recorder
dishit-wednesday Jul 15, 2026
bbd65ea
Merge offgrid/dev into locket-pro (core catch-up)
dishit-wednesday Jul 15, 2026
617f548
chore(locket): bump pro submodule to caught-up pin + mock its native …
dishit-wednesday Jul 15, 2026
d79d489
chore(locket): repoint pro pin to the LATEST recorder branch catch-up
dishit-wednesday Jul 15, 2026
3b76a7c
fix(whisper): restore fallback-recorder cancel in forceReset (dropped…
dishit-wednesday Jul 16, 2026
41765af
fix(whisper): scope forceReset's recorder-cancel to the realtime sess…
dishit-wednesday Jul 16, 2026
1bdc6c3
Merge pull request #559 from off-grid-ai/chore/locket-catchup-dev
dishit-wednesday Jul 16, 2026
f0c05eb
Merge remote-tracking branch 'offgrid/dev' into chore/locket-catchup-…
dishit-wednesday Jul 16, 2026
14b343d
fix(core): re-export stripMarkdownForSpeech for the pro audio submodule
dishit-wednesday Jul 16, 2026
197c210
chore(locket): bump pro pin to dev's current pro (ff0d8742) — audio s…
dishit-wednesday Jul 16, 2026
f842650
chore(core): drop the vestigial stripMarkdownForSpeech export (audit …
dishit-wednesday Jul 16, 2026
8888a46
Merge pull request #562 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
16e59b8
chore(locket): point pro pin at feat/locket (the → main base) after b…
dishit-wednesday Jul 16, 2026
1d00a31
fix(core): re-export AlertButton + Chunk for pro; bump pro pin to fea…
dishit-wednesday Jul 16, 2026
8453202
Merge pull request #566 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
ccda30f
chore(recorder): drop the Silero VAD model from public core (now bund…
dishit-wednesday Jul 16, 2026
89843be
Merge pull request #567 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ fastlane/*.p8
!.yarn/sdks
!.yarn/versions
docs/TRACTION_KNOWLEDGE_BASE.md
# Dev-only insights sim harness + private real recordings (never commit)
/sim/
# Debug screenshots (scratch)
/image.png
/image-*.png
# Local marketing drafts (not part of the app)
marketing/

Expand Down
2 changes: 2 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { LockScreen } from './src/screens';
import { useAppState } from './src/hooks/useAppState';
import { useDownloadStore } from './src/stores/downloadStore';
import { ErrorBoundary } from './src/components/ErrorBoundary';
import { Toast } from './src/components';

LogBox.ignoreAllLogs(); // Suppress all logs

Expand Down Expand Up @@ -392,6 +393,7 @@ function App() {
>
<AppNavigator />
</NavigationContainer>
<Toast />
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand Down
9 changes: 6 additions & 3 deletions __tests__/rntl/navigation/AppNavigator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ describe('AppNavigator', () => {
});

describe('Tab bar rendering', () => {
it('renders all five tab labels', () => {
it('renders all five tab labels (Recorder replaced by Settings)', () => {
const { getAllByText } = renderAppNavigator();

expect(getAllByText('Home').length).toBeGreaterThanOrEqual(1);
Expand All @@ -198,6 +198,11 @@ describe('AppNavigator', () => {
expect(getByTestId('models-tab')).toBeTruthy();
expect(getByTestId('settings-tab')).toBeTruthy();
});

it('no longer renders a Recorder tab (moved to a Home card)', () => {
const { queryByTestId } = renderAppNavigator();
expect(queryByTestId('recorder-tab')).toBeNull();
});
});

describe('Tab bar safe area insets', () => {
Expand Down Expand Up @@ -279,14 +284,12 @@ describe('AppNavigator', () => {
expect(getAllByText('Chats').length).toBeGreaterThanOrEqual(1);
expect(getAllByText('Projects').length).toBeGreaterThanOrEqual(1);
expect(getAllByText('Models').length).toBeGreaterThanOrEqual(1);
expect(getAllByText('Settings').length).toBeGreaterThanOrEqual(1);

// All tab buttons should be pressable
expect(getByTestId('home-tab')).toBeTruthy();
expect(getByTestId('chats-tab')).toBeTruthy();
expect(getByTestId('projects-tab')).toBeTruthy();
expect(getByTestId('models-tab')).toBeTruthy();
expect(getByTestId('settings-tab')).toBeTruthy();
});
});
});
4 changes: 2 additions & 2 deletions __tests__/rntl/onboarding/HomeScreenSpotlight.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,15 @@ describe('HomeScreen Spotlight Integration', () => {
// Flow 5: Explore Settings
// ========================================================================
describe('Flow 5: exploredSettings', () => {
it('queues pending spotlight 6, navigates to SettingsTab, fires goTo(5)', () => {
it('queues pending spotlight 6, navigates to Settings, fires goTo(5)', () => {
const { getByTestId } = renderHomeScreen();

act(() => {
fireEvent.press(getByTestId('step-exploredSettings'));
});

expect(peekPendingSpotlight()).toBe(6);
expect(mockNavigate).toHaveBeenCalledWith('SettingsTab');
expect(mockNavigate).toHaveBeenCalledWith('Settings');

act(() => { jest.advanceTimersByTime(800); });
expect(mockGoTo).toHaveBeenCalledWith(5);
Expand Down
4 changes: 2 additions & 2 deletions __tests__/unit/onboarding/handleStepPress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,9 @@ describe('handleStepPress', () => {
expect(peekPendingSpotlight()).toBe(6);
});

it('navigates to SettingsTab', () => {
it('navigates to Settings', () => {
simulateHandleStepPress('exploredSettings', callbacks());
expect(navigate).toHaveBeenCalledWith('SettingsTab');
expect(navigate).toHaveBeenCalledWith('Settings');
});

it('fires goTo(5) after delay', () => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/unit/onboarding/onboardingFlows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('Onboarding Flows', () => {
downloadedModel: 'ModelsTab',
loadedModel: 'HomeTab',
sentMessage: 'ChatsTab',
exploredSettings: 'SettingsTab',
exploredSettings: 'Settings',
createdProject: 'ProjectsTab',
triedImageGen: 'ModelsTab',
});
Expand Down
26 changes: 21 additions & 5 deletions __tests__/unit/services/rag/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ describe('RagDatabase', () => {
it('opens the database and creates tables', async () => {
await ragDatabase.ensureReady();
expect(open).toHaveBeenCalledWith({ name: 'rag.db' });
// rag_documents, rag_chunks, rag_embeddings = 3 tables
expect(mockExecuteSync).toHaveBeenCalledTimes(3);
// rag_documents, rag_chunks, ALTER rag_chunks (metadata migration), rag_embeddings
expect(mockExecuteSync).toHaveBeenCalledTimes(4);
expect(mockExecuteSync.mock.calls[0][0]).toContain('rag_documents');
expect(mockExecuteSync.mock.calls[1][0]).toContain('rag_chunks');
expect(mockExecuteSync.mock.calls[2][0]).toContain('rag_embeddings');
expect(mockExecuteSync.mock.calls[2][0]).toContain('ALTER TABLE rag_chunks');
expect(mockExecuteSync.mock.calls[2][0]).toContain('metadata');
expect(mockExecuteSync.mock.calls[3][0]).toContain('rag_embeddings');
});

it('does not re-initialize on second call', async () => {
Expand Down Expand Up @@ -86,8 +88,22 @@ describe('RagDatabase', () => {
(c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_chunks')
);
expect(chunkInserts).toHaveLength(2);
expect(chunkInserts[0][1]).toEqual(['chunk one', 42, 0]);
expect(chunkInserts[1][1]).toEqual(['chunk two', 42, 1]);
// 4th bind is metadata (null when the chunk carries none).
expect(chunkInserts[0][1]).toEqual(['chunk one', 42, 0, null]);
expect(chunkInserts[1][1]).toEqual(['chunk two', 42, 1, null]);
});

it('serializes chunk metadata to a JSON string on the way into the DB', async () => {
await ragDatabase.ensureReady();
mockExecuteSync.mockReturnValue({ insertId: 7, rowsAffected: 1, rows: [] });

const metadata = { recordingId: 'rec-1', startMs: 100, eventTitle: 'Standup' };
ragDatabase.insertChunks(42, [{ content: 'has meta', position: 0, metadata } as any]);

const chunkInsert = mockExecuteSync.mock.calls.find(
(c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_chunks'),
);
expect(chunkInsert![1]).toEqual(['has meta', 42, 0, JSON.stringify(metadata)]);
});
});

Expand Down
63 changes: 63 additions & 0 deletions __tests__/unit/services/selectTextModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { selectTextModelToLoad, fitsBudget } from '../../../src/services/selectTextModel';
import type { DownloadedModel } from '../../../src/types';

const MB = 1024 * 1024;

function model(id: string, fileSizeMB: number): DownloadedModel {
return {
id,
name: id,
author: 'test',
filePath: `/models/${id}`,
fileName: `${id}.gguf`,
fileSize: fileSizeMB * MB,
quantization: 'Q4',
downloadedAt: '2026-07-13',
engine: 'llama',
};
}

// Footprint = fileSize in MB (1x) — the selection logic is independent of the
// multiplier; the real caller passes hardwareService.estimateModelRam.
const footprint = (m: DownloadedModel) => (m.fileSize || 0) / MB;

const small = model('small', 500);
const medium = model('medium', 1000);
const large = model('large', 3000);

describe('fitsBudget', () => {
it('fits when footprint <= budget, not otherwise', () => {
expect(fitsBudget(1000, 1000)).toBe(true); // exactly fits
expect(fitsBudget(1001, 1000)).toBe(false);
});
});

describe('selectTextModelToLoad', () => {
it('returns null when nothing is downloaded', () => {
expect(selectTextModelToLoad([], 4000, { activeId: null, footprintMB: footprint })).toBeNull();
expect(selectTextModelToLoad([], 4000, { activeId: 'medium', footprintMB: footprint })).toBeNull();
});

it('uses the active model when it fits the budget', () => {
expect(selectTextModelToLoad([small, medium, large], 2000, { activeId: 'small', footprintMB: footprint })?.id).toBe('small');
});

it('ignores the active model when it does NOT fit, and picks the largest that fits', () => {
// budget 2000: large(3000) does not fit -> largest fitting is medium(1000)
expect(selectTextModelToLoad([small, medium, large], 2000, { activeId: 'large', footprintMB: footprint })?.id).toBe('medium');
});

it('with no active id, picks the largest model that fits (best quality within RAM)', () => {
expect(selectTextModelToLoad([small, medium, large], 2000, { activeId: null, footprintMB: footprint })?.id).toBe('medium');
expect(selectTextModelToLoad([small, medium, large], 4000, { activeId: null, footprintMB: footprint })?.id).toBe('large');
});

it('falls back to the SMALLEST when nothing fits (run something, not an OOM)', () => {
// budget 400: smallest is small(500) > 400, nothing fits -> smallest
expect(selectTextModelToLoad([small, medium, large], 400, { activeId: 'large', footprintMB: footprint })?.id).toBe('small');
});

it('ignores an active id that is not among the downloaded models', () => {
expect(selectTextModelToLoad([small, medium], 2000, { activeId: 'ghost', footprintMB: footprint })?.id).toBe('medium');
});
});
43 changes: 41 additions & 2 deletions __tests__/unit/services/whisperService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,49 @@ describe('WhisperService', () => {

await whisperService.loadModel('/path/to/model.bin');

expect(initWhisper).toHaveBeenCalledWith({ filePath: '/path/to/model.bin' });
expect(initWhisper).toHaveBeenCalledWith({
filePath: '/path/to/model.bin',
useGpu: false,
useFlashAttn: false,
// The test's RNFS.exists mock reports the CoreML encoder present, so
// loadModel auto-enables ANE CoreML on iOS.
useCoreMLIos: true,
});
expect(whisperService.isModelLoaded()).toBe(true);
expect(whisperService.getLoadedModelPath()).toBe('/path/to/model.bin');
});

it('falls back to CPU when CoreML requested but the encoder asset is missing', async () => {
// Valid model file, but the ggml-<model>-encoder.mlmodelc bundle is absent.
// Enabling CoreML without it makes whisper.rn crash at 0% on some iOS devices,
// so the guard must silently downgrade to CPU (useCoreMLIos: false).
mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any);
mockedRNFS.exists.mockImplementation(async (p: string) =>
!p.endsWith('-encoder.mlmodelc'),
);
const mockContext = { id: 'ctx', release: jest.fn(), transcribeRealtime: jest.fn(), transcribe: jest.fn() };
mockedInitWhisper.mockResolvedValue(mockContext as any);

await whisperService.loadModel('/path/to/model.bin', { useCoreML: true });

expect(initWhisper).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/path/to/model.bin', useCoreMLIos: false }),
);
});

it('enables CoreML when the encoder asset is present', async () => {
mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any);
mockedRNFS.exists.mockResolvedValue(true); // both the .bin and the .mlmodelc exist
const mockContext = { id: 'ctx', release: jest.fn(), transcribeRealtime: jest.fn(), transcribe: jest.fn() };
mockedInitWhisper.mockResolvedValue(mockContext as any);

await whisperService.loadModel('/path/to/model.bin', { useCoreML: true, useGpu: true, useFlashAttn: true });

expect(initWhisper).toHaveBeenCalledWith(
expect.objectContaining({ useCoreMLIos: true, useGpu: true, useFlashAttn: true }),
);
});

it('unloads different model before loading new one', async () => {
mockValidModelFile();
const mockContext1 = {
Expand Down Expand Up @@ -788,7 +826,8 @@ describe('WhisperService', () => {
})),
};
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
await whisperService.loadModel('/path/model.bin');
// English-only model (.en.bin) so transcribeFile forces language 'en'.
await whisperService.loadModel('/path/model.en.bin');

const result = await whisperService.transcribeFile('/audio.wav');

Expand Down
68 changes: 68 additions & 0 deletions __tests__/unit/utils/memorySnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* memorySnapshot unit tests
*
* logMemory() is a diagnostics probe used around whisper model load and each
* transcribe chunk to capture the app's footprint. On iOS it surfaces whether
* an apparent transcription "crash" was actually a jetsam low-memory kill.
*
* Guarantees under test:
* - formats used/total in MB and a percentage
* - never throws (a failing probe must not break the path it observes)
* - no divide-by-zero when total memory is reported as 0
*/

import DeviceInfo from 'react-native-device-info';
import logger from '../../../src/utils/logger';
import { logMemory } from '../../../src/utils/memorySnapshot';

const mockedDeviceInfo = DeviceInfo as jest.Mocked<typeof DeviceInfo>;

describe('logMemory', () => {
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
logSpy = jest.spyOn(logger, 'log').mockImplementation(() => {});
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
});

afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
});

it('logs used/total in MB with a percentage, tagged with the call site', async () => {
mockedDeviceInfo.getUsedMemory.mockResolvedValue(1.4 * 1024 * 1024 * 1024);
mockedDeviceInfo.getTotalMemory.mockResolvedValue(4 * 1024 * 1024 * 1024);

await logMemory('whisper:beforeLoad');

expect(logSpy).toHaveBeenCalledTimes(1);
const msg = logSpy.mock.calls[0][0] as string;
expect(msg).toContain('[mem] whisper:beforeLoad');
expect(msg).toContain('used=1434MB');
expect(msg).toContain('total=4096MB');
expect(msg).toContain('(35%)');
});

it('never throws and warns when the probe fails', async () => {
mockedDeviceInfo.getUsedMemory.mockRejectedValue(new Error('boom'));
mockedDeviceInfo.getTotalMemory.mockResolvedValue(4 * 1024 * 1024 * 1024);

await expect(logMemory('transcribe:chunk@0s')).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('snapshot failed'));
expect(logSpy).not.toHaveBeenCalled();
});

it('does not divide by zero when total memory is unavailable', async () => {
mockedDeviceInfo.getUsedMemory.mockResolvedValue(100 * 1024 * 1024);
mockedDeviceInfo.getTotalMemory.mockResolvedValue(0);

await logMemory('zero');

const msg = logSpy.mock.calls[0][0] as string;
expect(msg).toContain('total=0MB');
expect(msg).toContain('(0%)');
});
});
Loading