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
23 changes: 22 additions & 1 deletion tests/test_audio_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
22 changes: 21 additions & 1 deletion tests/test_session_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
15 changes: 6 additions & 9 deletions voiceMonitor/audio_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand All @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion voiceMonitor/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@ 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
auxiliary data, stored for future analysis, and do not yet
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,
Expand All @@ -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):
Expand Down
Loading