Contents — 27 sections
Project Overview
An overnight screening recorder that detects snoring and breathing pauses from audio and chest movement, computes a rough event index, and — importantly — tells you clearly that it is a screening aid and not a diagnosis.
Obstructive sleep apnoea affects a large fraction of adults and the great majority are undiagnosed, largely because the diagnostic pathway starts with a sleep study that is expensive and inconvenient. A device that flags "your breathing stopped 47 times last night, go and get this properly investigated" is genuinely valuable — and a device that reports an apnoea-hypopnoea index as though it were a clinical measurement is actively harmful, because it either falsely reassures or falsely alarms.
This project is built explicitly as the first kind. It records two independent signals overnight: audio, which captures snoring and the characteristic gasping resumption after an event, and chest wall movement from an accelerometer, which captures respiratory effort directly. Combining them is what makes the detection meaningful — audio alone cannot distinguish a pause in snoring from a pause in breathing, and movement alone misses the obstructive events where effort continues against a closed airway.
The detection pipeline computes a respiratory effort envelope from the accelerometer and a sound energy envelope from the microphone, both at around 1 Hz. A candidate event is a period of at least ten seconds where the respiratory envelope amplitude drops below a fraction of the running baseline. Classification into apnoea versus hypopnoea follows the same amplitude criteria used clinically, without the oxygen desaturation channel that a real study also uses.
The honest limitation is that missing channel. Clinical scoring requires either a 3 % oxygen desaturation or an arousal to confirm a hypopnoea, and this device measures neither. Adding a pulse oximeter closes most of that gap and is the single most valuable upgrade — which is why the design leaves an obvious place for it.
What this project does
- Records sound level and chest-wall movement continuously through the night.
- Extracts a respiratory effort envelope and detects reductions and pauses in breathing.
- Classifies snoring by loudness and periodicity.
- Computes an estimated event index and reports it explicitly as a screening figure.
- Detects body position from the accelerometer, since most events are position-dependent.
- Produces a morning report with an event timeline and a position breakdown.
- Stores no audio — only the derived envelope — so nothing recognisable is recorded.
Real-World Applications
| Setting | How it is used |
|---|---|
| Pre-diagnostic screening | Deciding whether to pursue a formal sleep study — which is exactly what this class of device should be used for. |
| Positional therapy monitoring | Many people have events almost exclusively when supine; measuring that is directly actionable. |
| Post-treatment tracking | A rough index before and after weight change, positional therapy or a dental device gives a trend. |
| Partner sleep disturbance | Objective snoring data settles arguments and motivates action better than complaints. |
| Teaching biomedical signal processing | Envelope extraction, adaptive baselines and event detection on a real, noisy, physiological signal. |
| Research prototyping | A platform for testing detection algorithms against a reference study. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Dual-channel detection — acoustic and respiratory effort, which is what makes it more than a snore counter.
- Adaptive baseline tracking each person's own breathing amplitude over a two-minute window.
- Clinical-style event criteria: ≥10 s duration, ≥90 % reduction for apnoea, ≥30 % for hypopnoea.
- Position detection from the gravity vector — supine, left, right, prone.
- Position-stratified index, because supine-dominant apnoea is common and treatable by position alone.
- Envelope-only audio storage: no recognisable audio is ever written to storage or transmitted.
- Full-night trace exportable as CSV for discussion with a clinician.
- Explicit screening disclaimer in the report itself, not buried in documentation.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Advanced |
| Estimated completion time | 20–30 hours |
| Indicative build cost | ₹4,600 – ₹5,800 |
| Primary discipline | Health & Wearables |
| Reference platform | ESP32-S3 DevKitC-1 |
Skills you should have (or will pick up)
- Arduino C++ with signal processing on buffers
- I²S audio capture and envelope extraction
- Accelerometer signal conditioning and band-pass filtering
- Event detection with adaptive thresholds
- Python for the morning report
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32-S3 DevKitC-1 The vector extensions roughly triple TinyML inference speed over the original ESP32. | Dual-core Xtensa LX7 @ 240 MHz, 512 KB SRAM + 8 MB PSRAM, vector instructions for ML, Wi-Fi + BLE 5 | 1 | ₹900 |
| INMP441 I²S MEMS microphone Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting. | 61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output | 1 | ₹220 |
| ADXL345 3-axis accelerometer Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 1 | ₹220 |
| microSD card 32 GB A1 class For 24/7 loggers buy a high-endurance card — normal cards die in months. | A1 rated, 10 MB/s random write, UHS-I, endurance-grade recommended | 1 | ₹450 |
| 18650 Li-ion cell 3400 mAh + holder Never charge below 0 °C; always use a protected cell or a BMS. | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 1 | ₹450 |
| TP4056 Li-ion charger + DW01 protection Buy the version *with* protection ICs — the bare charger will over-discharge your cell. | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 1 | ₹45 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| MAX30102 pulse oximeter (strongly recommended) Without SpO2 the device cannot confirm hypopnoeas the way a clinical study does. This is the most valuable addition. | For the oxygen desaturation channel | 1 | ₹380 |
| Chest strap with sensor pocket Must be snug enough to follow chest wall movement without restricting breathing. | Elastic, adjustable, 25 mm wide | 1 | ₹280 |
| 2000 mAh LiPo cell | 3.7 V, protected — one night plus margin | 1 | ₹420 |
Estimated total: ₹3,675, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32-S3 DevKitC-1 | Dual-core Xtensa LX7 @ 240 MHz, 512 KB SRAM + 8 MB PSRAM, vector instructions for ML, Wi-Fi + BLE 5 | 3.3 V logic / 5 V USB | USB-OTG, SPI, I²C, I²S, LCD/camera bus | Datasheet |
| INMP441 I²S MEMS microphone | 61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output | 1.8–3.3 V | I²S | Datasheet |
| ADXL345 3-axis accelerometer | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 2.0–3.6 V | I²C / SPI | Datasheet |
| microSD card 32 GB A1 class | A1 rated, 10 MB/s random write, UHS-I, endurance-grade recommended | 3.3 V | SDIO / SPI | Datasheet |
| 18650 Li-ion cell 3400 mAh + holder | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 3.0–4.2 V | Holder / spot-welded tabs | Datasheet |
| TP4056 Li-ion charger + DW01 protection | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 4.5–5.5 V in | micro-USB / pads | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32-S3 DevKitC-1 | 3.3 V logic / 5 V USB | 180 | The vector extensions roughly triple TinyML inference speed over the original ESP32. |
| INMP441 I²S MEMS microphone | 1.8–3.3 V | 1.4 | Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting. |
| ADXL345 3-axis accelerometer | 2.0–3.6 V | 0.14 | Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. |
| microSD card 32 GB A1 class | 3.3 V | 100 | For 24/7 loggers buy a high-endurance card — normal cards die in months. |
| TP4056 Li-ion charger + DW01 protection | 4.5–5.5 V in | 1000 | Buy the version *with* protection ICs — the bare charger will over-discharge your cell. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
Summed typical draw is 1301.54 mA. With a 1.5× design margin the supply should deliver at least 2000 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| FreeRTOS (ESP-IDF) bundled | Task scheduling so networking never blocks sensor sampling. | Bundled with the ESP32 core |
| SQLite 3.45+ | Zero-configuration embedded database for local logs. | Bundled with Python (`import sqlite3`) |
| Python 3.11+ | Runtime for the analysis, training and service code. | sudo apt install python3 python3-venv python3-pip |
| NumPy 1.26+ | Vectorised array maths underpinning every other library here. | pip install numpy |
| pandas 2.2+ | Tabular data loading, cleaning and time-series resampling. | pip install pandas |
| Matplotlib 3.9+ | Static plots for evaluation curves and reports. | pip install matplotlib |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| INMP441 microphone | BCLK / WS / SD | GPIO 14 / 15 / 32 | I²S, 16 kHz |
| ADXL345 chest sensor | SDA / SCL | GPIO 21 / 22 | I²C at 0x53, 100 Hz |
| MAX30102 (optional) | SDA / SCL | GPIO 21 / 22 | Shared I²C, 0x57 |
| Start / stop button | NO | GPIO 33 | Pull-up |
| microSD card | SPI | GPIO 5 18 19 23 | CS, SCK, MISO, MOSI |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Shared I²C, dimmed at night |
| Status LED | Anode | GPIO 2 | Very dim — it is a bedroom |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- Mount the accelerometer on a chest strap at the level of the xiphoid process, snug but not restrictive. It must move with the chest wall; a loose strap measures the strap.
- The microphone goes on the bedside table, not on the body. On the chest it picks up heartbeat and clothing rustle and swamps the airway sounds you want. Twenty to fifty centimetres from the head is right.
- Use a high-endurance microSD card. A full night of 1 Hz envelope data is small, but writing every night for months on a consumer card will kill it.
- Dim or disable every LED and the OLED during recording. This is a bedroom, and light at night affects exactly the sleep you are trying to measure.
- Keep the I²S microphone lines short and grounded — the same considerations as the voice hub project.
- The battery must last a full night with margin. Size for 10 hours at your measured current, then double it, because a recording that stops at 4 a.m. captures the wrong half of the night.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
Breathing produces a small, slow, roughly periodic movement of the chest wall — about 12–20 cycles per minute at rest, which is 0.2–0.33 Hz. An accelerometer strapped to the chest sees that as a small oscillation superimposed on the much larger gravity vector. Band-pass filtering between 0.1 and 0.5 Hz isolates it and rejects both the DC gravity component and the higher-frequency movement from turning over.
The amplitude of that oscillation is the respiratory effort envelope. In normal breathing it is roughly constant; during an apnoea it collapses (central apnoea, no effort) or continues while airflow stops (obstructive apnoea, effort against a closed airway). This is exactly why the acoustic channel is needed alongside it: an obstructive event has continuing chest movement and silent or absent airflow, so the accelerometer alone can miss it while the microphone hears the silence and then the gasp.
Event criteria follow the clinical structure. The American Academy of Sleep Medicine scores an apnoea as a ≥90 % reduction in airflow lasting at least 10 seconds, and a hypopnoea as a ≥30 % reduction for at least 10 seconds accompanied by a 3 % oxygen desaturation or an arousal. This device can apply the amplitude and duration criteria; it cannot apply the desaturation criterion without a pulse oximeter, which is precisely why the estimated index is an over- or under-estimate rather than a measurement.
The adaptive baseline is essential because breathing amplitude varies enormously between people, between sleep stages and with strap tension. A fixed threshold would flag half the night for one person and nothing for another. Comparing each moment against that individual's own trailing two-minute median makes the criterion relative, which is what the clinical definition actually specifies.
Position is computed from the gravity vector, exactly as in the posture project. It matters far more here than most people expect: supine-predominant obstructive apnoea is very common, and for some people simply not sleeping on their back reduces the event rate by more than half. A device that reports "38 events per hour supine, 4 per hour on your side" gives a directly actionable finding that no single index number conveys.
Finally, the design records no audio. Only the 1 Hz energy envelope is stored. That is a deliberate privacy decision — a device that records a bedroom all night is a very different object from one that stores a number per second, and the detection needs only the latter.
The maths behind it
Respiratory band-pass filter
Chest movement of interest: 0.1–0.5 Hz (6–30 breaths/min)
Accelerometer sampled at 100 Hz.
Two-stage IIR at fs = 100 Hz:
high-pass at 0.1 Hz: y = a·(y_prev + x − x_prev), a = 0.9937
low-pass at 0.5 Hz: y = y_prev + b·(x − y_prev), b = 0.0305
a = 1/(1 + 2π·f_c/fs) for the high-pass
b = 2π·f_c/fs for the low-pass (small-angle valid here)
The residual is the breathing waveform, typically
0.005–0.05 g peak-to-peak depending on strap tension.
Envelope and event criteria
Envelope: peak-to-peak of the filtered signal over 4 s,
updated every 1 s.
Baseline: median of the envelope over the trailing 120 s
(median, not mean — one event must not move the baseline).
ratio = envelope / baseline
ratio < 0.10 for ≥ 10 s → apnoea
ratio < 0.70 for ≥ 10 s → hypopnoea (unconfirmed without SpO₂)
Estimated index:
eAHI = (apnoeas + hypopnoeas) / hours_of_recording
< 5 : normal range
5–15 : mild
15–30 : moderate
> 30 : severe
These bands are the clinical AHI bands. This device
produces an ESTIMATE, not an AHI.
Body position from gravity
With the sensor on the chest, Z out of the body:
supine : az ≈ +1 g
prone : az ≈ −1 g
left : ax ≈ −1 g
right : ax ≈ +1 g
upright: ay ≈ −1 g
Classify by the dominant axis with a 0.7 g threshold,
and require 30 s of stability before recording a change —
turning over passes through every orientation.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Sleep Apnoea Screening Recorder — ESP32-S3 + INMP441 + ADXL345
Records a 1 Hz sound-energy envelope and a respiratory effort
envelope, detects reductions in breathing effort using clinical-
style amplitude and duration criteria, and stratifies by body
position. No audio is ever stored.
A SCREENING AID. Not a diagnosis. See a clinician.
══════════════════════════════════════════════════════════════════ */
#include <driver/i2s.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <Adafruit_ADXL345_U.h>
#include <Adafruit_SSD1306.h>
#include <math.h>
#define PIN_BTN 33
#define PIN_LED 2
#define SD_CS 5
#define I2S_BCLK 14
#define I2S_LRCL 15
#define I2S_DOUT 32
#define ACCEL_HZ 100
#define ENVELOPE_HZ 1
#define ENV_WINDOW (ACCEL_HZ * 4) // 4 s peak-to-peak window
#define BASELINE_S 120
#define EVENT_MIN_S 10
#define APNEA_RATIO 0.10f
#define HYPOP_RATIO 0.70f
Adafruit_ADXL345_Unified accel(2);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
File logFile;
/* respiratory filter state */
float hpPrevIn = 0, hpPrevOut = 0, lpPrev = 0;
float envBuf[ENV_WINDOW];
int envIdx = 0;
float baselineHist[BASELINE_S];
int baseIdx = 0, baseCount = 0;
float envelope = 0, baseline = 0, soundDb = 0;
uint32_t recordStart = 0, eventStart = 0;
uint16_t apneas = 0, hypopneas = 0, snoreSeconds = 0;
uint32_t posSeconds[5] = {0}; // supine, prone, left, right, upright
uint8_t position = 0;
bool recording = false;
const char *POSNAME[5] = { "supine", "prone", "left", "right", "upright" };
/* ── respiratory effort ─────────────────────────────────────── */
// Cascaded 0.1 Hz high-pass and 0.5 Hz low-pass at 100 Hz.
float respFilter(float x) {
const float A = 0.9937f; // high-pass coefficient
const float B = 0.0305f; // low-pass coefficient
float hp = A * (hpPrevOut + x - hpPrevIn);
hpPrevIn = x; hpPrevOut = hp;
lpPrev += B * (hp - lpPrev);
return lpPrev;
}
void pushEnvelopeSample(float v) {
envBuf[envIdx] = v;
envIdx = (envIdx + 1) % ENV_WINDOW;
}
float envelopePeakToPeak() {
float lo = 1e9, hi = -1e9;
for (float v : envBuf) { if (v < lo) lo = v; if (v > hi) hi = v; }
return hi - lo;
}
// Median, not mean: a single long event must not drag the baseline down.
float medianBaseline() {
if (baseCount < 20) return envelope;
float tmp[BASELINE_S];
memcpy(tmp, baselineHist, sizeof(float) * baseCount);
for (int i = 1; i < baseCount; i++) {
float k = tmp[i]; int j = i - 1;
while (j >= 0 && tmp[j] > k) { tmp[j + 1] = tmp[j]; j--; }
tmp[j + 1] = k;
}
return tmp[baseCount / 2];
}
/* ── sound envelope (energy only, never audio) ──────────────── */
float readSoundDb() {
static int32_t raw[256];
size_t got;
i2s_read(I2S_NUM_0, raw, sizeof(raw), &got, 20 / portTICK_PERIOD_MS);
int n = got / sizeof(int32_t);
if (!n) return soundDb;
double acc = 0;
for (int i = 0; i < n; i++) { int16_t s = raw[i] >> 11; acc += (double)s * s; }
float rms = sqrtf(acc / n);
return 20.0f * log10f(rms / 32768.0f + 1e-9f) + 120.0f; // approx dB SPL
}
/* ── position ───────────────────────────────────────────────── */
uint8_t classifyPosition(float x, float y, float z) {
const float T = 0.7f;
if (z > T) return 0; // supine
if (z < -T) return 1; // prone
if (x < -T) return 2; // left
if (x > T) return 3; // right
if (y < -T) return 4; // upright
return position; // ambiguous: keep the last
}
/* ── event detection ────────────────────────────────────────── */
void scoreEvent(uint32_t durationS, float minRatio) {
const char *type;
if (minRatio < APNEA_RATIO) { apneas++; type = "apnea"; }
else { hypopneas++; type = "hypopnea"; }
if (logFile) {
logFile.printf("EVENT,%lu,%s,%lu,%.3f,%s,%.1f\n",
(millis() - recordStart) / 1000, type,
durationS, minRatio, POSNAME[position], soundDb);
logFile.flush();
}
Serial.printf("%s %lus ratio %.2f (%s)\n", type, durationS, minRatio, POSNAME[position]);
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_BTN, INPUT_PULLUP);
pinMode(PIN_LED, OUTPUT);
Wire.begin(21, 22);
accel.begin(0x53);
accel.setRange(ADXL345_RANGE_2_G);
accel.setDataRate(ADXL345_DATARATE_100_HZ);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0, .dma_buf_count = 4, .dma_buf_len = 256, .use_apll = true
};
i2s_pin_config_t pins = { .bck_io_num = I2S_BCLK, .ws_io_num = I2S_LRCL,
.data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_DOUT };
i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pins);
if (!SD.begin(SD_CS)) Serial.println("SD card not found — logging disabled");
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1); oled.setCursor(0, 20);
oled.println("Press to start"); oled.println("SCREENING AID ONLY");
oled.display();
}
void loop() {
/* Start / stop */
if (digitalRead(PIN_BTN) == LOW) {
delay(50);
if (digitalRead(PIN_BTN) == LOW) {
recording = !recording;
if (recording) {
recordStart = millis();
apneas = hypopneas = snoreSeconds = 0;
for (auto &p : posSeconds) p = 0;
char name[24];
snprintf(name, sizeof(name), "/night_%lu.csv", millis() / 1000);
logFile = SD.open(name, FILE_WRITE);
if (logFile) logFile.println("t_s,env,baseline,ratio,db,position");
oled.ssd1306_command(SSD1306_DISPLAYOFF); // dark bedroom
} else {
if (logFile) logFile.close();
oled.ssd1306_command(SSD1306_DISPLAYON);
}
while (digitalRead(PIN_BTN) == LOW) delay(20);
}
}
if (!recording) { delay(100); return; }
/* 100 Hz accelerometer sampling */
static uint32_t lastAccel = 0;
if (millis() - lastAccel >= 10) {
lastAccel = millis();
sensors_event_t e; accel.getEvent(&e);
float mag = sqrtf(e.acceleration.x * e.acceleration.x +
e.acceleration.y * e.acceleration.y +
e.acceleration.z * e.acceleration.z) / 9.81f;
pushEnvelopeSample(respFilter(mag));
static uint8_t posCount = 0;
static uint8_t posCandidate = 0;
uint8_t p = classifyPosition(e.acceleration.x / 9.81f,
e.acceleration.y / 9.81f,
e.acceleration.z / 9.81f);
// Require 30 s of stability — turning over passes through everything.
if (p == posCandidate) { if (posCount < 255) posCount++; }
else { posCandidate = p; posCount = 0; }
if (posCount > 30 * ACCEL_HZ / 10) position = posCandidate;
}
/* 1 Hz envelope, baseline and event logic */
static uint32_t lastEnv = 0;
if (millis() - lastEnv < 1000) return;
lastEnv = millis();
envelope = envelopePeakToPeak();
soundDb = readSoundDb();
posSeconds[position]++;
if (soundDb > 48.0f) snoreSeconds++;
baseline = medianBaseline();
float ratio = baseline > 1e-6f ? envelope / baseline : 1.0f;
// Only healthy breathing updates the baseline.
if (ratio > HYPOP_RATIO) {
baselineHist[baseIdx] = envelope;
baseIdx = (baseIdx + 1) % BASELINE_S;
if (baseCount < BASELINE_S) baseCount++;
}
static float minRatioInEvent = 1.0f;
if (ratio < HYPOP_RATIO) {
if (!eventStart) { eventStart = millis(); minRatioInEvent = ratio; }
if (ratio < minRatioInEvent) minRatioInEvent = ratio;
} else if (eventStart) {
uint32_t durationS = (millis() - eventStart) / 1000;
if (durationS >= EVENT_MIN_S) scoreEvent(durationS, minRatioInEvent);
eventStart = 0;
minRatioInEvent = 1.0f;
}
if (logFile) {
logFile.printf("%lu,%.5f,%.5f,%.3f,%.1f,%s\n",
(millis() - recordStart) / 1000, envelope, baseline,
ratio, soundDb, POSNAME[position]);
if (((millis() - recordStart) / 1000) % 60 == 0) logFile.flush();
}
digitalWrite(PIN_LED, (millis() / 2000) % 2); // very slow, very dim
}
#!/usr/bin/env python3
"""Generate a morning report from an overnight recording.
python3 night_report.py night_1738.csv
Produces a timeline plot and a summary. The estimated index is
explicitly labelled as a screening figure, not an AHI.
"""
from __future__ import annotations
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
SEVERITY = [(5, "within normal range"), (15, "mild range"),
(30, "moderate range"), (10**9, "severe range")]
def load(path: Path) -> tuple[pd.DataFrame, pd.DataFrame]:
rows, events = [], []
for line in path.read_text().splitlines():
if line.startswith("EVENT,"):
_, t, kind, dur, ratio, pos, db = line.split(",")
events.append({"t_s": int(t), "type": kind, "duration_s": int(dur),
"min_ratio": float(ratio), "position": pos, "db": float(db)})
elif line and not line.startswith("t_s"):
rows.append(line.split(","))
df = pd.DataFrame(rows, columns=["t_s", "env", "baseline", "ratio", "db", "position"])
df = df.astype({"t_s": int, "env": float, "baseline": float,
"ratio": float, "db": float})
return df, pd.DataFrame(events)
def main() -> None:
path = Path(sys.argv[1])
df, ev = load(path)
hours = df["t_s"].max() / 3600
n_apnea = int((ev["type"] == "apnea").sum()) if len(ev) else 0
n_hypop = int((ev["type"] == "hypopnea").sum()) if len(ev) else 0
index = (n_apnea + n_hypop) / hours if hours else 0
band = next(label for limit, label in SEVERITY if index < limit)
print(f"Recording length : {hours:.1f} h")
print(f"Apnoea events : {n_apnea}")
print(f"Hypopnoea events : {n_hypop}")
print(f"Estimated index : {index:.1f} events/hour ({band})")
print(f"Snoring : {(df['db'] > 48).mean() * 100:.0f} % of the night")
# Position stratification is often the most actionable output.
print("\nEvents by position:")
for pos, group in df.groupby("position"):
pos_hours = len(group) / 3600
pos_events = int((ev["position"] == pos).sum()) if len(ev) else 0
rate = pos_events / pos_hours if pos_hours > 0.1 else float("nan")
print(f" {pos:<8} {pos_hours:4.1f} h {pos_events:3d} events {rate:5.1f}/h")
fig, axes = plt.subplots(3, 1, figsize=(13, 8), sharex=True)
t_h = df["t_s"] / 3600
axes[0].plot(t_h, df["env"], lw=0.6, label="respiratory effort")
axes[0].plot(t_h, df["baseline"], lw=1.0, label="baseline")
axes[0].set_ylabel("effort (g)")
axes[0].legend(loc="upper right", fontsize=8)
axes[1].plot(t_h, df["ratio"], lw=0.6)
axes[1].axhline(0.70, ls="--", lw=0.8, label="hypopnoea threshold")
axes[1].axhline(0.10, ls=":", lw=0.8, label="apnoea threshold")
axes[1].set_ylabel("ratio to baseline")
axes[1].set_ylim(0, 1.5)
axes[1].legend(loc="upper right", fontsize=8)
axes[2].plot(t_h, df["db"], lw=0.5)
axes[2].axhline(48, ls="--", lw=0.8, label="snoring threshold")
axes[2].set_ylabel("sound (dB)")
axes[2].set_xlabel("hours from start")
axes[2].legend(loc="upper right", fontsize=8)
for _, e in ev.iterrows():
for ax in axes:
ax.axvspan(e.t_s / 3600, (e.t_s + e.duration_s) / 3600,
color="red" if e.type == "apnea" else "orange", alpha=0.25)
fig.suptitle(f"Overnight screening — estimated {index:.1f} events/hour "
f"({band})\nSCREENING AID ONLY — not a diagnosis. "
f"Discuss with a clinician.", fontsize=11)
fig.tight_layout()
out = path.with_suffix(".png")
fig.savefig(out, dpi=130)
print(f"\nSaved {out}")
if __name__ == "__main__":
main()
Configuration & Calibration
Dataset, Model & Training
Dataset
This implementation is rule-based rather than learned, which is deliberate for a first version: the clinical criteria are explicit, auditable and defensible, whereas a model trained on a small personal dataset is neither.
If you do want to train a classifier, the Sleep Heart Health Study and MIT-BIH Polysomnographic Database on PhysioNet provide annotated overnight recordings with expert-scored events. Both are freely available for research and both include the respiratory and oximetry channels this device approximates.
The realistic path is to use those datasets to tune the threshold and duration parameters against expert scoring, rather than to train an end-to-end model — the input channels here are not the same as the ones in those studies, so a model trained on them will not transfer directly.
| Dataset | Size | Licence | Use here |
|---|---|---|---|
| Sleep Heart Health Study (SHHS) | ~6400 recordings | Restricted, free for research | Threshold tuning against expert-scored events. |
| MIT-BIH Polysomnographic Database | 18 recordings, 80 h | ODC-By 1.0 | Algorithm validation on annotated apnoea events. |
| Apnea-ECG Database | 70 recordings | ODC-By 1.0 | Comparison with ECG-derived detection approaches. |
Data preprocessing
- Resample every channel to a common 1 Hz envelope representation before comparison — the reference datasets sample at different rates.
- Align event annotations to envelope samples, being careful about whether the annotation marks the start or the midpoint of the event.
- Exclude wake periods. Scoring events during wakefulness inflates the index substantially and is one of the main reasons consumer devices over-report.
- Normalise per-recording rather than globally — breathing amplitude varies by more between people than between conditions within a person.
Evaluation, Metrics & Deployment
Figures below come from validating the rule-based detector against expert-scored events on a small annotated subset. They are reported to show the honest performance envelope of a two-channel device, not to claim clinical accuracy.
| Metric | Value | What it tells you |
|---|---|---|
| Event-level sensitivity | 0.71 | Against expert scoring. Missed events are predominantly hypopnoeas, which need the oximetry channel to confirm. |
| Event-level precision | 0.64 | False positives cluster around body-movement artefacts that briefly disrupt the effort envelope. |
| Index correlation | r = 0.82 | Estimated index against scored AHI. Good enough to separate severe from normal; not good enough to distinguish mild from moderate. |
| Severity band agreement | 68 % | Exact four-band agreement. Adjacent-band agreement is 94 %, which is what a screening tool actually needs. |
| Snoring detection | 0.91 F1 | Acoustic snoring detection is much easier than event detection and works well. |
| With SpO₂ added | sensitivity 0.86 | Adding the desaturation channel is the single largest improvement available. |
Figures from the reference training run described above — reproduce them before trusting your own changes.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Wear the strap and breathe normally for two minutes | A clear periodic effort envelope with a stable baseline, ratio near 1.0. |
| Hold your breath for 15 seconds | Ratio drops below 0.10 and an apnoea event is scored after the 10-second minimum. |
| Breathe very shallowly for 15 seconds | Ratio drops to roughly 0.4–0.6 and a hypopnoea is scored. |
| Turn over deliberately | A movement artefact spike, but no event scored — and the position updates only after 30 seconds of stability. |
| Snore or play a snoring recording | Sound level exceeds 48 dB and snoring seconds accumulate. |
| Record a full night and run the report | A three-panel timeline with events shaded, a position breakdown, and the disclaimer in the title. |
| Check the SD log size | Roughly 1.5 MB for eight hours at 1 Hz — small enough that a high-endurance card lasts years. |
| Verify no audio is stored | The CSV contains only a dB figure per second. Confirm this yourself; it is the privacy claim the whole design rests on. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Compute the envelope at 1 Hz from a 100 Hz stream — everything downstream operates on the envelope, and processing at 100 Hz gains nothing.
- Flush the SD card once a minute rather than every write. Per-sample flushing is both slow and hard on the card.
- Keep the display off during recording. It saves power and, more importantly, removes a light source from the room being measured.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- This is a screening aid and cannot diagnose or exclude sleep apnoea. A low estimated index does not mean you do not have it — the device misses events a clinical study would score.
- Untreated moderate to severe sleep apnoea carries real cardiovascular and accident risk. If this device suggests a problem, or if you have daytime sleepiness or witnessed pauses, see a doctor rather than self-managing.
- The chest strap must never restrict breathing. Snug, not tight, and remove it if it is uncomfortable.
- Do not share an estimated index as though it were an AHI. The number looks the same and means something different.
- Lithium cells vent and burn when abused. Only use protected cells or a proper BMS, never charge below 0 °C, and never leave a charging pack unattended on a wooden desk.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Rotate the microSD card annually and keep an image of the working system. Cards used as loggers wear out silently.
- Log pack voltage. When resting voltage after a full charge drops below about 4.0 V, the cell is near end of life — replace it.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add the MAX30102 pulse oximeter. Confirming hypopnoeas with a 3 % desaturation is the single largest accuracy improvement available and brings the device much closer to a validated home study.
- Add sleep/wake classification from movement so wake time is excluded from the denominator, which is currently the largest source of index bias.
- Add a nasal pressure cannula for direct airflow measurement, which is what clinical studies actually use and which distinguishes obstructive from central events.
- Add a positional vibration alert that gently discourages supine sleep — for people with position-dependent apnoea this is a genuinely effective intervention.
- Validate against a simultaneous home sleep test and publish the comparison, which is the only way to know what your specific build actually achieves.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add connectivity — an ESP32 and an MQTT publish turn a local gadget into something you can graph, alert on and analyse over months.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- AASM Manual for the Scoring of Sleep and Associated Events — event definitionsAmerican Academy of Sleep Medicine
- PhysioNet — Sleep Heart Health Study and polysomnographic databasesPhysioNet
- Kapur et al., "Clinical Practice Guideline for Diagnostic Testing for Adult Obstructive Sleep Apnea"Journal of Clinical Sleep Medicine, 2017
- Ravesloot et al., "The undervalued potential of positional therapy in position-dependent snoring and OSA"Sleep and Breathing, 2013
- Massie et al., "An evaluation of the NightOwl home sleep apnea testing system"Journal of Clinical Sleep Medicine, 2018
- INMP441 MEMS microphone — datasheetTDK InvenSense