The project started as a free alternative to subscription apps like SnoreLab, inspired by Tareq Khan's paper "A Deep Learning Model for Snoring Detection and Vibration Notification Using a Smart Wearable Gadget" — a CNN trained on MFCC features of 1-second audio clips reaching 96% accuracy on a 1,000-sample dataset.
It grew from a solo snore recorder into a snoring × sleep-stage analyzer with an Apple Watch remote, and along the way ran into an iOS 18 platform restriction that broke the entire premise of the app — detection while the screen is locked. Fixing that is the most interesting engineering story here, and it is what pulled the custom Create ML model forward from "optional upgrade path" to "required".
| Area | Tooling |
|---|---|
| Platform | iOS 17+ (iPhone) and watchOS, Swift + SwiftUI |
| Audio capture | AVAudioEngine input tap, AVAudioSession in .record with the audio + processing background modes |
| ML inference | Custom Create ML sound classifier, run through SNAudioStreamAnalyzer forced to CPU-only compute units |
| Persistence | SwiftData (SleepSession → SnoreEvent), audio clips as AAC .m4a in the app's Documents folder |
| Health data | HealthKit, read-only sleepAnalysis category samples |
| Watch link | WCSession (WatchConnectivity) — Start/Stop remote only |
| Charts | Swift Charts |
| Cost | $0 infrastructure; no dependencies, no network calls |
Before writing any app code, three approaches to the detection model were compared on accuracy, effort, real-time on-device feasibility, and cost.
| Option | Accuracy | Effort | Real-time on device | Cost |
|---|---|---|---|---|
| 1. Train own CNN (the paper's approach) | 96% on the paper's dataset; a Create ML route on the same data lands in a similar ballpark | Medium — but low via Create ML on the free dataset, since no data collection is needed | Yes (Core ML) | $0 |
| 2. Apple's built-in SoundAnalysis classifier ⭐ | Good — trained on a massive corpus, robust in the real world, exact figure unpublished | Minimal — no training at all | Yes — designed for exactly this | $0 |
| 3. Paid model or dataset under $10 | No clear candidate better than the free options | — | — | Rejected |
Decision: start with Option 2, keep Option 1 as the upgrade path.
Apple's SNClassifySoundRequest built-in classifier recognizes 300+ sound
classes including snoring (plus breathing,
cough, speech, and silence, useful for
filtering), runs fully on-device in real time, and needs zero training.
The reasoning: the surrounding pipeline is identical regardless of which model sits inside it. Starting with the built-in classifier meant the entire core function — audio session, stream analyzer, event segmentation, clip saving, timeline — could be built and tested immediately, then A/B'd against a custom model later using real overnight recordings as test data. A decision based on evidence rather than guesses.
YAMNet also has a snoring class, but a TensorFlow → Core ML conversion is extra effort for no advantage over Apple's built-in model, so it was rejected.
AVAudioSession in .record
plus the audio background mode keeps the microphone running with the screen
locked all night. An AVAudioEngine input-node tap delivers live PCM
buffers, which are deep-copied before asynchronous use because the system reuses the
tap's buffer.
SNAudioStreamAnalyzer with 50% window overlap. A window counts as a snore
hit when the snoring label's confidence clears the threshold (0.6 by
default).
.m4a file and live audio streams in until the episode ends — so the
first snore that triggered detection is actually in the recording. Event start times are
backdated to cover the trigger windows.
Non-snoring audio is never written to disk, for both privacy and
storage reasons.
SnoreEvent (start, end, peak confidence, clip filename) belonging to a
SleepSession. Deleting a session cascades to its events and removes its
clip folder from disk.
Phone calls and Siri are handled explicitly: the engine observes
AVAudioSession.interruptionNotification and automatically restarts the
engine when the interruption ends with the .shouldResume option — otherwise
a 2 a.m. phone call would silently end the night's recording.
| Parameter | Default | Effect |
|---|---|---|
confidenceThreshold |
0.6 | Raise if false positives, lower if snores are missed |
endHangover |
10 s | How much silence ends an episode |
consecutiveToStart |
2 windows | Debounce before an episode starts |
preRollDuration |
10 s | Audio kept in memory before the detection trigger |
The root cause turned out to be a known iOS 18 platform restriction rather than a bug in
the app. Apple's built-in SoundAnalysis classifier runs on the GPU, and iOS
forbids GPU work from backgrounded or locked apps. Every single classification
window was failing silently with
kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted. It worked
on iOS 15–17 and has been broken since iOS 18.
This promoted the "optional upgrade path" — a custom Create ML model — into a hard requirement, because CPU inference is permitted in the background. The fix, in two rounds:
Kaggle was blocked, but the paper's own dataset (16 kHz version) is mirrored in the
adrianagaler/Snoring-Detection GitHub repo, so 500 snoring and 500
non-snoring 1-second clips were pulled via sparse checkout. A Create ML Sound
Classification model (Audio Feature Print, 0.975 s windows, 50% overlap) converged at
14 iterations with 100% training and 100% validation accuracy, exporting
to a 4.8 KB .mlmodel.
The engine now loads it with MLModelConfiguration.computeUnits = .cpuOnly
and passes it to SNClassifySoundRequest(mlModel:), falling back to the
built-in classifier (foreground-only, with a warning in the debug log) if the model
isn't bundled. Retest: still failed when locked. The Create ML export
is a two-stage pipeline — Audio Feature Print feeding a classifier — and stage 0 was
still being denied compute in the background.
The fix, confirmed by the Apple developer forums, was to add
processing to UIBackgroundModes alongside
audio. One developer reported a 14-hour locked-screen run on iOS
18.2 after this change. Applied, rebuilt, deployed — and locked-screen detection worked.
The unexpected upside: the app is no longer dependent on Apple's generic 300-class model. The detector is now purpose-trained on the reference paper's own snoring dataset, in a 4.8 KB model, and it happens to be the only configuration that works in the background at all.
Four tabs on the phone, plus a Watch companion.
One large Start/Stop button, an elapsed session clock, a live snoring-confidence meter with the current threshold shown beneath it, tonight's episode list as it accumulates, an Apple Watch connection indicator, and a toggleable debug log that was invaluable for diagnosing the locked-screen failure.
A list of past nights, each showing bedtime, wake time, and episode count. Opening a night gives three summary statistics (time in bed, total snoring time, and snoring as a percentage of the night), a horizontal night timeline strip with orange bars marking each snoring interval in its true position, and the full episode log with playback.
Each episode row carries a waveform scrubber: the clip's real amplitude envelope rendered as 96 peak bins, voice-memo style, with a draggable playhead. Spikes in the waveform indicate loud moments, so you can drag straight to the snore instead of listening through the clip. Dragging works even before pressing play — scrubbing loads the clip paused. Waveforms are decoded off the main thread the first time a row appears and cached in memory, with placeholder bars while loading, so the list stays responsive.
The core deliverable of the second phase. Pick a night, and the app overlays that night's snoring intervals onto the Apple Health sleep-stage timeline.
Two details that mattered more than expected. First, snore episodes that span a stage boundary are split proportionally across the stages they overlap, rather than being assigned wholesale to whichever stage they started in. Second, sessions are matched to nights by actual sample time-range overlap rather than calendar day, so a session that crosses midnight pairs with the right night automatically.
A single reversible toggle gates the Apple Health integration. Turning it off stops all Health queries immediately and clears the cache, and the footer explains that access can also be revoked from the Health app itself. The About section states plainly that the detection model is custom, CPU, and on-device, and that audio storage is on-device only.
A subtlety worth noting: iOS never tells an app whether Health read permission was denied — for privacy reasons, "denied" and "no data" look identical. The empty-state copy is written to account for that, pointing at the most likely cause (no Apple Watch worn during sleep) rather than asserting a permission problem.
A deliberately minimal remote: one big Start/Stop button, the elapsed timer mirrored from the phone, and an iPhone-connection indicator dot. The Watch never records audio — the iPhone stays the recorder.
Making this work cleanly required refactoring start/stop out of the view and into a
shared RecordingCoordinator, so that the phone UI and the Watch drive the
identical code path for session creation, event persistence, and engine
lifecycle. That matters because the Watch has to be able to start a session
headlessly, when the app was woken in the background rather than opened
by the user.
The delivery model has two paths: when the Watch is reachable,
sendMessage delivers commands instantly; when the phone app is backgrounded
or not running, transferUserInfo queues the command and the system wakes
the app to deliver it — which works precisely because of the audio and
processing background modes added for the locked-screen fix. State is also
pushed via updateApplicationContext so the Watch shows the correct state
even if its app is opened much later.
| Milestone | Scope | Status |
|---|---|---|
| M1 | App skeleton — microphone permission, background audio session, live classifier with confidence meter and debug log | Complete |
| M2 | Event segmentation and timestamped clip saving with pre-roll | Complete |
| M3 | SwiftData session store, timeline review screen with playback | Complete |
| M4 | Real overnight testing and threshold tuning — 3 sessions on a physical iPhone | Complete |
| M5 | Custom Create ML model (forced by the locked-screen bug) + read-only HealthKit integration with a reversible Settings toggle | Complete |
| M6 | Analysis tab — snoring × sleep-stage correlation (core deliverable) | Complete |
| M7 | watchOS companion — Start/Stop remote via WatchConnectivity | Code complete; Watch target signing and on-device pairing test pending |
| — | UI polish, then optional free App Store publication | Planned |
Problems that took real diagnosis, beyond the locked-screen story above.
@Observable and NSObject don't mix cleanly.
Both SNResultsObserving and AVAudioPlayerDelegate require
NSObjectProtocol. Rather than fight the macro, the classifier observer and
the player delegate are each a separate small NSObject proxy, retained
explicitly because the analyzer and AVAudioPlayer both hold their delegates
weakly.
SIGABRT.
ClipPlayer refuses
to load a clip while a recording session is active, because switching the audio session
category to .playback mid-session would end the night's recording.
asleepUnspecified samples (iPhone-only estimates) are
ignored so the two sources don't double-count. If there's no Watch data at all, unstaged
sleep is shown as Core so the night is still visible.
Native SwiftUI, iOS 17+, no third-party dependencies. Around 2,400 lines of Swift across the iOS and watchOS targets. The repository README covers building, signing with a free Apple ID, testing detection before a real night, and the tuning knobs.