From 10699d6c1eb44e7065d79a030649c57baed13c0e Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Thu, 23 Jul 2026 11:45:46 +0500 Subject: [PATCH 01/13] add config values for EMA smoothing and impulse response model --- voiceMonitor/config.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 From df9feab92d83451c29b00349aa7ba615a0ed1f81 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Thu, 23 Jul 2026 11:46:11 +0500 Subject: [PATCH 02/13] add optional features field to session records --- voiceMonitor/session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/voiceMonitor/session.py b/voiceMonitor/session.py index 7c21aa9..ae85ce8 100644 --- a/voiceMonitor/session.py +++ b/voiceMonitor/session.py @@ -6,11 +6,12 @@ def __init__(self): self.analytics = SessionAnalytics() self.records = [] - def add_record(self, timestamp, chunk_file, score): + def add_record(self, timestamp, chunk_file, score, features=None): self.records.append({ "timestamp": timestamp, "chunk": chunk_file, "score": score, + "features": features or {}, }) self.analytics.add(score, timestamp) From 1a3a577a312ca959a18234e3212e233a3b3877fc Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Thu, 23 Jul 2026 11:46:34 +0500 Subject: [PATCH 03/13] add acoustic feature extraction and wire it into the processing pipeline --- voiceMonitor/audio_stream.py | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/voiceMonitor/audio_stream.py b/voiceMonitor/audio_stream.py index 4a56386..3c00f73 100644 --- a/voiceMonitor/audio_stream.py +++ b/voiceMonitor/audio_stream.py @@ -9,6 +9,57 @@ from .utils import timestamp, ensure_dir from .session import SessionReport +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, used alongside the primary auralis_vfs score rather than in + place of it. Returns an empty dict if parselmouth is unavailable or if + extraction fails on a short or silent window, so a single bad window + does not interrupt the monitoring session. + """ + if not _PARSELMOUTH_AVAILABLE: + 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: + return {} + + class VoiceMonitor: def __init__(self, threshold=None, chunk_dir=None): @@ -31,7 +82,12 @@ def _process_chunk(self, wav_path: str, ts: str): 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, features=features) return score def start(self, duration_sec=None): From f3a5c9dbdc57d50561457d001f78c9edccf4ef22 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Thu, 23 Jul 2026 11:47:38 +0500 Subject: [PATCH 04/13] add praat-parselmouth dependency for acoustic feature extraction --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From c0bf196e420c6f1c3ca5f4c9b926e79c33717253 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Thu, 23 Jul 2026 22:29:11 +0500 Subject: [PATCH 05/13] add EMA smoothing and vocal load impulse response model to analytics --- voiceMonitor/analytics.py | 109 +++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/voiceMonitor/analytics.py b/voiceMonitor/analytics.py index 7f6c4a8..359a48b 100644 --- a/voiceMonitor/analytics.py +++ b/voiceMonitor/analytics.py @@ -1,12 +1,113 @@ +import math +import time + +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). Vocal + readiness is the chronic component minus the acute component, mirroring + 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. + """ + + 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_ts = None + + def update(self, score, ts_seconds): + """ + score: raw fatigue score for this window + ts_seconds: elapsed session time in seconds, used to compute decay + between windows rather than a per window fixed step, since windows + are not guaranteed to arrive at perfectly even intervals + """ + if self._last_ts is not None: + dt = max(0.0, ts_seconds - self._last_ts) + self.acute *= math.exp(-dt / self.tau_fast) + self.chronic *= math.exp(-dt / self.tau_slow) + + self.acute += score + self.chronic += score + self._last_ts = ts_seconds + + return { + "acute": self.acute, + "chronic": self.chronic, + "readiness": 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._start_time = time.monotonic() + self._ema = EMAFatigue() + self._impulse = ImpulseResponseFatigue() def add(self, score, ts): self.scores.append(score) self.timestamps.append(ts) + elapsed = time.monotonic() - self._start_time + smoothed = self._ema.update(score) + self.smoothed_scores.append(smoothed) + + impulse_state = self._impulse.update(score, elapsed) + 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 +118,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": latest.get("readiness", 0), + "recovery_eta_sec": latest.get("recovery_eta_sec", 0), } \ No newline at end of file From 81003e72777478d411cfb18da96ceb4ce5d8d29e Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:00:38 +0500 Subject: [PATCH 06/13] clarify readiness as experimental and remove wall clock dependency --- voiceMonitor/analytics.py | 57 ++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/voiceMonitor/analytics.py b/voiceMonitor/analytics.py index 359a48b..7d72331 100644 --- a/voiceMonitor/analytics.py +++ b/voiceMonitor/analytics.py @@ -1,5 +1,4 @@ import math -import time from .config import Config @@ -33,12 +32,25 @@ class ImpulseResponseFatigue: 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). Vocal - readiness is the chronic component minus the acute component, mirroring + "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. + 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): @@ -46,28 +58,31 @@ def __init__(self, tau_fast=None, tau_slow=None): 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_ts = None + self._last_elapsed = None - def update(self, score, ts_seconds): + def update(self, score, elapsed_seconds): """ score: raw fatigue score for this window - ts_seconds: elapsed session time in seconds, used to compute decay - between windows rather than a per window fixed step, since windows - are not guaranteed to arrive at perfectly even intervals + 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_ts is not None: - dt = max(0.0, ts_seconds - self._last_ts) + 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_ts = ts_seconds + self._last_elapsed = elapsed_seconds return { "acute": self.acute, "chronic": self.chronic, - "readiness": self.chronic - self.acute, + "readiness_experimental": self.chronic - self.acute, } def recovery_eta_seconds(self, safe_level=None): @@ -90,19 +105,25 @@ def __init__(self): self.smoothed_scores = [] self.impulse_records = [] - self._start_time = time.monotonic() self._ema = EMAFatigue() self._impulse = ImpulseResponseFatigue() - def add(self, score, ts): + def add(self, score, ts, elapsed_seconds): + """ + 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. + """ self.scores.append(score) self.timestamps.append(ts) - elapsed = time.monotonic() - self._start_time smoothed = self._ema.update(score) self.smoothed_scores.append(smoothed) - impulse_state = self._impulse.update(score, elapsed) + impulse_state = self._impulse.update(score, elapsed_seconds) impulse_state["recovery_eta_sec"] = self._impulse.recovery_eta_seconds() self.impulse_records.append(impulse_state) @@ -126,6 +147,6 @@ def summary(self): "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": latest.get("readiness", 0), + "readiness_experimental": latest.get("readiness_experimental", 0), "recovery_eta_sec": latest.get("recovery_eta_sec", 0), } \ No newline at end of file From b92378dd1eaa2518ba692ad0e15039f2bcfefe46 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:00:50 +0500 Subject: [PATCH 07/13] document acoustic features as auxiliary and thread elapsed_seconds through --- voiceMonitor/session.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/voiceMonitor/session.py b/voiceMonitor/session.py index ae85ce8..b48718e 100644 --- a/voiceMonitor/session.py +++ b/voiceMonitor/session.py @@ -6,14 +6,22 @@ def __init__(self): self.analytics = SessionAnalytics() self.records = [] - def add_record(self, timestamp, chunk_file, score, features=None): + def add_record(self, timestamp, chunk_file, score, elapsed_seconds, 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 = { From bf7f4bac45938b6290e4ca35ecaa0987cbd613a6 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:01:02 +0500 Subject: [PATCH 08/13] derive elapsed time from audio samples processed and log extraction failures --- voiceMonitor/audio_stream.py | 41 +++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/voiceMonitor/audio_stream.py b/voiceMonitor/audio_stream.py index 3c00f73..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,8 @@ from .utils import timestamp, ensure_dir from .session import SessionReport +logger = logging.getLogger(__name__) + try: import parselmouth from parselmouth.praat import call @@ -22,12 +26,21 @@ 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, used alongside the primary auralis_vfs score rather than in - place of it. Returns an empty dict if parselmouth is unavailable or if - extraction fails on a short or silent window, so a single bad window - does not interrupt the monitoring session. + 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: @@ -56,7 +69,8 @@ def extract_acoustic_features(wav_path): "hnr": hnr, "cpps": cpps, } - except Exception: + except Exception as exc: + logger.warning("Acoustic feature extraction failed for %s: %s", wav_path, exc) return {} @@ -67,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""" @@ -75,7 +90,7 @@ 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: @@ -87,7 +102,9 @@ def _process_chunk(self, wav_path: str, ts: str): if Config.EXTRACT_ACOUSTIC_FEATURES: features = extract_acoustic_features(processed) - self.session.add_record(ts, processed, score, features=features) + self.session.add_record( + ts, processed, score, elapsed_seconds, features=features + ) return score def start(self, duration_sec=None): @@ -106,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):] From eb5028cd5bc6f31372e277d48d36bf731eafacba Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:01:13 +0500 Subject: [PATCH 09/13] add unit tests for EMA smoothing, impulse response, and session summary --- tests/test_analytics_impulse.py | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_analytics_impulse.py 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 From b38ac63d95677715ce396d322dfe6ee85ab72fcc Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:01:25 +0500 Subject: [PATCH 10/13] add tests for acoustic feature extraction failure path --- tests/test_acoustic_features.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_acoustic_features.py 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 From 45cff03ba6e0964985ac7182e311bd6fb9448a07 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:01:52 +0500 Subject: [PATCH 11/13] add CI workflow to run tests with coverage on push and pull request --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d1e1d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +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 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 From 719c94ccbce31573780e3b65fb3dd79d17807f8c Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Fri, 24 Jul 2026 17:19:28 +0500 Subject: [PATCH 12/13] fix backward compatibility with existing tests and ensure parselmouth installs in CI --- .github/workflows/ci.yml | 1 + voiceMonitor/analytics.py | 13 ++++++++++++- voiceMonitor/session.py | 10 +++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1e1d7..57c46a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . + pip install praat-parselmouth pip install pytest pytest-cov - name: Run tests with coverage diff --git a/voiceMonitor/analytics.py b/voiceMonitor/analytics.py index 7d72331..750a4ea 100644 --- a/voiceMonitor/analytics.py +++ b/voiceMonitor/analytics.py @@ -108,7 +108,7 @@ def __init__(self): self._ema = EMAFatigue() self._impulse = ImpulseResponseFatigue() - def add(self, score, ts, elapsed_seconds): + 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. @@ -116,7 +116,18 @@ def add(self, score, ts, elapsed_seconds): 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 + self.scores.append(score) self.timestamps.append(ts) diff --git a/voiceMonitor/session.py b/voiceMonitor/session.py index b48718e..891710d 100644 --- a/voiceMonitor/session.py +++ b/voiceMonitor/session.py @@ -1,12 +1,20 @@ import json from .analytics import SessionAnalytics + class SessionReport: def __init__(self): self.analytics = SessionAnalytics() self.records = [] - def add_record(self, timestamp, chunk_file, score, elapsed_seconds, features=None): + 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 From beb00f661ca8af08f47573f4425020ac44d91c00 Mon Sep 17 00:00:00 2001 From: SaribAzim Date: Sat, 25 Jul 2026 12:59:35 +0500 Subject: [PATCH 13/13] add tests for utils, session, audio_stream, and cli to raise coverage --- tests/test_audio_stream.py | 89 ++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 35 ++++++++++++++ tests/test_session_report.py | 62 +++++++++++++++++++++++++ tests/test_utils.py | 35 ++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 tests/test_audio_stream.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_session_report.py create mode 100644 tests/test_utils.py 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