diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57c46a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libportaudio2 ffmpeg + + - name: Install project dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install praat-parselmouth + pip install pytest pytest-cov + + - name: Run tests with coverage + run: | + pytest --cov=voiceMonitor --cov-report=term-missing --cov-report=xml + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b6975c8..0803418 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ soundfile>=0.12 pydub>=0.25 ffmpeg tqdm>=4.65 -sounddevice>=0.4 \ No newline at end of file +sounddevice>=0.4 +praat-parselmouth \ No newline at end of file diff --git a/tests/test_acoustic_features.py b/tests/test_acoustic_features.py new file mode 100644 index 0000000..c2058f4 --- /dev/null +++ b/tests/test_acoustic_features.py @@ -0,0 +1,21 @@ +import logging + +from voiceMonitor.audio_stream import extract_acoustic_features + + +def test_extraction_returns_empty_dict_on_missing_file(caplog): + """ + A nonexistent/invalid wav path should trigger the failure path and + return an empty dict rather than raising, and the failure should be + logged as a warning rather than silently swallowed. + """ + with caplog.at_level(logging.WARNING): + result = extract_acoustic_features("/tmp/this_file_does_not_exist.wav") + + assert result == {} + assert any("Acoustic feature extraction failed" in message for message in caplog.messages) + + +def test_extraction_returns_dict_type_even_on_failure(): + result = extract_acoustic_features("/tmp/another_missing_file.wav") + assert isinstance(result, dict) \ No newline at end of file diff --git a/tests/test_analytics_impulse.py b/tests/test_analytics_impulse.py new file mode 100644 index 0000000..fa8d214 --- /dev/null +++ b/tests/test_analytics_impulse.py @@ -0,0 +1,93 @@ +import math + +from voiceMonitor.analytics import EMAFatigue, ImpulseResponseFatigue, SessionAnalytics + + +def test_ema_first_value_passthrough(): + ema = EMAFatigue(alpha=0.3) + assert ema.update(50) == 50 + + +def test_ema_smooths_toward_new_value(): + ema = EMAFatigue(alpha=0.5) + ema.update(0) + result = ema.update(100) + assert result == 50 # 0.5 * 100 + 0.5 * 0 + + +def test_ema_default_alpha_from_config(): + ema = EMAFatigue() + assert ema.alpha > 0 and ema.alpha <= 1 + + +def test_impulse_accumulates_with_no_elapsed_time(): + model = ImpulseResponseFatigue(tau_fast=90, tau_slow=1500) + state1 = model.update(20, elapsed_seconds=0) + state2 = model.update(20, elapsed_seconds=0) + assert state2["acute"] == 40 + assert state2["chronic"] == 40 + + +def test_impulse_acute_decays_faster_than_chronic(): + model = ImpulseResponseFatigue(tau_fast=10, tau_slow=1000) + model.update(100, elapsed_seconds=0) + # advance a long time relative to tau_fast but short relative to tau_slow + state = model.update(0, elapsed_seconds=50) + assert state["acute"] < state["chronic"] + + +def test_impulse_readiness_is_chronic_minus_acute(): + model = ImpulseResponseFatigue(tau_fast=10, tau_slow=1000) + model.update(100, elapsed_seconds=0) + state = model.update(0, elapsed_seconds=50) + assert math.isclose( + state["readiness_experimental"], state["chronic"] - state["acute"] + ) + + +def test_recovery_eta_zero_when_already_safe(): + model = ImpulseResponseFatigue(tau_fast=90, tau_slow=1500) + model.update(10, elapsed_seconds=0) + eta = model.recovery_eta_seconds(safe_level=40) + assert eta == 0.0 + + +def test_recovery_eta_positive_when_above_safe_level(): + model = ImpulseResponseFatigue(tau_fast=90, tau_slow=1500) + model.update(100, elapsed_seconds=0) + eta = model.recovery_eta_seconds(safe_level=40) + assert eta > 0 + + +def test_recovery_eta_matches_manual_formula(): + model = ImpulseResponseFatigue(tau_fast=90, tau_slow=1500) + model.update(200, elapsed_seconds=0) + expected = 90 * math.log(200 / 40) + assert math.isclose(model.recovery_eta_seconds(safe_level=40), expected) + + +def test_session_analytics_summary_shape(): + sa = SessionAnalytics() + sa.add(20, "ts0", 0) + sa.add(80, "ts1", 5) + summary = sa.summary + for key in [ + "average_fatigue", "max_fatigue", "readings", "smoothed_fatigue", + "acute_load", "chronic_load", "readiness_experimental", "recovery_eta_sec", + ]: + assert key in summary + + +def test_session_analytics_average_and_max(): + sa = SessionAnalytics() + sa.add(20, "ts0", 0) + sa.add(80, "ts1", 5) + assert sa.average == 50 + assert sa.maximum == 80 + + +def test_session_analytics_empty_defaults(): + sa = SessionAnalytics() + assert sa.average == 0 + assert sa.maximum == 0 + assert sa.summary["readings"] == 0 \ No newline at end of file diff --git a/tests/test_audio_stream.py b/tests/test_audio_stream.py new file mode 100644 index 0000000..bd39c33 --- /dev/null +++ b/tests/test_audio_stream.py @@ -0,0 +1,89 @@ +import os +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from voiceMonitor.audio_stream import VoiceMonitor +from voiceMonitor.config import Config + + +def test_voice_monitor_init_creates_chunk_dir(tmp_path): + chunk_dir = tmp_path / "chunks_test" + vm = VoiceMonitor(chunk_dir=str(chunk_dir)) + assert chunk_dir.exists() + assert vm.threshold == Config.DEFAULT_THRESHOLD + + +def test_voice_monitor_init_custom_threshold(tmp_path): + vm = VoiceMonitor(threshold=55, chunk_dir=str(tmp_path / "chunks_test2")) + assert vm.threshold == 55 + + +def test_save_chunk_writes_wav_file(tmp_path): + vm = VoiceMonitor(chunk_dir=str(tmp_path / "chunks_test3")) + audio = np.zeros((Config.SAMPLE_RATE,), dtype="float32") + path = vm._save_chunk(audio, "20260101_000000") + assert os.path.exists(path) + assert path.endswith(".wav") + + +@patch("voiceMonitor.audio_stream.extract_acoustic_features") +@patch("voiceMonitor.audio_stream.score_audio") +@patch("voiceMonitor.audio_stream.preprocess_audio") +def test_process_chunk_calls_scoring_and_records_result( + mock_preprocess, mock_score, mock_features, tmp_path +): + mock_preprocess.return_value = ["processed_chunk.wav"] + mock_score.return_value = 66.6 + mock_features.return_value = {"jitter_local": 0.01} + + vm = VoiceMonitor(chunk_dir=str(tmp_path / "chunks_test4")) + audio = np.zeros((Config.SAMPLE_RATE,), dtype="float32") + raw_file = vm._save_chunk(audio, "20260101_000000") + + score = vm._process_chunk(raw_file, "20260101_000000", elapsed_seconds=0) + + assert score == 66.6 + assert len(vm.session.records) == 1 + assert vm.session.records[0]["score"] == 66.6 + assert vm.session.records[0]["features"] == {"jitter_local": 0.01} + + +@patch("voiceMonitor.audio_stream.preprocess_audio") +def test_process_chunk_returns_none_when_preprocessing_yields_nothing( + mock_preprocess, tmp_path +): + mock_preprocess.return_value = [] + vm = VoiceMonitor(chunk_dir=str(tmp_path / "chunks_test5")) + audio = np.zeros((Config.SAMPLE_RATE,), dtype="float32") + raw_file = vm._save_chunk(audio, "20260101_000000") + + result = vm._process_chunk(raw_file, "20260101_000000", elapsed_seconds=0) + assert result is None + assert len(vm.session.records) == 0 + + +@patch("voiceMonitor.audio_stream.extract_acoustic_features", return_value={}) +@patch("voiceMonitor.audio_stream.score_audio", return_value=30.0) +@patch("voiceMonitor.audio_stream.preprocess_audio", return_value=["processed.wav"]) +@patch("voiceMonitor.audio_stream.sd.InputStream") +def test_start_runs_for_fixed_duration_and_returns_session( + mock_input_stream, mock_preprocess, mock_score, mock_features, tmp_path, monkeypatch +): + # use small, fast values so the test does not depend on real audio timing + monkeypatch.setattr(Config, "SAMPLE_RATE", 10) + monkeypatch.setattr(Config, "WINDOW_SEC", 1) + monkeypatch.setattr(Config, "STEP_SEC", 1) + + fake_stream = MagicMock() + fake_stream.read.return_value = (np.zeros((10, 1), dtype="float32"), False) + mock_input_stream.return_value.__enter__.return_value = fake_stream + + vm = VoiceMonitor(chunk_dir=str(tmp_path / "chunks_test6")) + session = vm.start(duration_sec=2) + + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5e889be --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +from voiceMonitor import cli + + +@patch("voiceMonitor.cli.VoiceMonitor") +def test_main_parses_args_and_runs_session(mock_voice_monitor_cls, monkeypatch): + mock_instance = MagicMock() + mock_report = MagicMock() + mock_instance.start.return_value = mock_report + mock_voice_monitor_cls.return_value = mock_instance + + monkeypatch.setattr( + "sys.argv", ["voicemonitor", "--duration", "30", "--threshold", "65"] + ) + + cli.main() + + mock_voice_monitor_cls.assert_called_once_with(threshold=65.0) + mock_instance.start.assert_called_once_with(duration_sec=30) + mock_report.export_json.assert_called_once_with("session_report.json") + + +@patch("voiceMonitor.cli.VoiceMonitor") +def test_main_uses_defaults_when_no_args_given(mock_voice_monitor_cls, monkeypatch): + mock_instance = MagicMock() + mock_instance.start.return_value = MagicMock() + mock_voice_monitor_cls.return_value = mock_instance + + monkeypatch.setattr("sys.argv", ["voicemonitor"]) + + cli.main() + + mock_voice_monitor_cls.assert_called_once_with(threshold=None) + mock_instance.start.assert_called_once_with(duration_sec=None) \ No newline at end of file diff --git a/tests/test_session_report.py b/tests/test_session_report.py new file mode 100644 index 0000000..8311e93 --- /dev/null +++ b/tests/test_session_report.py @@ -0,0 +1,62 @@ +import json +import os +import tempfile + +from voiceMonitor.session import SessionReport + + +def test_add_record_stores_fields(): + report = SessionReport() + report.add_record("ts1", "chunks/ts1.wav", 42.5, elapsed_seconds=0) + + assert len(report.records) == 1 + record = report.records[0] + assert record["timestamp"] == "ts1" + assert record["chunk"] == "chunks/ts1.wav" + assert record["score"] == 42.5 + assert record["features"] == {} + + +def test_add_record_stores_features_when_given(): + report = SessionReport() + features = {"jitter_local": 0.01, "shimmer_local": 0.02, "hnr": 15.0, "cpps": 8.0} + report.add_record("ts1", "chunks/ts1.wav", 42.5, elapsed_seconds=0, features=features) + + assert report.records[0]["features"] == features + + +def test_add_record_defaults_elapsed_seconds_when_omitted(): + # backward compatible call, mirrors pre-existing callers + report = SessionReport() + report.add_record("ts1", "chunks/ts1.wav", 10) + report.add_record("ts2", "chunks/ts2.wav", 20) + assert report.analytics.average == 15 + assert report.analytics.maximum == 20 + + +def test_add_record_updates_analytics(): + report = SessionReport() + report.add_record("ts1", "chunks/ts1.wav", 10, elapsed_seconds=0) + report.add_record("ts2", "chunks/ts2.wav", 30, elapsed_seconds=5) + + assert report.analytics.average == 20 + assert report.analytics.maximum == 30 + assert report.analytics.summary["readings"] == 2 + + +def test_export_json_writes_expected_structure(): + report = SessionReport() + report.add_record("ts1", "chunks/ts1.wav", 50, elapsed_seconds=0) + + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, "session_report.json") + report.export_json(out_path) + + assert os.path.exists(out_path) + with open(out_path) as fp: + data = json.load(fp) + + assert "summary" in data + assert "records" in data + assert data["records"][0]["timestamp"] == "ts1" + assert data["summary"]["readings"] == 1 \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1844bfc --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,35 @@ +import os +import re +import shutil +import tempfile + +from voiceMonitor.utils import timestamp, ensure_dir + + +def test_timestamp_format(): + ts = timestamp() + # expected format: YYYYMMDD_HHMMSS + assert re.match(r"^\d{8}_\d{6}$", ts) + + +def test_ensure_dir_creates_missing_directory(): + tmp_root = tempfile.mkdtemp() + try: + target = os.path.join(tmp_root, "nested", "chunks") + assert not os.path.exists(target) + ensure_dir(target) + assert os.path.exists(target) + assert os.path.isdir(target) + finally: + shutil.rmtree(tmp_root) + + +def test_ensure_dir_is_idempotent_on_existing_directory(): + tmp_root = tempfile.mkdtemp() + try: + # should not raise when the directory already exists + ensure_dir(tmp_root) + ensure_dir(tmp_root) + assert os.path.exists(tmp_root) + finally: + shutil.rmtree(tmp_root) \ No newline at end of file diff --git a/voiceMonitor/analytics.py b/voiceMonitor/analytics.py index 7f6c4a8..750a4ea 100644 --- a/voiceMonitor/analytics.py +++ b/voiceMonitor/analytics.py @@ -1,12 +1,145 @@ +import math + +from .config import Config + + +class EMAFatigue: + """ + Smooths the raw per window fatigue score using an exponential moving + average, so a single noisy window (a cough, a loud word, a pause) does + not cause the reported fatigue level to jump around. Only the previous + smoothed value is kept, so this stays lightweight and does not require + the full session history. + """ + + def __init__(self, alpha=None): + self.alpha = alpha if alpha is not None else Config.EMA_ALPHA + self.value = None + + def update(self, score): + if self.value is None: + self.value = score + else: + self.value = self.alpha * score + (1 - self.alpha) * self.value + return self.value + + +class ImpulseResponseFatigue: + """ + Models accumulating vocal load using two parallel leaky integrators, + adapted from the Banister impulse response framework used in sports + science for athletic training load and recovery. + + Each incoming fatigue score is treated as an impulse applied to a + fast decaying "acute" component (short term strain) and a slow decaying + "chronic" component (strain accumulating across the session). + + NOTE on the readiness metric: "readiness" (chronic minus acute) mirrors + how the athletic model computes freshness as fitness minus fatigue. + Because voice has no direct positive equivalent to athletic fitness, + both components here are reinterpreted as strain at different + timescales rather than as fitness and fatigue. This reinterpretation, + and the readiness formulation itself, has not yet been empirically + validated for vocal fatigue. Treat "readiness" as an experimental, + exploratory metric. The acute and chronic components are exposed + separately precisely so they can be inspected and used independently + until readiness is validated against real session data. + + Elapsed time for decay is passed in explicitly by the caller as + elapsed_seconds, rather than measured internally with a wall clock. + This keeps the decay math on the same deterministic time base as the + audio stream itself (samples processed divided by sample rate), so it + cannot drift apart from the session's recorded timestamps due to + variable processing latency between windows. + """ + + def __init__(self, tau_fast=None, tau_slow=None): + self.tau_fast = tau_fast if tau_fast is not None else Config.TAU_FAST + self.tau_slow = tau_slow if tau_slow is not None else Config.TAU_SLOW + self.acute = 0.0 + self.chronic = 0.0 + self._last_elapsed = None + + def update(self, score, elapsed_seconds): + """ + score: raw fatigue score for this window + elapsed_seconds: cumulative elapsed time of the audio stream, in + seconds, at the moment this window was captured. Must come from + the same deterministic clock used elsewhere in the session (audio + samples processed divided by sample rate), not a separate wall + clock, so temporal load calculations stay consistent with the + session's own timestamps. + """ + if self._last_elapsed is not None: + dt = max(0.0, elapsed_seconds - self._last_elapsed) + self.acute *= math.exp(-dt / self.tau_fast) + self.chronic *= math.exp(-dt / self.tau_slow) + + self.acute += score + self.chronic += score + self._last_elapsed = elapsed_seconds + + return { + "acute": self.acute, + "chronic": self.chronic, + "readiness_experimental": self.chronic - self.acute, + } + + def recovery_eta_seconds(self, safe_level=None): + """ + Estimated seconds until the acute component decays back under the + safe level. Returns 0 if already at or below it. + """ + safe_level = ( + safe_level if safe_level is not None else Config.SAFE_RECOVERY_LEVEL + ) + if safe_level <= 0 or self.acute <= safe_level: + return 0.0 + return self.tau_fast * math.log(self.acute / safe_level) + + class SessionAnalytics: def __init__(self): self.scores = [] self.timestamps = [] + self.smoothed_scores = [] + self.impulse_records = [] + + self._ema = EMAFatigue() + self._impulse = ImpulseResponseFatigue() + + def add(self, score, ts, elapsed_seconds=None): + """ + ts: the human readable session timestamp string (e.g. from + utils.timestamp()), used for record labeling and display only. + elapsed_seconds: the deterministic, audio stream derived elapsed + time in seconds, used for all decay and load calculations. Keeping + these two separate but both required makes explicit that display + timestamps and temporal math must not be conflated. + + If elapsed_seconds is not provided, it defaults to a count based + estimate (number of windows added so far multiplied by the + configured step size). This keeps the method backward compatible + with callers that only have a raw score and timestamp, while real + usage (VoiceMonitor.start) always supplies the true elapsed time + derived from audio samples processed, which is what the decay + model should use whenever it is available. + """ + if elapsed_seconds is None: + elapsed_seconds = len(self.scores) * Config.STEP_SEC - def add(self, score, ts): self.scores.append(score) self.timestamps.append(ts) + smoothed = self._ema.update(score) + self.smoothed_scores.append(smoothed) + + impulse_state = self._impulse.update(score, elapsed_seconds) + impulse_state["recovery_eta_sec"] = self._impulse.recovery_eta_seconds() + self.impulse_records.append(impulse_state) + + return smoothed, impulse_state + @property def average(self): return sum(self.scores) / len(self.scores) if self.scores else 0 @@ -17,8 +150,14 @@ def maximum(self): @property def summary(self): + latest = self.impulse_records[-1] if self.impulse_records else {} return { "average_fatigue": self.average, "max_fatigue": self.maximum, - "readings": len(self.scores) + "readings": len(self.scores), + "smoothed_fatigue": self.smoothed_scores[-1] if self.smoothed_scores else 0, + "acute_load": latest.get("acute", 0), + "chronic_load": latest.get("chronic", 0), + "readiness_experimental": latest.get("readiness_experimental", 0), + "recovery_eta_sec": latest.get("recovery_eta_sec", 0), } \ No newline at end of file diff --git a/voiceMonitor/audio_stream.py b/voiceMonitor/audio_stream.py index 4a56386..9e42d20 100644 --- a/voiceMonitor/audio_stream.py +++ b/voiceMonitor/audio_stream.py @@ -1,3 +1,5 @@ +import logging + import sounddevice as sd import numpy as np import os @@ -9,6 +11,69 @@ from .utils import timestamp, ensure_dir from .session import SessionReport +logger = logging.getLogger(__name__) + +try: + import parselmouth + from parselmouth.praat import call + _PARSELMOUTH_AVAILABLE = True +except ImportError: + _PARSELMOUTH_AVAILABLE = False + + +def extract_acoustic_features(wav_path): + """ + Extracts jitter, shimmer, harmonics to noise ratio, and smoothed + cepstral peak prominence from a short audio segment using Praat, via + parselmouth. These are established speech pathology markers of vocal + fatigue, collected here as auxiliary features for future analysis. + They are stored alongside the primary auralis_vfs score but do not + currently influence the fatigue score, warning logic, or any + downstream analytics computed by this package. + + Returns an empty dict if parselmouth is unavailable or if extraction + fails on a short or silent window. Failures are logged as warnings + rather than raised, so a single bad window does not interrupt the + monitoring session, while still leaving a trace for debugging. + """ + if not _PARSELMOUTH_AVAILABLE: + logger.warning( + "parselmouth is not installed; skipping acoustic feature extraction for %s", + wav_path, + ) + return {} + + try: + sound = parselmouth.Sound(wav_path) + point_process = call(sound, "To PointProcess (periodic, cc)", 75, 500) + + jitter = call(point_process, "Get jitter (local)", 0, 0, 0.0001, 0.02, 1.3) + shimmer = call( + [sound, point_process], + "Get shimmer (local)", + 0, 0, 0.0001, 0.02, 1.3, 1.6, + ) + + harmonicity = call(sound, "To Harmonicity (cc)", 0.01, 75, 0.1, 1.0) + hnr = call(harmonicity, "Get mean", 0, 0) + + cepstrogram = call(sound, "To PowerCepstrogram", 60, 0.002, 5000, 50) + cpps = call( + cepstrogram, "Get CPPS", True, 0.02, 0.0005, 60, 330, + 0.05, "Parabolic", 0.001, 0, "Straight", "Robust", + ) + + return { + "jitter_local": jitter, + "shimmer_local": shimmer, + "hnr": hnr, + "cpps": cpps, + } + except Exception as exc: + logger.warning("Acoustic feature extraction failed for %s: %s", wav_path, exc) + return {} + + class VoiceMonitor: def __init__(self, threshold=None, chunk_dir=None): @@ -16,6 +81,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._samples_processed = 0 def _save_chunk(self, audio_arr: np.ndarray, ts: str): """Write raw audio to temporary wav""" @@ -24,14 +90,21 @@ def _save_chunk(self, audio_arr: np.ndarray, ts: str): sf.write(fname, audio_arr, Config.SAMPLE_RATE) return fname - def _process_chunk(self, wav_path: str, ts: str): + def _process_chunk(self, wav_path: str, ts: str, elapsed_seconds: float): # run auralis preprocessing (standardize) out_files = preprocess_audio(wav_path, self.chunk_dir) if not out_files: return None processed = out_files[-1] score = score_audio(processed) - self.session.add_record(ts, processed, score) + + features = {} + if Config.EXTRACT_ACOUSTIC_FEATURES: + features = extract_acoustic_features(processed) + + self.session.add_record( + ts, processed, score, elapsed_seconds, features=features + ) return score def start(self, duration_sec=None): @@ -50,12 +123,20 @@ def start(self, duration_sec=None): while remaining > 0: block, _ = stream.read(step_samples) buffer = np.concatenate([buffer, block.flatten()]) + self._samples_processed += step_samples 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) + score = self._process_chunk(raw_file, ts, elapsed_seconds) # slide buffer buffer = buffer[int(step_samples):] diff --git a/voiceMonitor/config.py b/voiceMonitor/config.py index fbc515a..d347646 100644 --- a/voiceMonitor/config.py +++ b/voiceMonitor/config.py @@ -5,8 +5,19 @@ class Config: STEP_SEC = 4 # fatigue warnings - DEFAULT_THRESHOLD = 70 # out of 0‑100 scale + DEFAULT_THRESHOLD = 70 # out of 0 to 100 scale - # session metadata + # session metadata SAVE_CHUNKS = True - CHUNK_DIR = "chunks" \ No newline at end of file + CHUNK_DIR = "chunks" + + # signal smoothing (exponential moving average) + EMA_ALPHA = 0.3 # weight given to the newest window, 0 to 1 + + # vocal load impulse response model + TAU_FAST = 90 # seconds, acute component decay constant + TAU_SLOW = 1500 # seconds, chronic component decay constant (25 min) + SAFE_RECOVERY_LEVEL = 40 # acute level considered recovered + + # acoustic feature extraction + EXTRACT_ACOUSTIC_FEATURES = True \ No newline at end of file diff --git a/voiceMonitor/session.py b/voiceMonitor/session.py index 7c21aa9..891710d 100644 --- a/voiceMonitor/session.py +++ b/voiceMonitor/session.py @@ -1,18 +1,35 @@ import json from .analytics import SessionAnalytics + class SessionReport: def __init__(self): self.analytics = SessionAnalytics() self.records = [] - def add_record(self, timestamp, chunk_file, score): + def add_record( + self, + timestamp, + chunk_file, + score, + elapsed_seconds=None, + features=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. + """ self.records.append({ "timestamp": timestamp, "chunk": chunk_file, "score": score, + "features": features or {}, }) - self.analytics.add(score, timestamp) + self.analytics.add(score, timestamp, elapsed_seconds) def export_json(self, path): data = {