Two things run while you sleep: an AAC encoder and a 20 Hz feature loop that keeps a 10 Hz loudness envelope. That is all. No machine learning runs overnight, nothing is uploaded, and no detection decision is made in the dark — decisions made at 3 a.m. against partial data cannot be revisited. In the morning the app walks the recording once: the envelope picks out the active stretches, Apple's sound classifier reads only those, and the surviving windows become events with clips, loudness and severity. Every number on this page is read from the shipping source.
Parameters below are taken from the app source (AudioPipeline, Screeners, EnvelopeGate, BatchProcessor, Theme, CLOUD_SYNC_SPEC). Synthetic data is labelled as such.
The design rule is that the overnight stage must survive eight hours of iOS background scheduling on battery. So the overnight stage does arithmetic only — rectify, filter, accumulate — and every decision that benefits from hindsight waits for morning, when the whole file is on disk and a wrong threshold can simply be run again.
AVAudioEngine tap, 4800-frame buffers (100 ms at 48 kHz). Session category .playAndRecord with mode .measurement, which turns off the system AGC and EQ so the measurement stays clean. A silent player node keeps the session alive through calls and alarms.
50 ms frames, 20 per second. Each frame yields four numbers: raw RMS, snore-band RMS, voice-band RMS and zero-crossing rate. Two 4th-order Biquad chains run in parallel — 40–400 Hz for snoring, 250–3000 Hz for speech.
Every two frames the louder value is kept, giving a 10 Hz loudness track — peaks must not be averaged away. Written to disk every 30 s. Ten hours is about 1.2 MB.
The envelope, already computed, is scanned for active stretches: threshold = 20th-percentile noise floor × 2.5, each stretch padded by 2 s, stretches merged when less than 5 s apart. A relative floor is what makes a hotel with loud air conditioning and a silent bedroom both work.
SNAudioStreamAnalyzer with the built-in Apple classifier, fed only the active stretches at their true file positions. Snoring is {snoring, snort}; sleep-talk is {speech, whispering, shout, singing, babbling}.
Same-class windows merge when the gap is 2 s or less; anything under 0.5 s is dropped. Each survivor gets a clip cut with 1 s on either side and normalised to 0.9 peak, plus peak dB, mean level and a severity score.
The full-night recording has done its job and is deleted by default — the clips are what you keep. Transcription is on demand, per event, when you tap it.
One row is one hour; one cell is ten seconds. Cell brightness is loudness on a fixed scale, so rows from different nights can be laid side by side — most sleep apps normalise each night to its own loudest moment, which makes every night look identical. Drag across the weave to read what the pipeline sees at that instant. The night is synthetic; the parameters, colours and mapping are the app's.
Loudness here is dBFS, digital full scale. The app also presents an estimated dB SPL, which depends on the device and where the phone sits — consistent night to night, not a calibrated sound-level meter.
This is the decision that turns a five-minute analysis into seconds. The envelope is cheap and already on disk; the gate uses it to decide what deserves the expensive pass. Move the three controls and watch the active share move. The 20th-percentile floor is fixed by the data, not by you — that is the point of a relative threshold.
Envelope shown at 10 Hz, max-pooled to the canvas width. Highlighted stretches are what SNAudioStreamAnalyzer receives, at their true frame positions, so window timestamps need no conversion.
The sensitivity setting is one number, 1 to 10, and it moves the minimum confidence from 0.70 down to 0.25 — strict to permissive, 0.50 at the default of 5. The subtler decision is how far down the classifier output to look. A hotel air conditioner produces a constant class that holds first place all night, pushing snoring into second. Reading only the top result loses the whole night; the app scans the top five and takes the first target class that clears the threshold.
Synthetic windows with confidences in the range the real classifier produces. Merge and minimum-duration rules are the shipping ones: same class within 2 s merges, under 0.5 s is dropped.
| 1 | white noise | 0.71 | — |
| 2 | mechanical fan | 0.63 | — |
| 3 | snoring | 0.58 | ← target ✓ |
| 4 | breathing | 0.31 | — |
| 5 | speech | 0.12 | — |
A real top-5 from a room with a fan running. The first target class above threshold wins, regardless of rank.
Snoring is low-frequency and speech is mid-frequency, so the overnight loop keeps both bands separately. It also keeps a dominance test: low above mid × 2 for snoring, mid above low × 0.35 for speech. Measured against real zh-TW speech through the pipeline, only about 23% of speech frames reach low > mid × 2 — which is why talking does not register as a snore. These screeners drive the engineering-mode instrument panel and shaped the design; the events they produce overnight are computed and discarded, because the morning classifier is the detector.
| rawRMS | Unfiltered RMS | Live waveform, ribbon, envelope |
| lowRMS | 40–400 Hz RMS | Snore screener, dominance test |
| midRMS | 250–3000 Hz RMS | Voice screener, dominance test |
| zcr | Zero crossings ÷ frame length | Speech-likeness, 0.006–0.35 |
| Adaptive floor | EMA, α = 0.002, updated only while not triggered — updating inside an event chases the threshold upward until the event eats itself. |
| Trigger | max(floor × ratio, 0.0009). The absolute lower bound is about −61 dBFS, so a dead-silent room does not fire on its own noise. |
| Hysteresis | Release at 0.6 × trigger, held for 1.2 s — snoring has a breathing gap between pulses. |
| Onset | 4 consecutive frames (200 ms) for snoring, 6 (300 ms) for speech. |
| Event bounds | Snore 0.5–30 s, sleep-talk 0.4–20 s. The end is the last frame above release, not where the wait ran out. |
| Constant-noise guard | A snore event cannot last 8 s — breathing ends it. Eight seconds without release means a fan or an air conditioner: the floor learns up to 90% of the observed peak and the stretch is discarded. |
| Sensitivity mapping | 1 → 5.0 ×, 10 → 1.5 ×, linear; the default 5 gives 3.4 × above the floor. |
| Platform | iPhone and iPad, iOS 17.6 or later |
| Audio session | .playAndRecord, mode .measurement — AGC and EQ off |
| Tap buffer | 4800 frames, about 100 ms at 48 kHz |
| Master recording | AAC m4a, device sample rate, quality medium, ≈200 MB per night |
| Frame rate | 50 ms frames, 20 fps |
| Filter bands | Snore 40–400 Hz, voice 250–3000 Hz, 4th-order Biquad each |
| Envelope | 10 Hz, max of two frames, flushed every 30 s, ≈1.2 MB per 10 h |
| Raw ring buffer | 45 s of audio, for clip extraction |
| Silence gate | 20th-percentile floor × 2.5, pad 2 s, merge 5 s, bail at 70% active |
| Classifier | SNClassifySoundRequest, identifier .version1, on-device |
| Confidence range | 0.70 (sensitivity 1) to 0.25 (sensitivity 10), 0.50 default |
| Class sets | snoring, snort / speech, whispering, shout, singing, babbling |
| Event rules | Merge same class within 2 s, drop under 0.5 s |
| Clip | Event ±1 s, normalised to 0.9 peak |
| Severity | 0–100: 0.6 × loudness (−50 → −10 dBFS) + 0.4 × duration (0 → 20 s) |
| Transcription | zh-TW, requiresOnDeviceRecognition = true, 30 s timeout, on demand |
| New engine | iOS 26 SpeechTranscriber preferred when installed, on-device |
| Sync | CloudKit private database, custom SessionsZone, CKSyncEngine + CKOperation |
| Network calls | None anywhere in the project |
| Themes | 29 presets, each with a dark and a light palette |
| Ribbon cell | 10 s per cell, absolute scale by default |
| Timeline scaling | 95th-percentile normalisation, so one loud bang does not flatten the night |
There is no server of ours and no account system. Sync uses CloudKit's private database under your own Apple ID: metadata travels through CKSyncEngine, assets through direct operations so that clips can be fetched only when you press play. The full-night recording is never uploaded — it is deleted locally after analysis. Offline is not an error state: if iCloud is unavailable the app is the local-only app it started as, and network state can never block a recording from starting.
| session.json — events, times, state | tens of KB | Always in iCloud, always local |
| envelope.bin — 10 Hz night envelope | ~1.2 MB | Always in iCloud, always local |
| clips/evt-* — event audio | a few MB | In iCloud; local copy can be offloaded and re-fetched on play |
| audio.m4a — full-night recording | ~200 MB | Never uploaded, deleted after analysis |
| Account, email, identifiers | — | Not collected, nothing to send |
| Analytics, ads, crash SDKs | — | Not present |
A single CloudKit record holds a recording lock, taken by compare-and-swap and kept alive by a heartbeat every 5 minutes against a 15-minute lease — a device that runs out of battery never gets to release anything, so a lock without a heartbeat would deadlock forever. And because the lock is a courtesy, not a correctness gate: if the check fails or you are offline, recording starts anyway. Two overlapping sessions are two UUIDs, both kept.
MoyiSnore is not a medical device. It records and measures sound; it does not detect apnea or any condition. An objective, timestamped record is useful to bring to a physician — it is not a substitute for one.
A phone cannot tell whose snore it heard. In a shared bed, read the report as a record of the room.
Loudness depends on distance and on what the phone rests on. Same place every night is what makes nights comparable.
The app uses Apple's built-in sound classifier rather than a model trained on snoring. It is honest about that: every event keeps its class, its confidence and its audio clip, so you can check any of it yourself.
A fan, a passing vehicle, a partner turning over. The constant-noise guard and the confidence threshold remove most of it, not all.
References inform the design; the app makes no clinical claim.