-
-
Notifications
You must be signed in to change notification settings - Fork 239
feat(recorder): core host for on-device recorder + whisper transcription #430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9d184e1
dca0b1a
6041359
4571d4f
5af7bbe
6a838e0
44d8694
684fce5
93c3be9
58b5656
7395249
a7aea86
2bc1aa9
d91a962
39cf93a
863981f
dc7a08a
62bd573
5164294
bf4ea87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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%)'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| // Autolink the pro submodule's native library ONLY when it is actually on | ||
| // disk. Mirrors the fs.existsSync(pro) guard metro.config.js uses for the pro | ||
| // JS: a public clone without the private submodule sees an empty/absent pro/ | ||
| // dir, this entry is omitted, and the open build compiles with no pro native. | ||
| // | ||
| // IMPORTANT: check a real file inside pro/, never just the pro/ directory - an | ||
| // uninitialised submodule leaves an empty pro/ folder behind. | ||
| const proRoot = path.resolve(__dirname, 'pro'); | ||
| const proAndroidGradle = path.join(proRoot, 'android', 'build.gradle'); | ||
| const proPodspec = path.join(proRoot, 'ios', 'OffgridPro.podspec'); | ||
| const proHasNative = fs.existsSync(proAndroidGradle); | ||
|
|
||
| module.exports = { | ||
| dependencies: { | ||
| ...(proHasNative | ||
| ? { | ||
| '@offgrid/pro': { | ||
| root: proRoot, | ||
| platforms: { | ||
| android: { | ||
| sourceDir: path.join(proRoot, 'android'), | ||
| packageImportPath: 'import ai.offgridmobile.alwayson.AlwaysOnTranscriptionPackage;', | ||
| packageInstance: 'new AlwaysOnTranscriptionPackage()', | ||
| }, | ||
| ios: { | ||
| podspecPath: proPodspec, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| : {}), | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -134,9 +134,14 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ navigation }) => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Text style={styles.title}>Off Grid</Text> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {showIcon && <PulsatingIcon onPress={openSheet} />} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <TouchableOpacity onPress={() => navigation.navigate('ProDetail')} hitSlop={8} style={styles.crownButton}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <IconMC name="crown" size={16} color={colors.primary} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </TouchableOpacity> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <View style={styles.headerRight}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <TouchableOpacity onPress={() => navigation.navigate('Settings')} hitSlop={8} style={styles.iconButton}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Icon name="settings" size={18} color={colors.textSecondary} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </TouchableOpacity> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <TouchableOpacity onPress={() => navigation.navigate('ProDetail')} hitSlop={8} style={styles.crownButton}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <IconMC name="crown" size={16} color={colors.primary} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </TouchableOpacity> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+137
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Add accessibility labels to the new icon-only header actions. These buttons are icon-only, so screen-reader users won’t get a reliable action name here without explicit labels. Suggested fix- <TouchableOpacity onPress={() => navigation.navigate('Settings')} hitSlop={8} style={styles.iconButton}>
+ <TouchableOpacity
+ accessibilityRole="button"
+ accessibilityLabel="Open settings"
+ onPress={() => navigation.navigate('Settings')}
+ hitSlop={8}
+ style={styles.iconButton}
+ >
<Icon name="settings" size={18} color={colors.textSecondary} />
</TouchableOpacity>
- <TouchableOpacity onPress={() => navigation.navigate('ProDetail')} hitSlop={8} style={styles.crownButton}>
+ <TouchableOpacity
+ accessibilityRole="button"
+ accessibilityLabel="Open Pro details"
+ onPress={() => navigation.navigate('ProDetail')}
+ hitSlop={8}
+ style={styles.crownButton}
+ >
<IconMC name="crown" size={16} color={colors.primary} />
</TouchableOpacity>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* Collapsed Models summary — tap to open the manager sheet. Both the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Guard the iOS autolink with the podspec as well.
Line 14 uses
pro/android/build.gradleto enable both platform entries, so a checkout with Android native files present butpro/ios/OffgridPro.podspecmissing will still hand CocoaPods a deadpodspecPathand breakpod install. Split the guard per platform, or at least include the podspec in the condition.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents