diff --git a/tests/test_baseline.py b/tests/test_baseline.py new file mode 100644 index 0000000..a70d173 --- /dev/null +++ b/tests/test_baseline.py @@ -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 \ No newline at end of file diff --git a/voiceMonitor/baseline.py b/voiceMonitor/baseline.py new file mode 100644 index 0000000..a135acb --- /dev/null +++ b/voiceMonitor/baseline.py @@ -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, + } \ No newline at end of file diff --git a/voiceMonitor/config.py b/voiceMonitor/config.py index d347646..228cf5e 100644 --- a/voiceMonitor/config.py +++ b/voiceMonitor/config.py @@ -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 @@ -20,4 +20,8 @@ class Config: SAFE_RECOVERY_LEVEL = 40 # acute level considered recovered # acoustic feature extraction - EXTRACT_ACOUSTIC_FEATURES = True \ No newline at end of file + 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 \ No newline at end of file