@shiihaa/breath-detection 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +545 -0
- package/dist/index.mjs +545 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felix Zeller / shii · haa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @shiihaa/breath-detection
|
|
2
|
+
|
|
3
|
+
Real-time breath cycle detection using microphone energy analysis, spectral centroid classification, and optional BLE heart rate data. Works in browsers and Capacitor apps.
|
|
4
|
+
|
|
5
|
+
**By [shii · haa](https://shiihaa.app)** — a breathwork and biofeedback app built by a physician.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Microphone-based breath detection** — detects inhale/exhale cycles from audio energy
|
|
10
|
+
- **Spectral centroid classification** — distinguishes inhale (higher frequency, turbulent airflow) from exhale (lower frequency, laminar airflow)
|
|
11
|
+
- **Dual-path detection** — threshold-based state machine for paused breathing + peak-based fallback for continuous breathing
|
|
12
|
+
- **Auto-recalibration** — adapts to changing ambient noise every 10 seconds
|
|
13
|
+
- **Refractory period** — prevents double-counting with configurable minimum cycle gap
|
|
14
|
+
- **Optional BLE heart rate input** — accepts external HR data for enhanced analysis
|
|
15
|
+
- **TypeScript types included**
|
|
16
|
+
- **Zero dependencies**
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @shiihaa/breath-detection
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
import { BreathDetector } from '@shiihaa/breath-detection';
|
|
28
|
+
|
|
29
|
+
const detector = new BreathDetector();
|
|
30
|
+
|
|
31
|
+
// Listen for completed breath cycles
|
|
32
|
+
detector.onCycle((cycle) => {
|
|
33
|
+
console.log(`Breath: ${cycle.inhaleMs}ms in, ${cycle.exhaleMs}ms out`);
|
|
34
|
+
console.log(`Rate: ${(60000 / cycle.cycleMs).toFixed(1)} breaths/min`);
|
|
35
|
+
console.log(`Method: ${cycle.method}`); // 'threshold' or 'peak'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Listen for real-time phase changes
|
|
39
|
+
detector.onPhase((event) => {
|
|
40
|
+
console.log(`Phase: ${event.phase}, Energy: ${event.energy.toFixed(2)}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Start microphone
|
|
44
|
+
const ok = await detector.start();
|
|
45
|
+
if (!ok) { console.error('Mic access denied'); return; }
|
|
46
|
+
|
|
47
|
+
// Calibrate (user breathes for 6 seconds)
|
|
48
|
+
const cal = await detector.calibrate();
|
|
49
|
+
console.log(`Noise: ${cal.noiseFloor.toFixed(4)}, Breath: ${cal.breathMax.toFixed(4)}`);
|
|
50
|
+
|
|
51
|
+
// Start detection loop
|
|
52
|
+
detector.startDetection();
|
|
53
|
+
|
|
54
|
+
// Later: stop
|
|
55
|
+
detector.stop();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How It Works
|
|
59
|
+
|
|
60
|
+
### Detection Pipeline
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Microphone → FFT → Energy + Centroid → State Machine → Breath Cycles
|
|
64
|
+
↑
|
|
65
|
+
Peak Counter (fallback)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
1. **Audio Capture**: Microphone via Web Audio API (`getUserMedia`)
|
|
69
|
+
2. **Spectral Analysis**: FFT computes energy in 150–2500 Hz band + spectral centroid
|
|
70
|
+
3. **Energy Smoothing**: EMA filter removes noise spikes
|
|
71
|
+
4. **State Machine**: Tracks phases (active → silent → active → silent = 1 cycle)
|
|
72
|
+
5. **Peak Fallback**: If energy never drops to silence (continuous breathing), counts energy peaks instead — two peaks = one breath cycle
|
|
73
|
+
6. **Centroid Classification**: Higher centroid → inhale (turbulent "shii..."), lower centroid → exhale (laminar "...haa")
|
|
74
|
+
7. **Auto-Recalibration**: Noise floor and breath max adapt every 10s
|
|
75
|
+
|
|
76
|
+
### Inhale vs. Exhale Classification
|
|
77
|
+
|
|
78
|
+
The spectral centroid (frequency center of mass) differs between inhale and exhale:
|
|
79
|
+
|
|
80
|
+
| Phase | Airflow | Centroid |
|
|
81
|
+
|-------|---------|----------|
|
|
82
|
+
| Inhale | Turbulent, through nasal passages | ~800–2500 Hz |
|
|
83
|
+
| Exhale | Laminar, relaxed | ~200–800 Hz |
|
|
84
|
+
|
|
85
|
+
This is a novel approach — to our knowledge, no other breathwork app uses spectral analysis for breath phase classification.
|
|
86
|
+
|
|
87
|
+
## API Reference
|
|
88
|
+
|
|
89
|
+
### `new BreathDetector(options?)`
|
|
90
|
+
|
|
91
|
+
| Option | Type | Default | Description |
|
|
92
|
+
|--------|------|---------|-------------|
|
|
93
|
+
| `fftSize` | number | 4096 | FFT window size |
|
|
94
|
+
| `smoothingAlpha` | number | 0.25 | Energy EMA smoothing (0–1) |
|
|
95
|
+
| `minCycleGapSeconds` | number | 2.5 | Minimum seconds between cycles |
|
|
96
|
+
| `minPhaseSeconds` | number | 1.5 | Minimum seconds per breath phase |
|
|
97
|
+
| `thresholdFactor` | number | 0.35 | Detection sensitivity (0=sensitive, 1=strict) |
|
|
98
|
+
| `freqLow` | number | 150 | Low frequency bound (Hz) |
|
|
99
|
+
| `freqHigh` | number | 2500 | High frequency bound (Hz) |
|
|
100
|
+
| `enableCentroid` | boolean | true | Enable spectral centroid classification |
|
|
101
|
+
| `centroidThreshold` | number | 40 | Hz difference for confident in/out labeling |
|
|
102
|
+
|
|
103
|
+
### Methods
|
|
104
|
+
|
|
105
|
+
| Method | Returns | Description |
|
|
106
|
+
|--------|---------|-------------|
|
|
107
|
+
| `start()` | `Promise<boolean>` | Start microphone capture |
|
|
108
|
+
| `stop()` | `void` | Stop capture, release resources |
|
|
109
|
+
| `calibrate()` | `Promise<CalibrationResult>` | 6-second calibration (2s noise + 4s breath) |
|
|
110
|
+
| `startDetection()` | `void` | Start the detection loop |
|
|
111
|
+
| `stopDetection()` | `void` | Stop detection (keeps mic open) |
|
|
112
|
+
| `setHeartRate(bpm)` | `void` | Feed external HR for enhanced analysis |
|
|
113
|
+
| `getState()` | `object` | Current state (phase, energy, centroid, etc.) |
|
|
114
|
+
|
|
115
|
+
### Events
|
|
116
|
+
|
|
117
|
+
| Event | Callback | Description |
|
|
118
|
+
|-------|----------|-------------|
|
|
119
|
+
| `onCycle(cb)` | `(cycle: BreathCycle) => void` | Full breath cycle detected |
|
|
120
|
+
| `onPhase(cb)` | `(event: BreathPhaseEvent) => void` | Phase change (every tick) |
|
|
121
|
+
| `onCalibration(cb)` | `(result: CalibrationResult) => void` | Calibration complete |
|
|
122
|
+
| `onEnergy(cb)` | `(energy, centroid) => void` | Raw energy + centroid per tick |
|
|
123
|
+
|
|
124
|
+
### BreathCycle
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
{
|
|
128
|
+
inhaleMs: number; // Inhale duration
|
|
129
|
+
exhaleMs: number; // Exhale duration
|
|
130
|
+
holdInMs: number; // Hold after inhale (0 if none)
|
|
131
|
+
holdOutMs: number; // Hold after exhale (0 if none)
|
|
132
|
+
cycleMs: number; // Total cycle duration
|
|
133
|
+
peakEnergy: number; // Peak energy (0–1)
|
|
134
|
+
confidence: number; // Detection confidence (0–100)
|
|
135
|
+
labelSwapped: boolean; // Whether in/out was corrected by centroid
|
|
136
|
+
centroidA1: number; // Spectral centroid phase 1 (Hz)
|
|
137
|
+
centroidA2: number; // Spectral centroid phase 2 (Hz)
|
|
138
|
+
method: string; // 'threshold' or 'peak'
|
|
139
|
+
timestamp: number; // Completion time
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Use with Capacitor / iOS
|
|
144
|
+
|
|
145
|
+
On iOS, `WKWebView`'s Web Audio API `AnalyserNode` returns garbage data with `getUserMedia`. Use our companion plugin [`@shiihaa/capacitor-audio-analysis`](https://github.com/shiihaa-app/capacitor-audio-analysis) for native `AVAudioEngine` audio capture, then feed the energy values to `BreathDetector`.
|
|
146
|
+
|
|
147
|
+
## Background
|
|
148
|
+
|
|
149
|
+
This library was extracted from [shii · haa](https://shiihaa.app), a breathwork and biofeedback app. The breath detection algorithm was developed to solve a specific challenge: real-time breath phase detection using only a smartphone microphone, without any wearable sensor.
|
|
150
|
+
|
|
151
|
+
The spectral centroid approach for inhale/exhale classification emerged from the observation that inhaled air creates turbulent flow (higher frequencies) while exhaled air creates laminar flow (lower frequencies) — a well-known principle in respiratory physiology that hadn't been applied to mobile breath detection before.
|
|
152
|
+
|
|
153
|
+
## Related
|
|
154
|
+
|
|
155
|
+
- [`@shiihaa/capacitor-audio-analysis`](https://github.com/shiihaa-app/capacitor-audio-analysis) — Native iOS audio capture plugin (AVAudioEngine) for Capacitor
|
|
156
|
+
- [shii · haa](https://shiihaa.app) — The breathwork app that uses this library
|
|
157
|
+
- [Blog: How We Solved iOS Audio Analysis](https://shiihaa.app/#blog-audio) — Technical deep-dive
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT © [Felix Zeller](https://linkedin.com/in/felixzeller) / [shii · haa](https://shiihaa.app)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shiihaa/breath-detection
|
|
3
|
+
* Real-time breath cycle detection using microphone energy analysis,
|
|
4
|
+
* spectral centroid classification, and optional BLE heart rate data.
|
|
5
|
+
*
|
|
6
|
+
* © 2026 shii · haa — Felix Zeller
|
|
7
|
+
* MIT License
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Types
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface BreathDetectorOptions {
|
|
15
|
+
/** FFT size for audio analysis (default: 4096) */
|
|
16
|
+
fftSize?: number;
|
|
17
|
+
/** EMA smoothing alpha for energy (0-1, default: 0.25) */
|
|
18
|
+
smoothingAlpha?: number;
|
|
19
|
+
/** Minimum seconds between detected cycles (default: 2.5) */
|
|
20
|
+
minCycleGapSeconds?: number;
|
|
21
|
+
/** Minimum seconds per breath phase (default: 1.5) */
|
|
22
|
+
minPhaseSeconds?: number;
|
|
23
|
+
/** Threshold factor: 0 = very sensitive, 1 = very strict (default: 0.35) */
|
|
24
|
+
thresholdFactor?: number;
|
|
25
|
+
/** Low frequency bound in Hz (default: 150) */
|
|
26
|
+
freqLow?: number;
|
|
27
|
+
/** High frequency bound in Hz (default: 2500) */
|
|
28
|
+
freqHigh?: number;
|
|
29
|
+
/** Enable spectral centroid for inhale/exhale classification (default: true) */
|
|
30
|
+
enableCentroid?: boolean;
|
|
31
|
+
/** Centroid Hz difference needed for confident classification (default: 40) */
|
|
32
|
+
centroidThreshold?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BreathCycle {
|
|
36
|
+
/** Inhale duration in ms */
|
|
37
|
+
inhaleMs: number;
|
|
38
|
+
/** Exhale duration in ms */
|
|
39
|
+
exhaleMs: number;
|
|
40
|
+
/** Hold after inhale in ms (0 if no hold) */
|
|
41
|
+
holdInMs: number;
|
|
42
|
+
/** Hold after exhale in ms (0 if no hold) */
|
|
43
|
+
holdOutMs: number;
|
|
44
|
+
/** Total cycle duration in ms */
|
|
45
|
+
cycleMs: number;
|
|
46
|
+
/** Peak energy during this cycle (0-1 normalized) */
|
|
47
|
+
peakEnergy: number;
|
|
48
|
+
/** Detection confidence (0-100) */
|
|
49
|
+
confidence: number;
|
|
50
|
+
/** Whether inhale/exhale labels were swapped based on centroid or BLE */
|
|
51
|
+
labelSwapped: boolean;
|
|
52
|
+
/** Spectral centroid during phase 1 (Hz) */
|
|
53
|
+
centroidA1: number;
|
|
54
|
+
/** Spectral centroid during phase 2 (Hz) */
|
|
55
|
+
centroidA2: number;
|
|
56
|
+
/** Detection method: 'threshold' (normal) or 'peak' (pauseless fallback) */
|
|
57
|
+
method: 'threshold' | 'peak';
|
|
58
|
+
/** Timestamp of cycle completion */
|
|
59
|
+
timestamp: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BreathPhaseEvent {
|
|
63
|
+
/** Current phase: 'idle', 'inhale', 'transition', 'exhale', 'pause' */
|
|
64
|
+
phase: 'idle' | 'inhale' | 'transition' | 'exhale' | 'pause';
|
|
65
|
+
/** Normalized energy (0-1) */
|
|
66
|
+
energy: number;
|
|
67
|
+
/** Spectral centroid in Hz */
|
|
68
|
+
centroid: number;
|
|
69
|
+
/** Time in current phase (ms) */
|
|
70
|
+
phaseTime: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CalibrationResult {
|
|
74
|
+
/** Noise floor energy level */
|
|
75
|
+
noiseFloor: number;
|
|
76
|
+
/** Maximum breath energy level */
|
|
77
|
+
breathMax: number;
|
|
78
|
+
/** Whether calibration succeeded */
|
|
79
|
+
success: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type BreathEventCallback = (cycle: BreathCycle) => void;
|
|
83
|
+
export type PhaseEventCallback = (event: BreathPhaseEvent) => void;
|
|
84
|
+
export type CalibrationCallback = (result: CalibrationResult) => void;
|
|
85
|
+
export type EnergyCallback = (energy: number, centroid: number) => void;
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// BreathDetector
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export declare class BreathDetector {
|
|
92
|
+
constructor(options?: BreathDetectorOptions);
|
|
93
|
+
start(): Promise<boolean>;
|
|
94
|
+
stop(): void;
|
|
95
|
+
calibrate(): Promise<CalibrationResult>;
|
|
96
|
+
startDetection(): void;
|
|
97
|
+
stopDetection(): void;
|
|
98
|
+
setHeartRate(bpm: number): void;
|
|
99
|
+
getState(): { running: boolean; calibrated: boolean; phase: string; energy: number; centroid: number; noiseFloor: number; breathMax: number };
|
|
100
|
+
onCycle(callback: BreathEventCallback): void;
|
|
101
|
+
onPhase(callback: PhaseEventCallback): void;
|
|
102
|
+
onCalibration(callback: CalibrationCallback): void;
|
|
103
|
+
onEnergy(callback: EnergyCallback): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default BreathDetector;
|
|
107
|
+
|
|
108
|
+
// Default export
|
|
109
|
+
export default BreathDetector;
|