Skip to content

Commit 654a0b9

Browse files
committed
feat: Hybrid sleep classifier with sensor calibration and historical priors
Major changes: - Create hybrid classifier combining mic + HR with probability fusion - Add SensorCalibration component for dream mode (mic + HR) - Simplify MicrophoneTest to mic-only for settings - Add prior night sleep stage proportions as classifier prior - Remove HRV display from settings (not available on Pixel) - Force model retrain at session start - Add awake prior that fades over 5 minutes Files: - services/hybridClassifier.ts (NEW): Probability-based fusion - components/SensorCalibration.tsx (NEW): Dual-sensor calibration - services/sleepStageLearning.ts: Stage proportions calculation - services/sleep.ts: Hybrid classifier integration - app/(tabs)/dream.tsx: Use SensorCalibration - app/(tabs)/settings.tsx: Remove HRV display - components/MicrophoneTest.tsx: Simplified mic-only test
1 parent 86ccba8 commit 654a0b9

13 files changed

Lines changed: 1914 additions & 207 deletions

app/(tabs)/dream.tsx

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Text, Heading } from '@/components/ui/Text';
88
import { Button } from '@/components/ui/Button';
99
import { useThemedAlert } from '@/components/ui/ThemedAlert';
1010
import { VolumeSetup } from '@/components/VolumeSetup';
11-
import { MicrophoneTest } from '@/components/MicrophoneTest';
11+
import { SensorCalibration } from '@/components/SensorCalibration';
1212
import { SleepStageGraph } from '@/components/SleepStageGraph';
1313
import { SleepHistoryCard } from '@/components/SleepHistoryCard';
1414
import { SleepSessionDetailModal } from '@/components/SleepSessionDetailModal';
@@ -22,8 +22,12 @@ import {
2222
onRemStart,
2323
onRemEnd,
2424
onStageHistoryChange,
25+
onSleepStageChange,
26+
startCalibrationTest,
27+
stopCalibrationTest,
2528
type StageHistoryEntry,
2629
} from '@/services/sleep';
30+
import type { SleepStage } from '@/types/database';
2731
import { storage } from '@/lib/storage';
2832
import { fadeOut, configureSleepAudioSession } from '@/services/volume';
2933
import { colors, spacing, borderRadius } from '@/theme/tokens';
@@ -37,7 +41,7 @@ export default function DreamScreen() {
3741
const { queue, getNext, complete } = useLaunchQueue();
3842

3943
const [showVolumeSetup, setShowVolumeSetup] = useState(false);
40-
const [showMicTest, setShowMicTest] = useState(false);
44+
const [showSensorCalibration, setShowSensorCalibration] = useState(false);
4145
const [selectedSession, setSelectedSession] = useState<SleepSession | null>(null);
4246
const [showSleepSummary, setShowSleepSummary] = useState(false);
4347
const [sleepSummaryData, setSleepSummaryData] = useState<{
@@ -62,7 +66,9 @@ export default function DreamScreen() {
6266
const remUnsubscribeRef = useRef<(() => void) | null>(null);
6367
const remEndUnsubscribeRef = useRef<(() => void) | null>(null);
6468
const stageHistoryUnsubscribeRef = useRef<(() => void) | null>(null);
69+
const meditationSleepUnsubscribeRef = useRef<(() => void) | null>(null);
6570
const currentQueueIdRef = useRef<string | null>(null);
71+
const isMeditationFadingRef = useRef(false);
6672

6773
useEffect(() => {
6874
return () => {
@@ -106,8 +112,53 @@ export default function DreamScreen() {
106112
} catch {}
107113
meditationSoundRef.current = null;
108114
}
115+
if (meditationSleepUnsubscribeRef.current) {
116+
meditationSleepUnsubscribeRef.current();
117+
meditationSleepUnsubscribeRef.current = null;
118+
}
119+
stopCalibrationTest();
109120
};
110121

122+
const handleSleepDetectedDuringMeditation = useCallback(
123+
async (stage: SleepStage) => {
124+
if (stage === 'awake' || !meditationSoundRef.current || isMeditationFadingRef.current) {
125+
return;
126+
}
127+
128+
console.log('[Dream] Sleep detected during meditation:', stage);
129+
isMeditationFadingRef.current = true;
130+
131+
try {
132+
const status = await meditationSoundRef.current.getStatusAsync();
133+
if (!status.isLoaded) return;
134+
135+
const currentVolume = status.volume ?? 1.0;
136+
await fadeOut(meditationSoundRef.current, currentVolume, 10000);
137+
138+
await meditationSoundRef.current.stopAsync();
139+
await meditationSoundRef.current.unloadAsync();
140+
meditationSoundRef.current = null;
141+
142+
if (meditationSleepUnsubscribeRef.current) {
143+
meditationSleepUnsubscribeRef.current();
144+
meditationSleepUnsubscribeRef.current = null;
145+
}
146+
stopCalibrationTest();
147+
148+
setIsMeditating(false);
149+
setIsMeditationPaused(false);
150+
setMeditationProgress(0);
151+
152+
await start('audio');
153+
} catch (err) {
154+
console.warn('Failed to handle sleep transition:', err);
155+
} finally {
156+
isMeditationFadingRef.current = false;
157+
}
158+
},
159+
[start]
160+
);
161+
111162
const handleRemStart = async () => {
112163
console.log('[Dream] REM detected - starting playback');
113164
if (isPlaying) return;
@@ -218,6 +269,11 @@ export default function DreamScreen() {
218269
try {
219270
await configureSleepAudioSession();
220271

272+
await startCalibrationTest();
273+
meditationSleepUnsubscribeRef.current = onSleepStageChange(
274+
handleSleepDetectedDuringMeditation
275+
);
276+
221277
const { sound, status } = await Audio.Sound.createAsync(
222278
{ uri: getMeditationUrl() },
223279
{
@@ -236,6 +292,11 @@ export default function DreamScreen() {
236292
}
237293

238294
if (playbackStatus.didJustFinish) {
295+
if (meditationSleepUnsubscribeRef.current) {
296+
meditationSleepUnsubscribeRef.current();
297+
meditationSleepUnsubscribeRef.current = null;
298+
}
299+
stopCalibrationTest();
239300
setIsMeditating(false);
240301
setIsMeditationPaused(false);
241302
setMeditationProgress(0);
@@ -252,15 +313,25 @@ export default function DreamScreen() {
252313
setIsMeditationPaused(false);
253314
} catch (err) {
254315
console.warn('Failed to play meditation:', err);
316+
if (meditationSleepUnsubscribeRef.current) {
317+
meditationSleepUnsubscribeRef.current();
318+
meditationSleepUnsubscribeRef.current = null;
319+
}
320+
stopCalibrationTest();
255321
start('audio').catch(() => {
256322
showAlert('Error', 'Failed to start sleep tracking.');
257323
});
258324
}
259325
},
260-
[start]
326+
[start, handleSleepDetectedDuringMeditation]
261327
);
262328

263329
const skipMeditation = useCallback(async () => {
330+
if (meditationSleepUnsubscribeRef.current) {
331+
meditationSleepUnsubscribeRef.current();
332+
meditationSleepUnsubscribeRef.current = null;
333+
}
334+
stopCalibrationTest();
264335
if (meditationSoundRef.current) {
265336
try {
266337
await meditationSoundRef.current.stopAsync();
@@ -311,6 +382,11 @@ export default function DreamScreen() {
311382
}, [playMeditation]);
312383

313384
const stopMeditation = useCallback(async () => {
385+
if (meditationSleepUnsubscribeRef.current) {
386+
meditationSleepUnsubscribeRef.current();
387+
meditationSleepUnsubscribeRef.current = null;
388+
}
389+
stopCalibrationTest();
314390
if (meditationSoundRef.current) {
315391
try {
316392
await meditationSoundRef.current.stopAsync();
@@ -325,11 +401,11 @@ export default function DreamScreen() {
325401

326402
const handleVolumeComplete = useCallback(() => {
327403
setShowVolumeSetup(false);
328-
setShowMicTest(true);
404+
setShowSensorCalibration(true);
329405
}, []);
330406

331-
const handleMicTestComplete = useCallback(async () => {
332-
setShowMicTest(false);
407+
const handleCalibrationComplete = useCallback(async () => {
408+
setShowSensorCalibration(false);
333409

334410
if (helpMeFallAsleep) {
335411
await playMeditation();
@@ -339,14 +415,14 @@ export default function DreamScreen() {
339415
} catch {
340416
showAlert(
341417
'Error',
342-
'Failed to start sleep tracking. Please ensure microphone access is enabled.'
418+
'Failed to start sleep tracking. Please ensure sensor access is enabled.'
343419
);
344420
}
345421
}
346422
}, [start, helpMeFallAsleep, playMeditation]);
347423

348-
const handleMicTestSkip = useCallback(async () => {
349-
setShowMicTest(false);
424+
const handleCalibrationSkip = useCallback(async () => {
425+
setShowSensorCalibration(false);
350426

351427
if (helpMeFallAsleep) {
352428
await playMeditation();
@@ -356,7 +432,7 @@ export default function DreamScreen() {
356432
} catch {
357433
showAlert(
358434
'Error',
359-
'Failed to start sleep tracking. Please ensure microphone access is enabled.'
435+
'Failed to start sleep tracking. Please ensure sensor access is enabled.'
360436
);
361437
}
362438
}
@@ -733,25 +809,28 @@ export default function DreamScreen() {
733809
onComplete={handleVolumeComplete}
734810
onSkip={() => {
735811
setShowVolumeSetup(false);
736-
setShowMicTest(true);
812+
setShowSensorCalibration(true);
737813
}}
738814
/>
739815
</SafeAreaView>
740816
</Modal>
741817

742818
<Modal
743-
visible={showMicTest}
819+
visible={showSensorCalibration}
744820
animationType="slide"
745821
presentationStyle="pageSheet"
746-
onRequestClose={() => setShowMicTest(false)}
822+
onRequestClose={() => setShowSensorCalibration(false)}
747823
>
748824
<SafeAreaView style={styles.modalContainer} edges={['top', 'bottom']}>
749825
<View style={styles.modalHeader}>
750-
<Pressable onPress={() => setShowMicTest(false)} style={styles.closeButton}>
826+
<Pressable onPress={() => setShowSensorCalibration(false)} style={styles.closeButton}>
751827
<Ionicons name="close" size={24} color={colors.gray[400]} />
752828
</Pressable>
753829
</View>
754-
<MicrophoneTest onComplete={handleMicTestComplete} onSkip={handleMicTestSkip} />
830+
<SensorCalibration
831+
onComplete={handleCalibrationComplete}
832+
onSkip={handleCalibrationSkip}
833+
/>
755834
</SafeAreaView>
756835
</Modal>
757836
</SafeAreaView>

app/(tabs)/settings.tsx

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
clearModel,
2525
type DebugReport,
2626
} from '@/services/sleepStageLearning';
27+
import {
28+
runHealthConnectDebugReport,
29+
formatHealthConnectDebugReport,
30+
} from '@/services/healthConnect';
2731

2832
export default function SettingsScreen() {
2933
const [showSleepDebug, setShowSleepDebug] = useState(false);
@@ -33,6 +37,9 @@ export default function SettingsScreen() {
3337
const [showModelDebug, setShowModelDebug] = useState(false);
3438
const [modelDebugReport, setModelDebugReport] = useState<string | null>(null);
3539
const [isLoadingModelDebug, setIsLoadingModelDebug] = useState(false);
40+
const [showHCDebug, setShowHCDebug] = useState(false);
41+
const [hcDebugReport, setHcDebugReport] = useState<string | null>(null);
42+
const [isLoadingHCDebug, setIsLoadingHCDebug] = useState(false);
3643

3744
const health = useHealth();
3845

@@ -65,6 +72,19 @@ export default function SettingsScreen() {
6572
setModelDebugReport('Model cleared. Tap "Run Debug Report" to see current state.');
6673
};
6774

75+
const handleRunHCDebug = async () => {
76+
setIsLoadingHCDebug(true);
77+
setHcDebugReport(null);
78+
try {
79+
const report = await runHealthConnectDebugReport(24);
80+
setHcDebugReport(formatHealthConnectDebugReport(report));
81+
} catch (error) {
82+
setHcDebugReport(`Error: ${error}`);
83+
} finally {
84+
setIsLoadingHCDebug(false);
85+
}
86+
};
87+
6888
useEffect(() => {
6989
if (showWearable && health.status?.permissionsGranted) {
7090
health.startVitalsPolling();
@@ -157,12 +177,6 @@ export default function SettingsScreen() {
157177
{health.vitals?.heartRate ?? '--'} bpm
158178
</Text>
159179
</View>
160-
<View style={styles.vitalItem}>
161-
<Ionicons name="pulse" size={16} color={colors.accent.cyan} />
162-
<Text variant="caption" color="primary">
163-
{health.vitals?.hrv?.toFixed(0) ?? '--'} ms HRV
164-
</Text>
165-
</View>
166180
<View style={styles.pollingIndicator}>
167181
{health.isPolling && <View style={styles.pollingDot} />}
168182
<Pressable style={styles.refreshButton} onPress={health.refreshVitals}>
@@ -332,6 +346,55 @@ export default function SettingsScreen() {
332346
)}
333347
</View>
334348
)}
349+
350+
{(Platform.OS === 'android' || Platform.OS === 'ios') && (
351+
<>
352+
<MenuRow
353+
icon="medkit-outline"
354+
label="Health Connect Data Debug"
355+
onPress={() => setShowHCDebug(!showHCDebug)}
356+
/>
357+
{showHCDebug && (
358+
<View style={styles.expandedSection}>
359+
<View style={styles.debugButtonRow}>
360+
<Pressable
361+
style={styles.debugButton}
362+
onPress={handleRunHCDebug}
363+
disabled={isLoadingHCDebug}
364+
>
365+
{isLoadingHCDebug ? (
366+
<ActivityIndicator size="small" color={colors.primary[500]} />
367+
) : (
368+
<Ionicons name="play" size={16} color={colors.primary[500]} />
369+
)}
370+
<Text variant="caption" color="primary">
371+
Run Debug Report
372+
</Text>
373+
</Pressable>
374+
</View>
375+
<Text variant="caption" color="muted" style={{ marginBottom: spacing.sm }}>
376+
Shows all Health Connect data available from your wearable (last 24h)
377+
</Text>
378+
{hcDebugReport && (
379+
<ScrollView
380+
style={styles.debugOutput}
381+
horizontal={false}
382+
nestedScrollEnabled={true}
383+
>
384+
<Text
385+
variant="caption"
386+
color="muted"
387+
style={styles.debugOutputText}
388+
selectable={true}
389+
>
390+
{hcDebugReport}
391+
</Text>
392+
</ScrollView>
393+
)}
394+
</View>
395+
)}
396+
</>
397+
)}
335398
</View>
336399

337400
<View style={styles.versionSection}>

0 commit comments

Comments
 (0)