diff --git a/tests/test_audio_stream.py b/tests/test_audio_stream.py index bd39c33..f1fb85b 100644 --- a/tests/test_audio_stream.py +++ b/tests/test_audio_stream.py @@ -86,4 +86,25 @@ def test_start_runs_for_fixed_duration_and_returns_session( assert session is vm.session # two one-second steps should have produced two processed windows assert len(session.records) == 2 - assert mock_score.call_count == 2 \ No newline at end of file + assert mock_score.call_count == 2 + +@patch("voiceMonitor.audio_stream.extract_acoustic_features") +@patch("voiceMonitor.audio_stream.score_audio") +@patch("voiceMonitor.audio_stream.preprocess_audio") +def test_process_chunk_wires_baseline_calibration( + mock_preprocess, mock_score, mock_features, tmp_path +): + mock_preprocess.return_value = ["processed_chunk.wav"] + mock_score.return_value = 50.0 + mock_features.return_value = {} + + vm = VoiceMonitor(chunk_dir=str(tmp_path / "chunks_test7")) + audio = np.zeros((Config.SAMPLE_RATE,), dtype="float32") + raw_file = vm._save_chunk(audio, "20260101_000000") + + vm._process_chunk(raw_file, "20260101_000000", elapsed_seconds=0) + + record = vm.session.records[0] + assert record["baseline"] is not None + assert record["baseline_adjusted_score"] is not None + assert record["baseline_is_provisional"] is True \ No newline at end of file diff --git a/tests/test_session_report.py b/tests/test_session_report.py index 117a0a8..7a35009 100644 --- a/tests/test_session_report.py +++ b/tests/test_session_report.py @@ -91,4 +91,24 @@ def test_recovery_eta_decreases_as_session_recovers(): def test_recovery_eta_is_zero_for_low_scores(): report = SessionReport() report.add_record("ts1", "chunks/ts1.wav", 5, elapsed_seconds=0) - assert report.records[0]["recovery_eta_sec"] == 0 \ No newline at end of file + assert report.records[0]["recovery_eta_sec"] == 0 + +def test_add_record_stores_baseline_info_when_given(): + report = SessionReport() + baseline_info = {"baseline": 30, "adjusted_score": 20, "is_provisional": True} + report.add_record( + "ts1", "chunks/ts1.wav", 50, elapsed_seconds=0, baseline_info=baseline_info + ) + record = report.records[0] + assert record["baseline"] == 30 + assert record["baseline_adjusted_score"] == 20 + assert record["baseline_is_provisional"] is True + + +def test_add_record_baseline_fields_default_to_none_when_omitted(): + report = SessionReport() + report.add_record("ts1", "chunks/ts1.wav", 50, elapsed_seconds=0) + record = report.records[0] + assert record["baseline"] is None + assert record["baseline_adjusted_score"] is None + assert record["baseline_is_provisional"] is None \ No newline at end of file diff --git a/voiceMonitor/audio_stream.py b/voiceMonitor/audio_stream.py index 9e42d20..2ce6267 100644 --- a/voiceMonitor/audio_stream.py +++ b/voiceMonitor/audio_stream.py @@ -10,6 +10,7 @@ from .config import Config from .utils import timestamp, ensure_dir from .session import SessionReport +from .baseline import BaselineCalibrator logger = logging.getLogger(__name__) @@ -81,6 +82,7 @@ def __init__(self, threshold=None, chunk_dir=None): self.chunk_dir = chunk_dir or f"{Config.CHUNK_DIR}_{timestamp()}" ensure_dir(self.chunk_dir) self.session = SessionReport() + self.baseline = BaselineCalibrator() self._samples_processed = 0 def _save_chunk(self, audio_arr: np.ndarray, ts: str): @@ -102,8 +104,11 @@ def _process_chunk(self, wav_path: str, ts: str, elapsed_seconds: float): if Config.EXTRACT_ACOUSTIC_FEATURES: features = extract_acoustic_features(processed) + baseline_info = self.baseline.update(score, elapsed_seconds) + self.session.add_record( - ts, processed, score, elapsed_seconds, features=features + ts, processed, score, elapsed_seconds, + features=features, baseline_info=baseline_info, ) return score @@ -127,21 +132,13 @@ def start(self, duration_sec=None): if len(buffer) >= win_samples: ts = timestamp() - # elapsed_seconds is derived from the actual audio - # samples consumed so far, the same deterministic - # clock the audio stream itself runs on, rather than a - # separate wall clock that could drift due to - # processing latency between windows elapsed_seconds = self._samples_processed / Config.SAMPLE_RATE - # write raw to wav raw_file = self._save_chunk(buffer[:win_samples], ts) score = self._process_chunk(raw_file, ts, elapsed_seconds) - # slide buffer buffer = buffer[int(step_samples):] - # warnings & prints print(f"[{ts}] Score: {score:.2f}") if score >= self.threshold: print("⚠ fatigue threshold crossed") diff --git a/voiceMonitor/session.py b/voiceMonitor/session.py index 3f783b0..e841a8f 100644 --- a/voiceMonitor/session.py +++ b/voiceMonitor/session.py @@ -6,7 +6,10 @@ def __init__(self): self.analytics = SessionAnalytics() self.records = [] - def add_record(self, timestamp, chunk_file, score, elapsed_seconds=None, features=None): + def add_record( + self, timestamp, chunk_file, score, elapsed_seconds=None, + features=None, baseline_info=None, + ): """ features: acoustic markers (jitter, shimmer, HNR, CPPS) collected alongside the primary auralis_vfs score. These are currently @@ -14,8 +17,15 @@ def add_record(self, timestamp, chunk_file, score, elapsed_seconds=None, feature influence the fatigue score, warning logic, or any analytics computed in this module. Integrating them into the fatigue model itself is planned as future work, not part of this change. + + baseline_info: output of BaselineCalibrator.update (baseline, + adjusted_score, is_provisional), stored alongside the raw score for + the same reason as features above. The raw score and warning + threshold logic are unchanged by this; baseline adjusted values are + exposed for inspection and future use, not yet wired into alerting. """ smoothed, impulse_state = self.analytics.add(score, timestamp, elapsed_seconds) + baseline_info = baseline_info or {} self.records.append({ "timestamp": timestamp, @@ -27,6 +37,9 @@ def add_record(self, timestamp, chunk_file, score, elapsed_seconds=None, feature "chronic_load": impulse_state["chronic"], "readiness_experimental": impulse_state["readiness_experimental"], "recovery_eta_sec": impulse_state["recovery_eta_sec"], + "baseline": baseline_info.get("baseline"), + "baseline_adjusted_score": baseline_info.get("adjusted_score"), + "baseline_is_provisional": baseline_info.get("is_provisional"), }) def export_json(self, path):