Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions tests/test_baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from voiceMonitor.baseline import BaselineCalibrator


def test_first_reading_uses_population_default():
calib = BaselineCalibrator(calibration_sec=45, default_baseline=30)
result = calib.update(score=50, elapsed_seconds=0)
assert result["baseline"] == 30
assert result["is_provisional"] is True


def test_baseline_updates_as_more_scores_seen_during_calibration():
calib = BaselineCalibrator(calibration_sec=45, default_baseline=30)
calib.update(score=20, elapsed_seconds=0)
result = calib.update(score=40, elapsed_seconds=10)
# average of 20 and 40 is 30, matches the running estimate
assert result["baseline"] == 30
assert result["is_provisional"] is True


def test_baseline_locks_after_calibration_window_closes():
calib = BaselineCalibrator(calibration_sec=10, default_baseline=30)
calib.update(score=20, elapsed_seconds=0)
calib.update(score=40, elapsed_seconds=5)
# window closes here; locked baseline should be avg(20, 40) = 30
result = calib.update(score=100, elapsed_seconds=15)
assert result["baseline"] == 30
assert result["is_provisional"] is False


def test_locked_baseline_does_not_change_after_lock():
calib = BaselineCalibrator(calibration_sec=10, default_baseline=30)
calib.update(score=20, elapsed_seconds=0)
calib.update(score=40, elapsed_seconds=5)
calib.update(score=100, elapsed_seconds=15) # locks here at 30
result = calib.update(score=5, elapsed_seconds=20)
# even though a very low score comes in post lock, baseline stays fixed
assert result["baseline"] == 30


def test_adjusted_score_is_score_minus_baseline():
calib = BaselineCalibrator(calibration_sec=10, default_baseline=30)
result = calib.update(score=50, elapsed_seconds=0)
assert result["adjusted_score"] == 20 # 50 - 30


def test_adjusted_score_never_negative():
calib = BaselineCalibrator(calibration_sec=10, default_baseline=30)
result = calib.update(score=10, elapsed_seconds=0)
assert result["adjusted_score"] == 0


def test_falls_back_to_population_default_with_no_calibration_data():
# session ends with only one instantaneous reading right at elapsed=0
calib = BaselineCalibrator(calibration_sec=45, default_baseline=30)
result = calib.update(score=60, elapsed_seconds=0)
assert result["baseline"] == 30
assert result["adjusted_score"] == 30


def test_is_locked_property_reflects_state():
calib = BaselineCalibrator(calibration_sec=10, default_baseline=30)
assert calib.is_locked is False
calib.update(score=20, elapsed_seconds=0)
assert calib.is_locked is False
calib.update(score=20, elapsed_seconds=15)
assert calib.is_locked is True
83 changes: 83 additions & 0 deletions voiceMonitor/baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from .config import Config


class BaselineCalibrator:
"""
Establishes a personal fatigue baseline for the current speaker during
the first portion of a session, rather than comparing everyone against
one fixed population level. Fatigue is then reported as a deviation
from that speaker's own baseline, so a naturally breathy or otherwise
atypical voice is not mistaken for fatigue.

During calibration (elapsed_seconds below the configured calibration
window), the baseline is estimated from whatever scores have been seen
so far, and readings are flagged as provisional since the estimate is
still forming. If a session ends before enough calibration data has
been collected, or before the calibration window closes, a conservative
population default is used instead of an unstable partial estimate
computed from very little data.

After the calibration window closes, the baseline is locked to the
average of the scores collected during calibration and does not change
for the rest of the session, so later fatigue readings are compared
against a stable reference point rather than one that keeps drifting.
"""

def __init__(self, calibration_sec=None, default_baseline=None):
self.calibration_sec = (
calibration_sec
if calibration_sec is not None
else Config.BASELINE_CALIBRATION_SEC
)
self.default_baseline = (
default_baseline
if default_baseline is not None
else Config.DEFAULT_POPULATION_BASELINE
)
self._scores = []
self._locked_baseline = None

@property
def is_locked(self):
return self._locked_baseline is not None

def _current_estimate(self):
if not self._scores:
return self.default_baseline
return sum(self._scores) / len(self._scores)

def update(self, score, elapsed_seconds):
"""
Returns a dict with the baseline used for this reading, the score
adjusted for that baseline (never negative, since a fatigue level
below one's own baseline is reported as zero rather than negative),
and whether the reading falls inside the still forming calibration
window.

The very first reading in a session has no prior data at all, so
it is compared against the population default rather than against
itself, which would otherwise always report zero deviation. Once
at least one prior reading exists, the baseline reflects the
running average including the current reading.
"""
still_calibrating = elapsed_seconds < self.calibration_sec

if still_calibrating:
if not self._scores:
baseline = self.default_baseline
self._scores.append(score)
else:
self._scores.append(score)
baseline = self._current_estimate()
else:
if self._locked_baseline is None:
self._locked_baseline = self._current_estimate()
baseline = self._locked_baseline

adjusted_score = max(0.0, score - baseline)

return {
"baseline": baseline,
"adjusted_score": adjusted_score,
"is_provisional": still_calibrating,
}
8 changes: 6 additions & 2 deletions voiceMonitor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class Config:
STEP_SEC = 4

# fatigue warnings
DEFAULT_THRESHOLD = 70 # out of 0 to 100 scale
DEFAULT_THRESHOLD = 70 # out of 0-100 scale

# session metadata
SAVE_CHUNKS = True
Expand All @@ -20,4 +20,8 @@ class Config:
SAFE_RECOVERY_LEVEL = 40 # acute level considered recovered

# acoustic feature extraction
EXTRACT_ACOUSTIC_FEATURES = True
EXTRACT_ACOUSTIC_FEATURES = True

# personal baseline calibration
BASELINE_CALIBRATION_SEC = 45 # seconds, per design doc's 30-60s window
DEFAULT_POPULATION_BASELINE = 30 # fallback used before calibration completes
Loading