audio-validation 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {audio_validation-0.3.0 → audio_validation-0.3.2}/PKG-INFO +1 -1
  2. audio_validation-0.3.2/RELEASE_NOTES.md +19 -0
  3. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/_version.py +3 -3
  4. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/criteria.py +137 -61
  5. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/models.py +62 -6
  6. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/validator.py +46 -24
  7. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/PKG-INFO +1 -1
  8. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/scm_file_list.json +23 -23
  9. audio_validation-0.3.2/src/audio_validation.egg-info/scm_version.json +8 -0
  10. audio_validation-0.3.0/RELEASE_NOTES.md +0 -11
  11. audio_validation-0.3.0/src/audio_validation.egg-info/scm_version.json +0 -8
  12. {audio_validation-0.3.0 → audio_validation-0.3.2}/.github/workflows/code_checker.yml +0 -0
  13. {audio_validation-0.3.0 → audio_validation-0.3.2}/.github/workflows/publish_to_pypi.yml +0 -0
  14. {audio_validation-0.3.0 → audio_validation-0.3.2}/.github/workflows/unit_tests.yml +0 -0
  15. {audio_validation-0.3.0 → audio_validation-0.3.2}/.gitignore +0 -0
  16. {audio_validation-0.3.0 → audio_validation-0.3.2}/.pylint_ignore +0 -0
  17. {audio_validation-0.3.0 → audio_validation-0.3.2}/LICENSE +0 -0
  18. {audio_validation-0.3.0 → audio_validation-0.3.2}/README.md +0 -0
  19. {audio_validation-0.3.0 → audio_validation-0.3.2}/_ci/is_dev_version.sh +0 -0
  20. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/Makefile +0 -0
  21. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/conf.py +0 -0
  22. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/contribute.rst +0 -0
  23. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/index.rst +0 -0
  24. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/installation.rst +0 -0
  25. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/release_notes.rst +0 -0
  26. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/requirements.txt +0 -0
  27. {audio_validation-0.3.0 → audio_validation-0.3.2}/docs/tutorials/index.rst +0 -0
  28. {audio_validation-0.3.0 → audio_validation-0.3.2}/pyproject.toml +0 -0
  29. {audio_validation-0.3.0 → audio_validation-0.3.2}/requirements-dev.txt +0 -0
  30. {audio_validation-0.3.0 → audio_validation-0.3.2}/requirements-test.txt +0 -0
  31. {audio_validation-0.3.0 → audio_validation-0.3.2}/requirements.txt +0 -0
  32. {audio_validation-0.3.0 → audio_validation-0.3.2}/setup.cfg +0 -0
  33. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/__init__.py +0 -0
  34. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/audio_features.py +0 -0
  35. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/audio_verification.py +0 -0
  36. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/plots.py +0 -0
  37. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/recorder.py +0 -0
  38. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/continous_validation/retention.py +0 -0
  39. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation/utils/audio_generation.py +0 -0
  40. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/SOURCES.txt +0 -0
  41. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/dependency_links.txt +0 -0
  42. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/requires.txt +0 -0
  43. {audio_validation-0.3.0 → audio_validation-0.3.2}/src/audio_validation.egg-info/top_level.txt +0 -0
  44. {audio_validation-0.3.0 → audio_validation-0.3.2}/tests/__init__.py +0 -0
  45. {audio_validation-0.3.0 → audio_validation-0.3.2}/tests/test_template.py +0 -0
  46. {audio_validation-0.3.0 → audio_validation-0.3.2}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audio_validation
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Tools for validating audio data, including feature extraction and visualization.
5
5
  Maintainer-email: Hubert Stepniewski <hubert.stepniewski@int2code.com>, Marcin Tomiczek <marcin.tomiczek@int2code.com>, Piotr Sznapka <piotr.sznapka@int2code.com>
6
6
  License: MIT License
@@ -0,0 +1,19 @@
1
+ ### 0.3.2
2
+
3
+ [PATCH] Add warmup chunks, improve failure reporting.
4
+
5
+ ### 0.3.1
6
+
7
+ [PATCH] Fix printing Continous Audio Validator Failure Info.
8
+
9
+ ### 0.3.0
10
+
11
+ [MINOR] Add Continous Audio Validator.
12
+
13
+ ### 0.2.0
14
+
15
+ [MINOR] Add THD, THD+N calculation.
16
+
17
+ ### 0.1.2
18
+
19
+ [MINOR] Initial release.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.3.2'
22
+ __version_tuple__ = version_tuple = (0, 3, 2)
23
23
 
24
- __commit_id__ = commit_id = 'gc754f7812'
24
+ __commit_id__ = commit_id = 'ge32c5a61b'
@@ -6,10 +6,14 @@ swapped atomically by reference while the analysis thread reads it, without
6
6
  copying. :func:`evaluate_chunk` runs every configured check (skipping any left
7
7
  at ``None``) and reports *all* failures, each check living in its own function
8
8
  that logs a per-channel table.
9
+
10
+ Each check returns a :class:`CheckResult` that keeps its one-line *summary*
11
+ separate from its per-channel *failures* table, so callers can render a compact
12
+ overview (one row per chunk) without embedding a whole table in a single cell.
9
13
  """
10
14
 
11
15
  import logging
12
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field
13
17
  from typing import Any, Callable, List, Optional, Tuple
14
18
 
15
19
  import numpy as np
@@ -60,6 +64,74 @@ class AudioCriteria:
60
64
  custom_check: Optional[Callable[[AudioFeatures], Tuple[bool, str]]] = None
61
65
 
62
66
 
67
+ @dataclass(frozen=True)
68
+ class CheckResult:
69
+ """Outcome of a single check.
70
+
71
+ Keeping *summary* (one line) apart from *failures* (a table) lets the caller
72
+ choose the rendering: a compact column in a metrics table, or a full
73
+ multiline block in a failure report.
74
+
75
+ :ivar name: Short check identifier, e.g. ``"thd_n"``.
76
+ :ivar ok: Whether the check passed.
77
+ :ivar summary: Single-line reason; empty when the check passed.
78
+ :ivar failures: Failing per-channel rows, or ``None`` when not applicable.
79
+ """
80
+
81
+ name: str
82
+ ok: bool
83
+ summary: str = ""
84
+ failures: Optional[pd.DataFrame] = None
85
+
86
+ @classmethod
87
+ def from_tuple(cls, name: str, result: Tuple[bool, str]) -> "CheckResult":
88
+ """Adapt a legacy ``(ok, reason)`` pair (e.g. from ``custom_check``).
89
+
90
+ A multiline *reason* keeps its first line as the summary; the rest is
91
+ preserved as a single-column details table.
92
+ """
93
+ ok, reason = result
94
+ head, _, rest = reason.partition("\n")
95
+ failures = pd.DataFrame({"detail": rest.splitlines()}) if rest.strip() else None
96
+ return cls(name=name, ok=ok, summary=head, failures=failures)
97
+
98
+ @property
99
+ def detail(self) -> str:
100
+ """Return the multiline ``summary`` + failing-rows table."""
101
+ if self.ok:
102
+ return ""
103
+ if self.failures is None or self.failures.empty:
104
+ return self.summary
105
+ return f"{self.summary}\n{self.failures.to_string()}"
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ChunkVerdict:
110
+ """Aggregated outcome of every check run against one chunk."""
111
+
112
+ results: List[CheckResult] = field(default_factory=list)
113
+
114
+ @property
115
+ def failures(self) -> List[CheckResult]:
116
+ """Return the failing checks, in evaluation order."""
117
+ return [result for result in self.results if not result.ok]
118
+
119
+ @property
120
+ def ok(self) -> bool:
121
+ """Whether every check passed."""
122
+ return not self.failures
123
+
124
+ @property
125
+ def summary(self) -> str:
126
+ """Single-line reason joining every failing check's summary."""
127
+ return "; ".join(result.summary for result in self.failures)
128
+
129
+ @property
130
+ def detail(self) -> str:
131
+ """Multiline reason with each failing check's per-channel table."""
132
+ return "\n".join(result.detail for result in self.failures)
133
+
134
+
63
135
  def _channel_label(ch: int) -> str:
64
136
  """Return a stable, human-readable label for channel index *ch*."""
65
137
  return f"ch{ch}"
@@ -87,13 +159,13 @@ def _format_list(freqs: Any, max_display: int = 5) -> str:
87
159
  )
88
160
 
89
161
 
90
- def check_silence(features: AudioFeatures, threshold: float) -> Tuple[bool, str]:
162
+ def check_silence(features: AudioFeatures, threshold: float) -> CheckResult:
91
163
  """Check that every channel is silent (RMS at/below *threshold*).
92
164
 
93
165
  :param features: Per-channel features to check.
94
166
  :param threshold: RMS at/below which a channel counts as silent.
95
- :return: ``(True, "")`` if all channels are silent, else ``(False, reason)``
96
- naming the non-silent channels.
167
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
168
+ non-silent channels.
97
169
  """
98
170
  rows = [
99
171
  {
@@ -104,18 +176,20 @@ def check_silence(features: AudioFeatures, threshold: float) -> Tuple[bool, str]
104
176
  for ch, feat in enumerate(features.channel_features)
105
177
  ]
106
178
  df = pd.DataFrame(rows).set_index("ch")
107
- logger.debug("Silence check:\n%s", df.to_string())
108
179
 
109
180
  non_silent_df = df[~df["silent"]]
110
181
  if non_silent_df.empty:
111
- return True, ""
112
- return False, (
182
+ return CheckResult("silence", True)
183
+ return CheckResult(
184
+ "silence",
185
+ False,
113
186
  f"Expected silence (RMS <= {threshold}) but audio detected on "
114
- f"{len(non_silent_df)} channel(s).\n{non_silent_df.to_string()}"
187
+ f"{len(non_silent_df)} channel(s)",
188
+ non_silent_df,
115
189
  )
116
190
 
117
191
 
118
- def check_audio_present(features: AudioFeatures, threshold: float) -> Tuple[bool, str]:
192
+ def check_audio_present(features: AudioFeatures, threshold: float) -> CheckResult:
119
193
  """Check that every channel carries audio (RMS strictly above *threshold*).
120
194
 
121
195
  Complement of :func:`check_silence`; mirrors the legacy
@@ -123,8 +197,8 @@ def check_audio_present(features: AudioFeatures, threshold: float) -> Tuple[bool
123
197
 
124
198
  :param features: Per-channel features to check.
125
199
  :param threshold: RMS strictly above which a channel counts as present.
126
- :return: ``(True, "")`` if all channels are present, else ``(False, reason)``
127
- naming the silent channels.
200
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
201
+ silent channels.
128
202
  """
129
203
  rows = [
130
204
  {
@@ -135,27 +209,28 @@ def check_audio_present(features: AudioFeatures, threshold: float) -> Tuple[bool
135
209
  for ch, feat in enumerate(features.channel_features)
136
210
  ]
137
211
  df = pd.DataFrame(rows).set_index("ch")
138
- logger.debug("Audio presence check:\n%s", df.to_string())
139
212
 
140
213
  silent_df = df[~df["audio_present"]]
141
214
  if silent_df.empty:
142
- return True, ""
143
- return False, (
144
- f"No audio detected (RMS <= {threshold}) on {len(silent_df)} channel(s).\n"
145
- f"{silent_df.to_string()}"
215
+ return CheckResult("audio_present", True)
216
+ return CheckResult(
217
+ "audio_present",
218
+ False,
219
+ f"No audio detected (RMS <= {threshold}) on {len(silent_df)} channel(s)",
220
+ silent_df,
146
221
  )
147
222
 
148
223
 
149
224
  def check_rms(
150
225
  features: AudioFeatures, expected_rms: List[float], tolerance: float
151
- ) -> Tuple[bool, str]:
226
+ ) -> CheckResult:
152
227
  """Check that each channel's RMS is within *tolerance* of its expected value.
153
228
 
154
229
  :param features: Per-channel features to check.
155
230
  :param expected_rms: Per-channel expected RMS values.
156
231
  :param tolerance: Allowed absolute deviation from *expected_rms*.
157
- :return: ``(True, "")`` if all channels are in tolerance, else
158
- ``(False, reason)`` naming the out-of-tolerance channels.
232
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
233
+ out-of-tolerance channels.
159
234
  """
160
235
  rows = [
161
236
  {
@@ -168,23 +243,25 @@ def check_rms(
168
243
  for ch, feat in enumerate(features.channel_features)
169
244
  ]
170
245
  df = pd.DataFrame(rows).set_index("ch")
171
- logger.debug("RMS check:\n%s", df.to_string())
172
246
 
173
247
  failed_df = df[~df["rms_ok"]]
174
248
  if failed_df.empty:
175
- return True, ""
176
- return False, (
177
- f"RMS out of tolerance on {len(failed_df)} channel(s).\n{failed_df.to_string()}"
249
+ return CheckResult("rms", True)
250
+ return CheckResult(
251
+ "rms",
252
+ False,
253
+ f"RMS out of tolerance on {len(failed_df)} channel(s)",
254
+ failed_df,
178
255
  )
179
256
 
180
257
 
181
- def check_frequency(features: AudioFeatures) -> Tuple[bool, str]:
258
+ def check_frequency(features: AudioFeatures) -> CheckResult:
182
259
  """Check that the expected frequency was detected on every channel.
183
260
 
184
261
  :param features: Per-channel features to check (``detected`` set by the
185
262
  FFT stage of :meth:`AudioFeatures.compute`).
186
- :return: ``(True, "")`` if detected on all channels, else ``(False, reason)``
187
- naming the channels that missed the expected frequency.
263
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
264
+ channels that missed the expected frequency.
188
265
  """
189
266
  rows = [
190
267
  {
@@ -202,26 +279,27 @@ def check_frequency(features: AudioFeatures) -> Tuple[bool, str]:
202
279
  for ch, feat in enumerate(features.channel_features)
203
280
  ]
204
281
  df = pd.DataFrame(rows).set_index("ch")
205
- logger.debug("Frequency presence check:\n%s", df.to_string())
206
282
 
207
283
  failed_df = df[~df["detected"]]
208
284
  if failed_df.empty:
209
- return True, ""
210
- return False, (
211
- f"Expected frequency not detected on {len(failed_df)} channel(s).\n"
212
- f"{failed_df.to_string()}"
285
+ return CheckResult("frequency", True)
286
+ return CheckResult(
287
+ "frequency",
288
+ False,
289
+ f"Expected frequency not detected on {len(failed_df)} channel(s)",
290
+ failed_df,
213
291
  )
214
292
 
215
293
 
216
- def check_thd(features: AudioFeatures, max_thd: float) -> Tuple[bool, str]:
294
+ def check_thd(features: AudioFeatures, max_thd: float) -> CheckResult:
217
295
  """Check that each channel's THD is at/below *max_thd* percent.
218
296
 
219
297
  Channels with THD not computed (``None``) are treated as passing.
220
298
 
221
299
  :param features: Per-channel features to check.
222
300
  :param max_thd: Maximum allowed THD as a percentage.
223
- :return: ``(True, "")`` if all channels are within tolerance, else
224
- ``(False, reason)`` naming the channels above tolerance.
301
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
302
+ channels above tolerance.
225
303
  """
226
304
  rows = [
227
305
  {
@@ -233,18 +311,19 @@ def check_thd(features: AudioFeatures, max_thd: float) -> Tuple[bool, str]:
233
311
  for ch, feat in enumerate(features.channel_features)
234
312
  ]
235
313
  df = pd.DataFrame(rows).set_index("ch")
236
- logger.debug("THD check:\n%s", df.to_string())
237
314
 
238
315
  failed_df = df[~df["thd_ok"]]
239
316
  if failed_df.empty:
240
- return True, ""
241
- return False, (
242
- f"THD above tolerance ({max_thd} %) on {len(failed_df)} channel(s).\n"
243
- f"{failed_df.to_string()}"
317
+ return CheckResult("thd", True)
318
+ return CheckResult(
319
+ "thd",
320
+ False,
321
+ f"THD above tolerance ({max_thd} %) on {len(failed_df)} channel(s)",
322
+ failed_df,
244
323
  )
245
324
 
246
325
 
247
- def check_thd_n(features: AudioFeatures, max_thd_n: float) -> Tuple[bool, str]:
326
+ def check_thd_n(features: AudioFeatures, max_thd_n: float) -> CheckResult:
248
327
  """Check that each channel's THD+N is at/below *max_thd_n* percent.
249
328
 
250
329
  THD+N accounts for broadband noise as well as harmonics, so it catches a
@@ -253,8 +332,8 @@ def check_thd_n(features: AudioFeatures, max_thd_n: float) -> Tuple[bool, str]:
253
332
 
254
333
  :param features: Per-channel features to check.
255
334
  :param max_thd_n: Maximum allowed THD+N as a percentage.
256
- :return: ``(True, "")`` if all channels are within tolerance, else
257
- ``(False, reason)`` naming the channels above tolerance.
335
+ :return: A passing :class:`CheckResult`, or a failing one carrying the
336
+ channels above tolerance.
258
337
  """
259
338
  rows = [
260
339
  {
@@ -266,33 +345,31 @@ def check_thd_n(features: AudioFeatures, max_thd_n: float) -> Tuple[bool, str]:
266
345
  for ch, feat in enumerate(features.channel_features)
267
346
  ]
268
347
  df = pd.DataFrame(rows).set_index("ch")
269
- logger.debug("THD+N check:\n%s", df.to_string())
270
348
 
271
349
  failed_df = df[~df["thd_n_ok"]]
272
350
  if failed_df.empty:
273
- return True, ""
274
- return False, (
275
- f"THD+N above tolerance ({max_thd_n} %) on {len(failed_df)} channel(s).\n"
276
- f"{failed_df.to_string()}"
351
+ return CheckResult("thd_n", True)
352
+ return CheckResult(
353
+ "thd_n",
354
+ False,
355
+ f"THD+N above tolerance ({max_thd_n} %) on {len(failed_df)} channel(s)",
356
+ failed_df,
277
357
  )
278
358
 
279
359
 
280
- def evaluate_chunk(
281
- features: AudioFeatures, criteria: AudioCriteria
282
- ) -> Tuple[bool, str]:
360
+ def evaluate_chunk(features: AudioFeatures, criteria: AudioCriteria) -> ChunkVerdict:
283
361
  """Evaluate one chunk's features against every configured check.
284
362
 
285
363
  Unlike a short-circuiting check, this runs *all* checks enabled by
286
- *criteria* and aggregates the reasons of every failing one, so a single
287
- chunk reports all its problems at once.
364
+ *criteria* and collects every result, so a single chunk reports all its
365
+ problems at once.
288
366
 
289
367
  :param features: Per-channel features from :meth:`AudioFeatures.compute`.
290
368
  :param criteria: Active criteria snapshot.
291
- :return: ``(True, "")`` if every configured check passes, otherwise
292
- ``(False, reason)`` where *reason* joins the reason of each failing
293
- check (one per line).
369
+ :return: A :class:`ChunkVerdict` holding each executed check's result;
370
+ ``verdict.ok`` is ``True`` when every configured check passed.
294
371
  """
295
- results: List[Tuple[bool, str]] = []
372
+ results: List[CheckResult] = []
296
373
 
297
374
  if criteria.require_audio_present:
298
375
  results.append(check_audio_present(features, criteria.silence_rms_threshold))
@@ -309,9 +386,8 @@ def evaluate_chunk(
309
386
  if criteria.max_thd_n is not None:
310
387
  results.append(check_thd_n(features, criteria.max_thd_n))
311
388
  if criteria.custom_check is not None:
312
- results.append(criteria.custom_check(features))
389
+ results.append(
390
+ CheckResult.from_tuple("custom", criteria.custom_check(features))
391
+ )
313
392
 
314
- failures = [reason for ok, reason in results if not ok]
315
- if failures:
316
- return False, "\n".join(failures)
317
- return True, ""
393
+ return ChunkVerdict(results)
@@ -5,12 +5,29 @@ short-lived (only referenced by the bounded retention buffer).
5
5
  """
6
6
 
7
7
  from dataclasses import dataclass, field
8
+ from textwrap import indent
8
9
  from typing import List, Optional
9
10
 
10
11
  import numpy as np
11
12
  import pandas as pd
12
13
 
13
14
 
15
+ def format_timestamp(seconds: float) -> str:
16
+ """Render *seconds* since capture start as ``HH:MM:SS.mmm``.
17
+
18
+ Chunk boundaries are far easier to line up with a long run's wall clock as
19
+ ``00:15:04.023`` than as ``904.023``. Hours are not wrapped at 24.
20
+
21
+ :param seconds: Offset from capture start in seconds.
22
+ :return: Zero-padded ``HH:MM:SS.mmm`` string.
23
+ """
24
+ millis = round(seconds * 1000)
25
+ hours, millis = divmod(millis, 3_600_000)
26
+ minutes, millis = divmod(millis, 60_000)
27
+ secs, millis = divmod(millis, 1000)
28
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
29
+
30
+
14
31
  @dataclass
15
32
  class RawChunk:
16
33
  """One contiguous captured block, tagged with its position in the run.
@@ -41,13 +58,19 @@ class ChannelMetric:
41
58
 
42
59
  @dataclass
43
60
  class ChunkMetrics:
44
- """Per-chunk analysis result: timing, verdict and per-channel metrics."""
61
+ """Per-chunk analysis result: timing, verdict and per-channel metrics.
62
+
63
+ :ivar reason: Single-line failure summary, suited to a table column.
64
+ :ivar detail: Multiline failure report with the per-check channel tables;
65
+ never put this in a DataFrame cell, log it separately.
66
+ """
45
67
 
46
68
  index: int
47
69
  start_s: float
48
70
  end_s: float
49
71
  ok: bool
50
72
  reason: str
73
+ detail: str = ""
51
74
  channels: List[ChannelMetric] = field(default_factory=list)
52
75
 
53
76
 
@@ -60,6 +83,17 @@ class FailureInfo:
60
83
  reason: str
61
84
  wav_offset_s: float
62
85
 
86
+ def __str__(self) -> str:
87
+ """Return a human-readable, multiline failure summary."""
88
+ return (
89
+ "FailureInfo:\n"
90
+ f" chunk_index: {self.chunk_index}\n"
91
+ f" time_s: {self.time_s}\n"
92
+ f" wav_offset_s: {self.wav_offset_s}\n"
93
+ " reason:\n"
94
+ f"{indent(self.reason, ' ')}"
95
+ )
96
+
63
97
 
64
98
  @dataclass
65
99
  class ValidationResult: # pylint: disable=too-many-instance-attributes
@@ -83,22 +117,44 @@ class ValidationResult: # pylint: disable=too-many-instance-attributes
83
117
  plot_path: Optional[str] = None
84
118
 
85
119
  def metrics_dataframe(self) -> pd.DataFrame:
86
- """Flatten the metrics timeline to one row per (chunk, channel)."""
120
+ """Flatten the metrics timeline to one row per (chunk, channel).
121
+
122
+ The ``reason`` column holds only the single-line failure summary; the
123
+ per-check channel tables live in :meth:`failure_details`, because a
124
+ multiline table crammed into one cell wrecks the table layout.
125
+
126
+ ``start``/``end`` are rendered as ``HH:MM:SS.mmm`` offsets from capture
127
+ start rather than raw seconds.
128
+ """
87
129
  rows = []
88
130
  for metric in self.metrics:
89
131
  for ch_idx, ch_mertric in enumerate(metric.channels):
90
132
  rows.append(
91
133
  {
92
134
  "index": metric.index,
93
- "start_s": metric.start_s,
94
- "end_s": metric.end_s,
95
- "ok": metric.ok,
96
- "reason": metric.reason,
135
+ "start": format_timestamp(metric.start_s),
136
+ "end": format_timestamp(metric.end_s),
97
137
  "ch": ch_idx,
98
138
  "rms": ch_mertric.rms,
99
139
  "thd": ch_mertric.thd,
100
140
  "thd_n": ch_mertric.thd_n,
101
141
  "detected": ch_mertric.detected,
142
+ "ok": metric.ok,
143
+ "reason": metric.reason,
102
144
  }
103
145
  )
104
146
  return pd.DataFrame(rows)
147
+
148
+ def failure_details(self) -> str:
149
+ """Render the multiline failure report of every failing chunk.
150
+
151
+ One block per failing chunk, each with the per-check channel tables
152
+ produced by the criteria checks. Empty when no chunk failed.
153
+ """
154
+ blocks = [
155
+ f"chunk {metric.index} (t={metric.start_s:.1f}-{metric.end_s:.1f}s):\n"
156
+ f"{indent(metric.detail or metric.reason, ' ')}"
157
+ for metric in self.metrics
158
+ if not metric.ok
159
+ ]
160
+ return "\n".join(blocks)
@@ -27,6 +27,7 @@ from scipy.io import wavfile
27
27
  from audio_validation.audio_features import AudioFeatures
28
28
  from audio_validation.continous_validation.criteria import (
29
29
  AudioCriteria,
30
+ ChunkVerdict,
30
31
  evaluate_chunk,
31
32
  )
32
33
  from audio_validation.continous_validation.models import (
@@ -56,8 +57,14 @@ class ValidatorConfig:
56
57
  :param max_capture_s: Capture-time limit; ``None`` means unlimited.
57
58
  :param pre_failure_s: Seconds retained before failure; ``None`` keeps all.
58
59
  :param post_failure_s: Seconds retained/captured after a failure.
59
- :param stop_on_failure: Stop the run on the first armed failure.
60
+ :param stop_on_failure: Stop the run on the first armed failure. When
61
+ ``False`` the run continues to completion but the failure is still
62
+ reported in :attr:`ValidationResult.failure`.
60
63
  :param failure_consecutive: Consecutive failing chunks required to report run failure.
64
+ :param warmup_chunks: Leading chunks whose features are computed and recorded
65
+ but not evaluated. Covers playback start-up, where the signal is still
66
+ ramping and THD / RMS are meaningless — especially with a small
67
+ ``chunk_s``, where the ramp fills a whole chunk.
61
68
  :param artifacts_dir: Directory for persisted artifacts (for example WAV).
62
69
  :param wav_filename: Saved WAV filename.
63
70
  :param wav_dtype: Retained/saved sample dtype.
@@ -74,6 +81,7 @@ class ValidatorConfig:
74
81
  post_failure_s: int = 60
75
82
  stop_on_failure: bool = True
76
83
  failure_consecutive: int = 1
84
+ warmup_chunks: int = 0
77
85
  artifacts_dir: str = "test_artifacts"
78
86
  wav_filename: str = "continuous_capture.wav"
79
87
  wav_dtype: str = "float32"
@@ -323,7 +331,12 @@ class ContinuousAudioValidator: # pylint: disable=too-many-instance-attributes
323
331
  self._abort.set()
324
332
 
325
333
  def _validate_chunk_features(self, chunk: RawChunk) -> None:
326
- """Compute features for *chunk*, evaluate criteria, and arm on failure."""
334
+ """Compute features for *chunk*, evaluate criteria, and arm on failure.
335
+
336
+ Warm-up chunks still get their features computed and recorded — they
337
+ belong in the metrics table and the WAV — but no check is run against
338
+ them, so they can neither fail the run nor break a failing streak.
339
+ """
327
340
  with self._state_lock:
328
341
  criteria = self._criteria
329
342
 
@@ -337,20 +350,16 @@ class ContinuousAudioValidator: # pylint: disable=too-many-instance-attributes
337
350
  skip_latency=chunk.index == 0,
338
351
  activity_threshold=criteria.silence_rms_threshold,
339
352
  )
340
- logger.debug(
341
- "Evaluating chunk %d: start=%.1fs end=%.1fs",
342
- chunk.index,
343
- chunk.start_s,
344
- chunk.end_s,
345
- )
346
- ok, reason = evaluate_chunk(features, criteria)
353
+ warmup = chunk.index < self._cfg.warmup_chunks
354
+ verdict = ChunkVerdict() if warmup else evaluate_chunk(features, criteria)
347
355
 
348
356
  metric = ChunkMetrics(
349
357
  index=chunk.index,
350
358
  start_s=chunk.start_s,
351
359
  end_s=chunk.end_s,
352
- ok=ok,
353
- reason=reason,
360
+ ok=verdict.ok,
361
+ reason="warm-up (not evaluated)" if warmup else verdict.summary,
362
+ detail=verdict.detail,
354
363
  channels=[
355
364
  ChannelMetric(
356
365
  rms=feat.rms,
@@ -372,26 +381,27 @@ class ContinuousAudioValidator: # pylint: disable=too-many-instance-attributes
372
381
 
373
382
  self._retention.add(chunk)
374
383
 
375
- if ok:
384
+ if verdict.ok:
376
385
  self._consec_fail = 0
377
386
  return
378
- if not self._cfg.stop_on_failure:
379
- return
380
387
  self._consec_fail += 1
381
388
  if self._consec_fail < self._cfg.failure_consecutive:
382
389
  return
383
- first = False
390
+ arm_stop = False
384
391
  with self._state_lock:
385
- if self._failure_time_s is None:
386
- self._failure_time_s = chunk.end_s
392
+ if self._failure_info is None:
387
393
  self._failure_info = FailureInfo(
388
394
  chunk_index=chunk.index,
389
395
  time_s=chunk.start_s,
390
- reason=reason,
396
+ reason=verdict.detail,
391
397
  wav_offset_s=0.0, # will be updated when wav is written
392
398
  )
393
- first = True
394
- if first:
399
+ # ``_failure_time_s`` arms the run teardown (reader break + retention
400
+ # freeze), so it is only set when the run is meant to stop.
401
+ if self._cfg.stop_on_failure and self._failure_time_s is None:
402
+ self._failure_time_s = chunk.end_s
403
+ arm_stop = True
404
+ if arm_stop:
395
405
  self._retention.freeze(chunk.end_s)
396
406
 
397
407
  # Finalising the run and assembling the result ------------------------------------------------
@@ -463,12 +473,20 @@ class ContinuousAudioValidator: # pylint: disable=too-many-instance-attributes
463
473
  error = self._error
464
474
  metrics = list(self._metrics)
465
475
 
466
- if failure is not None and wav_start is not None:
476
+ # Without ``stop_on_failure`` the retention window keeps rolling, so the
477
+ # failing chunk may have been evicted before the WAV was written.
478
+ failure_in_wav = (
479
+ failure is not None
480
+ and wav_start is not None
481
+ and wav_end is not None
482
+ and wav_start <= failure.time_s <= wav_end
483
+ )
484
+ if failure_in_wav:
467
485
  failure = replace(failure, wav_offset_s=failure.time_s - wav_start)
468
486
 
469
487
  if error is not None:
470
488
  stopped_reason = "capture_error"
471
- elif failure is not None:
489
+ elif failure is not None and self._cfg.stop_on_failure:
472
490
  stopped_reason = "failure"
473
491
  elif self._stop_requested:
474
492
  stopped_reason = "stopped"
@@ -484,8 +502,12 @@ class ContinuousAudioValidator: # pylint: disable=too-many-instance-attributes
484
502
  (
485
503
  f"; failure at t={failure.time_s:.1f}s = "
486
504
  f"{failure.wav_offset_s:.1f}s into WAV."
487
- if failure is not None
488
- else "."
505
+ if failure_in_wav
506
+ else (
507
+ f"; failure at t={failure.time_s:.1f}s is outside the WAV."
508
+ if failure is not None
509
+ else "."
510
+ )
489
511
  ),
490
512
  )
491
513
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audio_validation
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Tools for validating audio data, including feature extraction and visualization.
5
5
  Maintainer-email: Hubert Stepniewski <hubert.stepniewski@int2code.com>, Marcin Tomiczek <marcin.tomiczek@int2code.com>, Piotr Sznapka <piotr.sznapka@int2code.com>
6
6
  License: MIT License
@@ -1,38 +1,38 @@
1
1
  {
2
2
  "files": [
3
- "README.md",
4
- "requirements-test.txt",
5
3
  "tox.ini",
6
- "LICENSE",
7
- ".pylint_ignore",
8
- "pyproject.toml",
9
4
  "requirements.txt",
10
- ".gitignore",
5
+ "requirements-test.txt",
11
6
  "requirements-dev.txt",
7
+ "pyproject.toml",
8
+ ".pylint_ignore",
9
+ "LICENSE",
12
10
  "RELEASE_NOTES.md",
13
- "docs/Makefile",
14
- "docs/index.rst",
15
- "docs/requirements.txt",
16
- "docs/installation.rst",
17
- "docs/contribute.rst",
18
- "docs/release_notes.rst",
19
- "docs/conf.py",
20
- "docs/tutorials/index.rst",
11
+ "README.md",
12
+ ".gitignore",
13
+ ".github/workflows/code_checker.yml",
14
+ ".github/workflows/unit_tests.yml",
15
+ ".github/workflows/publish_to_pypi.yml",
16
+ "tests/__init__.py",
17
+ "tests/test_template.py",
21
18
  "src/audio_validation/__init__.py",
22
19
  "src/audio_validation/audio_features.py",
23
20
  "src/audio_validation/audio_verification.py",
24
21
  "src/audio_validation/utils/audio_generation.py",
25
- "src/audio_validation/continous_validation/retention.py",
26
- "src/audio_validation/continous_validation/validator.py",
27
22
  "src/audio_validation/continous_validation/criteria.py",
23
+ "src/audio_validation/continous_validation/plots.py",
24
+ "src/audio_validation/continous_validation/validator.py",
28
25
  "src/audio_validation/continous_validation/recorder.py",
26
+ "src/audio_validation/continous_validation/retention.py",
29
27
  "src/audio_validation/continous_validation/models.py",
30
- "src/audio_validation/continous_validation/plots.py",
31
- "_ci/is_dev_version.sh",
32
- "tests/__init__.py",
33
- "tests/test_template.py",
34
- ".github/workflows/unit_tests.yml",
35
- ".github/workflows/publish_to_pypi.yml",
36
- ".github/workflows/code_checker.yml"
28
+ "docs/requirements.txt",
29
+ "docs/index.rst",
30
+ "docs/conf.py",
31
+ "docs/release_notes.rst",
32
+ "docs/Makefile",
33
+ "docs/contribute.rst",
34
+ "docs/installation.rst",
35
+ "docs/tutorials/index.rst",
36
+ "_ci/is_dev_version.sh"
37
37
  ]
38
38
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.2",
3
+ "distance": 0,
4
+ "node": "ge32c5a61ba2f0d187518620dd508fd43f8e2e24f",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-06"
8
+ }
@@ -1,11 +0,0 @@
1
- ### 0.3.0
2
-
3
- [MINOR] Add Continous Audio Validator.
4
-
5
- ### 0.2.0
6
-
7
- [MINOR] Add THD, THD+N calculation.
8
-
9
- ### 0.1.2
10
-
11
- [MINOR] Initial release.
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.0",
3
- "distance": 0,
4
- "node": "gc754f78124724d9a3fd179145568e6219acf5b25",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-16"
8
- }