audio-validation 0.1.2__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.2'
22
+ __version_tuple__ = version_tuple = (0, 1, 2)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,430 @@
1
+ """Classes for keeping audio features and utilities to compute them from raw samples."""
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Optional, Union
8
+
9
+ import numpy as np
10
+ import pytest
11
+ from _pytest.python_api import ApproxBase
12
+ from matplotlib import pyplot as plt
13
+ from scipy.fftpack import rfft, rfftfreq
14
+ from scipy.signal import find_peaks
15
+ from scipy.io import wavfile
16
+
17
+ logger = logging.getLogger(__name__)
18
+ save_plot_lock = threading.Lock()
19
+
20
+
21
+ # pylint:disable=too-many-instance-attributes, too-many-locals, too-many-positional-arguments
22
+ # pylint:disable=too-many-arguments
23
+ @dataclass
24
+ class ChannelFeatures:
25
+ """Holds calculated audio quantities for a single captured channel.
26
+
27
+ This is the per-channel building block used by :class:`AudioFeatures`.
28
+ Every field describes one audio channel extracted from a multi-channel capture.
29
+
30
+ :cvar samples: 1-D single-channel numpy sample array.
31
+ :cvar detected: ``True`` when all expected frequencies were found in the FFT.
32
+ :cvar failed_peaks: List of frequency strings (Hz) that did not match any
33
+ expected frequency; ``None`` when FFT detection was not requested.
34
+ :cvar peak_frequencies: 1-D array of detected FFT peak frequencies in Hz;
35
+ ``None`` when FFT detection was not requested.
36
+ :cvar peak_amplitudes: 1-D array of normalised amplitudes at each detected
37
+ peak; ``None`` when FFT detection was not requested.
38
+ :cvar rms: Root-mean-square value of the channel samples.
39
+ :cvar max: Maximum sample value.
40
+ :cvar min: Minimum sample value.
41
+ :cvar dbs: Level in dBFS (placeholder, populated as ``-90.0`` by default).
42
+ :cvar mean: Arithmetic mean of the channel samples.
43
+ :cvar start_audio_offset_s: Time in seconds from the start of the capture at
44
+ which the signal was first detected as varying; ``-1`` if the channel is
45
+ entirely silent.
46
+ """
47
+
48
+ samples: np.ndarray # 1-D single-channel array
49
+ detected: bool = False
50
+ failed_peaks: list = None
51
+ peak_frequencies: list = None
52
+ peak_amplitudes: list = None
53
+ rms: float = 0
54
+ max: float = 0
55
+ min: float = 0
56
+ dbs: float = 0
57
+ mean: float = 0
58
+ start_audio_offset_s: int = -1
59
+
60
+ @staticmethod
61
+ def _compute(
62
+ samples: np.ndarray,
63
+ sample_rate: int = 48000,
64
+ expected_frequencies: list = None,
65
+ tolerance: Union[int, float] = None,
66
+ freq_checker: Callable = None,
67
+ start_audio_offset_s: Optional[float] = None,
68
+ ) -> "ChannelFeatures":
69
+ """Compute all audio features from a 1-D single-channel sample array.
70
+
71
+ Basic statistics (rms, max, min, mean) are always computed.
72
+ FFT-based frequency detection is performed only when all three of
73
+ *expected_frequencies*, *tolerance*, and *freq_checker* are provided.
74
+
75
+ :param samples: 1-D array of samples for a single channel.
76
+ :param sample_rate: Sample rate of the audio in Hz.
77
+ :param expected_frequencies: List of expected frequencies in Hz
78
+ (e.g. ``[400, 800]``); pass ``None`` to skip FFT detection.
79
+ :param tolerance: Absolute frequency tolerance in Hz.
80
+ :param freq_checker: Aggregation callable — typically built-in ``all``
81
+ or ``any``.
82
+ :param start_audio_offset_s: Pre-computed onset offset in seconds to
83
+ store directly, bypassing the internal
84
+ :func:`get_audio_start_offset` call. Pass this when the caller has
85
+ already trimmed the samples (e.g. ``skip_latency=True`` in
86
+ :meth:`AudioFeatures.compute`) so that the stored offset reflects
87
+ the original capture position rather than a near-zero residual.
88
+ :return: Fully populated :class:`ChannelFeatures` instance.
89
+ """
90
+ samples_float = samples.astype(np.float64)
91
+ rms_val = round(np.sqrt(np.mean(samples_float**2)), 2)
92
+ max_val = float(np.max(samples))
93
+ min_val = float(np.min(samples))
94
+ mean_val = float(np.mean(samples))
95
+ if start_audio_offset_s is None:
96
+ start_audio_offset_s = get_audio_start_offset(samples, sample_rate)
97
+
98
+ detected = False
99
+ failed_peaks = None
100
+ peak_frequencies = None
101
+ peak_amplitudes = None
102
+
103
+ if (
104
+ expected_frequencies is not None
105
+ and tolerance is not None
106
+ and freq_checker is not None
107
+ ):
108
+ expected_freq_approx = _calculate_approx_values(
109
+ expected_frequencies, tolerance
110
+ )
111
+ peak_frequencies, peak_amplitudes = ChannelFeatures._calculate_ffts(
112
+ samples, sample_rate
113
+ )
114
+ checks = []
115
+ failed_peaks = []
116
+
117
+ for exp_approx in expected_freq_approx:
118
+ checks.append(any(pf == exp_approx for pf in peak_frequencies))
119
+
120
+ for peak_freq in peak_frequencies:
121
+ if peak_freq not in expected_freq_approx:
122
+ failed_peaks.append(str(int(peak_freq)))
123
+ detected = freq_checker(checks)
124
+
125
+ return ChannelFeatures(
126
+ samples=samples,
127
+ detected=detected,
128
+ failed_peaks=failed_peaks,
129
+ peak_frequencies=peak_frequencies,
130
+ peak_amplitudes=peak_amplitudes,
131
+ rms=rms_val,
132
+ max=max_val,
133
+ min=min_val,
134
+ dbs=-90.0,
135
+ mean=mean_val,
136
+ start_audio_offset_s=start_audio_offset_s,
137
+ )
138
+
139
+ @staticmethod
140
+ def from_wav(
141
+ filepath: str, channel: int = 0, skip_first: int = 0
142
+ ) -> "ChannelFeatures":
143
+ """Build :class:`ChannelFeatures` from a single channel of a WAV file.
144
+
145
+ :param filepath: Path to the WAV file.
146
+ :param channel: Channel to analyse (0-based index).
147
+ :param skip_first: Number of samples to discard from the start.
148
+ :return: :class:`ChannelFeatures` with basic statistics; ``detected``
149
+ is ``False``.
150
+ """
151
+ _, data = wavfile.read(filepath)
152
+ samples = data[:, channel] if data.ndim > 1 else data
153
+ samples = samples[skip_first:]
154
+ return ChannelFeatures._compute(samples=samples)
155
+
156
+ @staticmethod
157
+ def _calculate_ffts(samples, sample_rate=48000, **find_peaks_kwargs):
158
+ """Calculate RFFT peaks from a 1-D sample array.
159
+
160
+ :param samples: 1-D array of samples for a single channel.
161
+ :param sample_rate: Sample rate in Hz.
162
+ :param find_peaks_kwargs: Forwarded to :func:`scipy.signal.find_peaks`;
163
+ defaults are ``prominence=0.03, height=0.3``.
164
+ :return: Tuple ``(frequencies, amplitudes)`` — two 1-D arrays of
165
+ detected peak frequencies (Hz) and their normalised amplitudes.
166
+ """
167
+ default_kwargs = {"prominence": 0.03, "height": 0.3}
168
+ kwargs = default_kwargs | find_peaks_kwargs
169
+
170
+ y_amplitudes = np.abs(rfft(samples))
171
+ y_amplitudes /= np.max(y_amplitudes)
172
+ x_frequencies = rfftfreq(samples.size, 1 / sample_rate)
173
+ p_idx, _ = find_peaks(y_amplitudes, **kwargs)
174
+
175
+ return x_frequencies[p_idx], y_amplitudes[p_idx]
176
+
177
+
178
+ @dataclass
179
+ class AudioFeatures:
180
+ """Full multi-channel audio capture result.
181
+
182
+ Combines the raw multi-channel sample array with a per-channel list of
183
+ :class:`ChannelFeatures` computed from those samples.
184
+
185
+ :cvar samples: 2-D numpy array of shape ``(n_samples, n_channels)`` — the
186
+ native sounddevice / interleaved layout as returned by ``record_audio``.
187
+ :cvar channel_features: One :class:`ChannelFeatures` per channel in
188
+ channel-index order. Each entry may have been computed from a trimmed
189
+ slice of the corresponding column in ``samples`` (e.g. when
190
+ *skip_first* or *skip_latency* is used in :meth:`compute`).
191
+ """
192
+
193
+ samples: np.ndarray # (n_samples, n_channels)
194
+ channel_features: list
195
+
196
+ def __len__(self) -> int:
197
+ """Return the number of channels."""
198
+ return len(self.channel_features)
199
+
200
+ def __getitem__(self, channel: int) -> ChannelFeatures:
201
+ """Return the :class:`ChannelFeatures` for *channel* (0-based index).
202
+
203
+ :param channel: 0-based channel index.
204
+ :return: :class:`ChannelFeatures` for the requested channel.
205
+ """
206
+ return self.channel_features[channel]
207
+
208
+ @staticmethod
209
+ def compute(
210
+ samples: np.ndarray,
211
+ sample_rate: int = 48000,
212
+ expected_frequencies: list = None,
213
+ tolerance: Union[int, float] = None,
214
+ freq_checker: Callable = None,
215
+ skip_first: int = 0,
216
+ skip_latency: bool = False,
217
+ ) -> "AudioFeatures":
218
+ """Build :class:`AudioFeatures` from a multi-channel sample array.
219
+
220
+ Computes :class:`ChannelFeatures` for every channel in *samples*.
221
+ FFT-based frequency detection is performed only when
222
+ *expected_frequencies*, *tolerance*, and *freq_checker* are all given.
223
+
224
+ :param samples: 2-D array of shape ``(n_samples, n_channels)`` — the
225
+ native sounddevice / interleaved layout.
226
+ :param sample_rate: Sample rate in Hz.
227
+ :param expected_frequencies: Per-channel expected frequencies indexed by
228
+ channel position, e.g. ``[[400], [800]]``; pass ``None`` to skip
229
+ FFT detection.
230
+ :param tolerance: Absolute frequency tolerance in Hz.
231
+ :param freq_checker: Aggregation callable — typically built-in ``all``
232
+ or ``any``.
233
+ :param skip_first: Samples to discard from the front of each channel
234
+ (ignored when *skip_latency* is ``True``).
235
+ :param skip_latency: When ``True``, auto-detect the signal start and
236
+ trim the silent prefix instead of using *skip_first*.
237
+ :return: :class:`AudioFeatures` with ``samples`` and
238
+ ``channel_features`` populated.
239
+ """
240
+ n_channels = samples.shape[1] if samples.ndim > 1 else 1
241
+ channel_features: list[ChannelFeatures] = []
242
+
243
+ for ch in range(n_channels):
244
+ ch_samples = samples[:, ch] if samples.ndim > 1 else samples
245
+
246
+ pre_trim_offset_s: Optional[float] = None
247
+ if skip_latency:
248
+ pre_trim_offset_s = get_audio_start_offset(ch_samples, sample_rate)
249
+ if pre_trim_offset_s >= 0:
250
+ ch_samples = ch_samples[int(pre_trim_offset_s * sample_rate) :]
251
+ else:
252
+ logger.debug(
253
+ "skip_latency: channel %d is entirely silent, no trimming.", ch
254
+ )
255
+ elif skip_first > 0:
256
+ ch_samples = ch_samples[skip_first:]
257
+
258
+ ch_freqs = (
259
+ expected_frequencies[ch] if expected_frequencies is not None else None
260
+ )
261
+ ch_features = ChannelFeatures._compute( # pylint: disable=protected-access
262
+ samples=ch_samples,
263
+ sample_rate=sample_rate,
264
+ expected_frequencies=ch_freqs,
265
+ tolerance=tolerance,
266
+ freq_checker=freq_checker,
267
+ start_audio_offset_s=pre_trim_offset_s,
268
+ )
269
+ channel_features.append(ch_features)
270
+
271
+ return AudioFeatures(samples=samples, channel_features=channel_features)
272
+
273
+ @staticmethod
274
+ def from_wav(
275
+ filepath: str, channels: int = 1, skip_first: int = 0
276
+ ) -> "AudioFeatures":
277
+ """Build :class:`AudioFeatures` from a WAV file (no FFT detection).
278
+
279
+ :param filepath: Path to the WAV file.
280
+ :param channels: Number of channels to load (first *channels* tracks).
281
+ :param skip_first: Samples to discard from the front of every channel.
282
+ :return: :class:`AudioFeatures` with basic statistics per channel;
283
+ ``detected`` is ``False`` on every :class:`ChannelFeatures`.
284
+ """
285
+ _, data = wavfile.read(filepath)
286
+ if data.ndim == 1:
287
+ data = data.reshape(-1, 1)
288
+
289
+ wav_samples = data[:, :channels]
290
+ if skip_first:
291
+ wav_samples = wav_samples[skip_first:]
292
+ return AudioFeatures.compute(samples=wav_samples)
293
+
294
+
295
+ def _calculate_approx_values(
296
+ frequencies: list[int], tolerance: int | float
297
+ ) -> list[ApproxBase]:
298
+ """Return pytest.approx wrappers for each frequency ± tolerance.
299
+
300
+ :param frequencies: List of frequency values in Hz to wrap in
301
+ :func:`pytest.approx`.
302
+ :param tolerance: Absolute tolerance applied symmetrically to each
303
+ frequency.
304
+ :return: List of :class:`~_pytest.python_api.ApproxBase` objects, one per
305
+ input frequency.
306
+ """
307
+ expected_freq_approx = list(
308
+ map(lambda x: pytest.approx(x, abs=tolerance), frequencies)
309
+ )
310
+ return expected_freq_approx
311
+
312
+
313
+ def draw_plots(
314
+ audio: "AudioFeatures",
315
+ path: str,
316
+ chunk_size: int = 1024,
317
+ ) -> None:
318
+ """Draw time-domain and FFT plots for all channels and save to *path*.
319
+
320
+ Produces a grid of ``(n_channels, 2)`` subplots: the left column shows the
321
+ time-domain waveform and the right column shows the detected FFT peaks.
322
+
323
+ :param audio: :class:`AudioFeatures` containing per-channel features.
324
+ :param path: Destination file path including extension
325
+ (e.g. ``"output.png"``).
326
+ :param chunk_size: Number of samples shown on the time-domain axis.
327
+ """
328
+ n = len(audio.channel_features)
329
+ with save_plot_lock:
330
+ fig, axes = plt.subplots(n, 2, figsize=(18, 5 * n), squeeze=False)
331
+ for ch, feat in enumerate(audio.channel_features):
332
+ n_show = min(len(feat.samples), chunk_size * 10)
333
+
334
+ ax_time = axes[ch][0]
335
+ ax_time.plot(feat.samples[:n_show])
336
+ ax_time.set_title(f"Audio CH{ch}")
337
+ ax_time.set_xlabel(f"First {n_show} samples")
338
+ ax_time.set_ylabel("Amplitude")
339
+
340
+ ax_fft = axes[ch][1]
341
+ if feat.peak_frequencies is not None and len(feat.peak_frequencies):
342
+ ax_fft.plot(feat.peak_frequencies, feat.peak_amplitudes, "x")
343
+ ax_fft.vlines(feat.peak_frequencies, 0, feat.peak_amplitudes)
344
+ ax_fft.set_title(f"RFFT CH{ch}")
345
+ ax_fft.set_xlabel("Frequency [Hz]")
346
+ ax_fft.set_ylabel("Power")
347
+ ax_fft.ticklabel_format(useOffset=False)
348
+
349
+ plt.tight_layout()
350
+ if dirname := os.path.dirname(path):
351
+ os.makedirs(dirname, exist_ok=True)
352
+ plt.savefig(path)
353
+ plt.close(fig)
354
+
355
+
356
+ def save_to_wave(
357
+ audio: "AudioFeatures",
358
+ path: str,
359
+ samplerate: int = 48000,
360
+ dtype: str = "float32",
361
+ ) -> None:
362
+ """Save all channels to a single multi-channel WAV file.
363
+
364
+ Uses ``audio.samples`` (the original unprocessed 2-D capture array) so
365
+ that the full, untrimmed data is written regardless of any *skip_first* /
366
+ *skip_latency* trimming applied during feature computation.
367
+
368
+ :param audio: :class:`AudioFeatures` whose ``samples`` array is written.
369
+ :param path: Destination WAV file path.
370
+ :param samplerate: Sample rate in Hz.
371
+ :param dtype: Numpy dtype string matching the original capture format.
372
+ """
373
+ if dirname := os.path.dirname(path):
374
+ os.makedirs(dirname, exist_ok=True)
375
+ # audio.samples shape: (n_samples, n_channels) — already interleaved, write directly
376
+ wavfile.write(path, samplerate, audio.samples.astype(dtype))
377
+
378
+
379
+ def get_audio_start_offset(
380
+ samples: np.ndarray[Any], sample_rate: int, threshold: int = 100
381
+ ) -> int | float:
382
+ """Calculate the time (in seconds) when the audio starts varying.
383
+
384
+ :param samples: Numpy array of audio samples.
385
+ :param sample_rate: Sample rate of the audio in Hz.
386
+ :param threshold: Standard-deviation threshold used to detect signal
387
+ activity.
388
+ :return: Time in seconds (relative to the start of *samples*) at which
389
+ the signal first becomes active (std > *threshold*); ``-1`` if the
390
+ signal never varies.
391
+ """
392
+ window = 100
393
+ signal_evaluation = detect_if_signal_changes(
394
+ samples, window_size=window, threshold=threshold
395
+ )
396
+ first_index_where_started = np.where(signal_evaluation)[0]
397
+ if first_index_where_started.size:
398
+ idx = first_index_where_started[0] * window
399
+ time_where_started = idx / sample_rate
400
+ else:
401
+ time_where_started = -1
402
+ return time_where_started
403
+
404
+
405
+ def detect_if_signal_changes(
406
+ samples: np.ndarray[Any], window_size=100, threshold=50
407
+ ) -> np.ndarray[np.bool]:
408
+ """Detect whether the signal is varying within successive windows.
409
+
410
+ Divides *samples* into non-overlapping windows of *window_size* and
411
+ computes the standard deviation of each window. A window is marked as
412
+ active when its standard deviation exceeds *threshold*.
413
+
414
+ :param samples: Numpy array of audio samples.
415
+ :param window_size: Number of samples per evaluation window.
416
+ :param threshold: Standard-deviation threshold above which the signal is
417
+ considered as varying.
418
+ :return: Boolean array of length ``(len(samples) // window_size) - 1``
419
+ indicating activity in each window.
420
+ """
421
+ n_windows = len(samples) // window_size
422
+ if n_windows < 2:
423
+ # Not enough samples to fill even two windows — treat as non-varying (silent).
424
+ return np.zeros(0, dtype=bool)
425
+ result = np.zeros(n_windows - 1, dtype=bool)
426
+ for i, _ in enumerate(result):
427
+ window = samples[i * window_size : (i + 1) * window_size]
428
+ if abs(np.std(window)) > threshold:
429
+ result[i] = True
430
+ return result
@@ -0,0 +1,567 @@
1
+ """Methods for validating captured audio against a reference WAV,
2
+ with detailed mismatch analysis and artefact generation.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+ import threading
8
+ import wave
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from matplotlib import pyplot as plt
13
+
14
+ logger = logging.getLogger(__name__)
15
+ save_plot_lock = threading.Lock()
16
+
17
+
18
+ def _find_mismatches(
19
+ diff: np.ndarray,
20
+ merge_gap: int = 16,
21
+ ) -> list[tuple[int, int]]:
22
+ """Return non-zero diff regions as ``(start, end)`` pairs.
23
+
24
+ Regions separated by at most *merge_gap* zero samples are merged.
25
+
26
+ :param diff: 1-D int64 difference array
27
+ :param merge_gap: max gap in samples to merge into one run
28
+ :return: list of ``(start, end)`` pairs (inclusive, 0-based)
29
+ """
30
+ mismatch_indexes = np.nonzero(diff)[0]
31
+ if len(mismatch_indexes) == 0:
32
+ return []
33
+ mismatches: list[tuple[int, int]] = []
34
+ mismatch_start = int(mismatch_indexes[0])
35
+ last_mismatch = int(mismatch_indexes[0])
36
+ for idx in mismatch_indexes[1:]:
37
+ idx = int(idx)
38
+ if idx - last_mismatch > merge_gap:
39
+ mismatches.append((mismatch_start, last_mismatch))
40
+ mismatch_start = idx
41
+ last_mismatch = idx
42
+ mismatches.append((mismatch_start, last_mismatch))
43
+ return mismatches
44
+
45
+
46
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
47
+ def _find_best_resync_lag(
48
+ reference: np.ndarray,
49
+ detected: np.ndarray,
50
+ ref_mismatch: int,
51
+ det_mismatch: int,
52
+ max_offset_search: int,
53
+ resync_verify_window: int,
54
+ ) -> tuple[int, float]:
55
+ """Return the lag with the highest bit-exact match ratio at a mismatch point.
56
+
57
+ Tries all integer lags in ``-max_offset_search … +max_offset_search``.
58
+
59
+ :param reference: full 1-D reference array
60
+ :param detected: full 1-D detected array
61
+ :param ref_mismatch: absolute mismatch index in *reference*
62
+ :param det_mismatch: absolute mismatch index in *detected*
63
+ :param max_offset_search: maximum ±offset to try
64
+ :param resync_verify_window: verification window length in samples
65
+ :return: ``(best_lag, best_ratio)``
66
+ """
67
+ best_lag = 0
68
+ best_ratio = 0.0
69
+ for lag in range(-max_offset_search, max_offset_search + 1):
70
+ ref_start = ref_mismatch + max(0, -lag)
71
+ det_start = det_mismatch + max(0, lag)
72
+ window = min(
73
+ resync_verify_window,
74
+ len(reference) - ref_start,
75
+ len(detected) - det_start,
76
+ )
77
+ if window < resync_verify_window // 2:
78
+ continue
79
+ ratio = (
80
+ float(
81
+ np.sum(
82
+ reference[ref_start : ref_start + window].astype(np.int64)
83
+ == detected[det_start : det_start + window].astype(np.int64)
84
+ )
85
+ )
86
+ / window
87
+ )
88
+ if ratio > best_ratio:
89
+ best_ratio = ratio
90
+ best_lag = lag
91
+ return best_lag, best_ratio
92
+
93
+
94
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
95
+ def _segment_compare(
96
+ reference: np.ndarray,
97
+ detected: np.ndarray,
98
+ max_offset_search: int = 64,
99
+ resync_verify_window: int = 256,
100
+ min_match_ratio: float = 0.95,
101
+ max_iterations: int = 128,
102
+ ) -> tuple[list[tuple[int, int]], int]:
103
+ """Compare arrays with automatic re-alignment after sample insertion/deletion glitches.
104
+
105
+ A plain diff permanently phase-shifts after one inserted/deleted sample.
106
+ This function re-aligns at each such event and reports only truly bad samples.
107
+
108
+ Algorithm (up to *max_iterations*):
109
+
110
+ 1. Find first non-zero position in the remaining diff.
111
+ 2. Score candidate lags via :func:`_find_best_resync_lag`.
112
+ 3. High ratio + non-zero lag → timing glitch: record and re-align.
113
+ 4. Otherwise → data error: record run via :func:`_find_mismatches` and advance.
114
+
115
+ :param reference: 1-D reference array (post initial-lag alignment)
116
+ :param detected: 1-D detected array (post initial-lag alignment)
117
+ :param max_offset_search: maximum ±offset to try at each mismatch
118
+ :param resync_verify_window: samples used to score each candidate offset
119
+ :param min_match_ratio: minimum match fraction to accept a re-sync
120
+ :param max_iterations: safety cap on iterations
121
+ :return: ``(mismatches, cumulative_lag)``
122
+ """
123
+ ref_pos = 0
124
+ det_pos = 0
125
+ mismatches: list[tuple[int, int]] = []
126
+
127
+ for _ in range(max_iterations):
128
+ if ref_pos >= len(reference) or det_pos >= len(detected):
129
+ break
130
+
131
+ remaining = min(len(reference) - ref_pos, len(detected) - det_pos)
132
+ diff_rem = detected[det_pos : det_pos + remaining].astype(np.int64) - reference[
133
+ ref_pos : ref_pos + remaining
134
+ ].astype(np.int64)
135
+ mismatch_indexes = np.nonzero(diff_rem)[0]
136
+ if len(mismatch_indexes) == 0:
137
+ break
138
+
139
+ local_mismatch = int(mismatch_indexes[0])
140
+ abs_ref_mismatch = ref_pos + local_mismatch
141
+ abs_det_mismatch = det_pos + local_mismatch
142
+
143
+ best_lag, best_ratio = _find_best_resync_lag(
144
+ reference,
145
+ detected,
146
+ abs_ref_mismatch,
147
+ abs_det_mismatch,
148
+ max_offset_search,
149
+ resync_verify_window,
150
+ )
151
+
152
+ if best_ratio >= min_match_ratio and best_lag != 0:
153
+ mismatches.append((abs_ref_mismatch, abs_ref_mismatch + abs(best_lag) - 1))
154
+ if best_lag > 0:
155
+ # insertion in detected → advance detected
156
+ ref_pos = abs_ref_mismatch
157
+ det_pos = abs_det_mismatch + best_lag
158
+ else:
159
+ # deletion in detected → advance reference
160
+ ref_pos = abs_ref_mismatch + abs(best_lag)
161
+ det_pos = abs_det_mismatch
162
+ else:
163
+ # Data error or no clean re-sync — record run and advance past it.
164
+ first_mismatch = _find_mismatches(diff_rem[local_mismatch:], merge_gap=16)
165
+ if first_mismatch:
166
+ _, mismatch_end_local = first_mismatch[0]
167
+ mismatches.append(
168
+ (
169
+ ref_pos + local_mismatch,
170
+ ref_pos + local_mismatch + mismatch_end_local,
171
+ )
172
+ )
173
+ advance = local_mismatch + mismatch_end_local + 1
174
+ ref_pos += advance
175
+ det_pos += advance
176
+ else:
177
+ # Fallback: report all remaining
178
+ mismatches.append((abs_ref_mismatch, ref_pos + remaining - 1))
179
+ break
180
+
181
+ return mismatches, det_pos - ref_pos # cumulative timing drift
182
+
183
+
184
+ def _align_channel_samples(
185
+ ref_samples: np.ndarray,
186
+ det_samples: np.ndarray,
187
+ channel: int,
188
+ ) -> tuple[np.ndarray, np.ndarray, int]:
189
+ """Trim *det_samples* so both arrays share the same signal onset.
190
+
191
+ :param ref_samples: 1-D reference channel samples
192
+ :param det_samples: 1-D detected channel samples
193
+ :param channel: channel index (error messages only)
194
+ :return: ``(ref_aligned, det_aligned, signal_offset)``
195
+ :raises ValueError: if either channel is completely silent
196
+ :raises RuntimeError: if detected starts before reference (negative latency)
197
+ """
198
+ ref_nz = np.nonzero(ref_samples)[0]
199
+ det_nz = np.nonzero(det_samples)[0]
200
+
201
+ if len(ref_nz) == 0 or len(det_nz) == 0:
202
+ raise ValueError(f"Channel {channel} is completely silent.")
203
+
204
+ ref_start = int(ref_nz[0])
205
+ det_start = int(det_nz[0])
206
+ signal_offset = det_start - ref_start
207
+
208
+ if signal_offset < 0:
209
+ raise RuntimeError(
210
+ f"[Ch {channel}] Detected signal starts {abs(signal_offset)} samples "
211
+ f"BEFORE the reference (ref_start={ref_start}, det_start={det_start}). "
212
+ f"Negative latency is physically impossible — check that detected_data "
213
+ )
214
+
215
+ if signal_offset > 0:
216
+ det_aligned = det_samples[signal_offset:]
217
+ ref_aligned = ref_samples[: len(det_aligned)]
218
+ else:
219
+ det_aligned = det_samples
220
+ ref_aligned = ref_samples
221
+
222
+ min_len = min(len(det_aligned), len(ref_aligned))
223
+ return ref_aligned[:min_len], det_aligned[:min_len], signal_offset
224
+
225
+
226
+ def _compute_mismatch_stats(
227
+ ref_aligned: np.ndarray,
228
+ det_aligned: np.ndarray,
229
+ mismatches: list[tuple[int, int]],
230
+ ) -> tuple[int, int, int, str]:
231
+ """Return ``(total_samples, max_abs_diff, n_runs, run_description)`` for mismatch runs.
232
+
233
+ :param ref_aligned: 1-D aligned reference samples
234
+ :param det_aligned: 1-D aligned detected samples
235
+ :param mismatches: list of ``(start, end)`` pairs
236
+ :return: ``(total_mismatch_samples, max_abs_diff, n_runs, run_description)``
237
+ """
238
+ n_runs = len(mismatches)
239
+ total_mismatch_samples = sum(end - start + 1 for start, end in mismatches)
240
+ max_abs_diff = max(
241
+ int(
242
+ np.max(
243
+ np.abs(
244
+ det_aligned[s : e + 1].astype(np.int64)
245
+ - ref_aligned[s : e + 1].astype(np.int64)
246
+ )
247
+ )
248
+ )
249
+ for s, e in mismatches
250
+ )
251
+ run_description = (
252
+ "1 continuous mismatch region"
253
+ if n_runs == 1
254
+ else f"{n_runs} separate mismatch regions"
255
+ )
256
+ return total_mismatch_samples, max_abs_diff, n_runs, run_description
257
+
258
+
259
+ def _save_mismatch_plot(
260
+ ref_aligned: np.ndarray,
261
+ det_aligned: np.ndarray,
262
+ start: int,
263
+ end: int,
264
+ run_idx: int,
265
+ total_runs: int,
266
+ channel: int,
267
+ artifacts_dir: str,
268
+ max_plot_samples: int = 4096,
269
+ context_samples: int = 256,
270
+ ) -> str:
271
+ """Save a PNG with reference (top) and detected (bottom) subplots for one mismatch run.
272
+
273
+ The x-axis is capped at *max_plot_samples* for readability.
274
+
275
+ :param ref_aligned: 1-D reference array (post-alignment)
276
+ :param det_aligned: 1-D detected array (post-alignment)
277
+ :param start: mismatch start index (inclusive)
278
+ :param end: mismatch end index (inclusive)
279
+ :param run_idx: 1-based run index
280
+ :param total_runs: total mismatch run count
281
+ :param channel: channel index
282
+ :param artifacts_dir: output directory
283
+ :param max_plot_samples: max samples to render
284
+ :param context_samples: samples prepended/appended around the mismatch
285
+ :return: path of the saved PNG
286
+ """
287
+ ctx_start = max(0, start - context_samples)
288
+ ctx_end = min(len(ref_aligned), end + context_samples + 1)
289
+ if ctx_end - ctx_start > max_plot_samples:
290
+ ctx_end = ctx_start + max_plot_samples
291
+
292
+ x = np.arange(ctx_start, ctx_end)
293
+ ref_slice = ref_aligned[ctx_start:ctx_end]
294
+ det_slice = det_aligned[ctx_start:ctx_end]
295
+
296
+ span_start = max(start, ctx_start)
297
+ span_end = min(end, ctx_end - 1)
298
+ run_len = end - start + 1
299
+ truncated = (ctx_end - ctx_start) >= max_plot_samples
300
+
301
+ title_suffix = f"mismatch {run_idx}/{total_runs} | {run_len} bad samples" + (
302
+ " [plot truncated to first samples]" if truncated else ""
303
+ )
304
+
305
+ with save_plot_lock:
306
+ fig, axes = plt.subplots(2, 1, figsize=(14, 6), sharex=True)
307
+
308
+ axes[0].plot(x, ref_slice, color="steelblue", linewidth=0.8, label="reference")
309
+ if span_end >= span_start:
310
+ axes[0].axvspan(
311
+ span_start, span_end, alpha=0.25, color="red", label="mismatch"
312
+ )
313
+ axes[0].set_title(f"Ch {channel} – Reference ({title_suffix})")
314
+ axes[0].set_ylabel("Amplitude")
315
+ axes[0].legend(loc="upper right", fontsize=8)
316
+
317
+ axes[1].plot(x, det_slice, color="darkorange", linewidth=0.8, label="detected")
318
+ if span_end >= span_start:
319
+ axes[1].axvspan(
320
+ span_start, span_end, alpha=0.25, color="red", label="mismatch"
321
+ )
322
+ axes[1].set_title(f"Ch {channel} – Detected ({title_suffix})")
323
+ axes[1].set_ylabel("Amplitude")
324
+ axes[1].set_xlabel("Sample index (post-alignment)")
325
+ axes[1].legend(loc="upper right", fontsize=8)
326
+
327
+ plt.tight_layout()
328
+ png_path = os.path.join(
329
+ artifacts_dir, f"null_test_ch{channel}_mismatch{run_idx:02d}.png"
330
+ )
331
+ plt.savefig(png_path, dpi=100)
332
+ plt.close(fig)
333
+
334
+ logger.info("Saved mismatch plot: %s", png_path)
335
+ return png_path
336
+
337
+
338
+ def _save_mismatch_wav(
339
+ ref_aligned: np.ndarray,
340
+ det_aligned: np.ndarray,
341
+ start: int,
342
+ end: int,
343
+ run_idx: int,
344
+ channel: int,
345
+ artifacts_dir: str,
346
+ sample_rate: int,
347
+ dtype: str,
348
+ context_samples: int = 256,
349
+ ) -> str:
350
+ """Save a 2-channel WAV clip for one mismatch run (reference=L, detected=R).
351
+
352
+ :param ref_aligned: 1-D reference array (post-alignment)
353
+ :param det_aligned: 1-D detected array (post-alignment)
354
+ :param start: mismatch start index (inclusive)
355
+ :param end: mismatch end index (inclusive)
356
+ :param run_idx: 1-based run index
357
+ :param channel: channel index
358
+ :param artifacts_dir: output directory
359
+ :param sample_rate: sample rate in Hz
360
+ :param dtype: numpy dtype string (e.g. ``"int16"``)
361
+ :param context_samples: samples prepended/appended around the mismatch
362
+ :return: path of the saved WAV
363
+ """
364
+ wav_start = max(0, start - context_samples)
365
+ wav_end = min(len(ref_aligned), end + context_samples + 1)
366
+ ref_wav = ref_aligned[wav_start:wav_end].reshape(-1, 1)
367
+ det_wav = det_aligned[wav_start:wav_end].reshape(-1, 1)
368
+ stacked = np.hstack([ref_wav, det_wav]) # 2-ch: ref=L, detected=R
369
+ wav_path = os.path.join(
370
+ artifacts_dir, f"null_test_ch{channel}_mismatch{run_idx:02d}.wav"
371
+ )
372
+ with wave.open(wav_path, "wb") as wf: # pylint: disable=no-member
373
+ wf.setnchannels(2) # type: ignore[attr-defined] # pylint: disable=no-member
374
+ wf.setframerate(sample_rate) # type: ignore[attr-defined] # pylint: disable=no-member
375
+ wf.setsampwidth(np.dtype(dtype).itemsize) # type: ignore[attr-defined] # pylint: disable=no-member
376
+ wf.writeframes(stacked.astype(dtype).tobytes()) # type: ignore[attr-defined] # pylint: disable=no-member
377
+ logger.info("Saved mismatch WAV: %s", wav_path)
378
+ return wav_path
379
+
380
+
381
+ def _save_mismatch_artifacts(
382
+ ref_aligned: np.ndarray,
383
+ det_aligned: np.ndarray,
384
+ mismatches: list[tuple[int, int]],
385
+ channel: int,
386
+ artifacts_dir: str,
387
+ max_plot_samples: int = 4096,
388
+ context_samples: int = 256,
389
+ ) -> list[str]:
390
+ """Save a PNG per mismatch run and (when >1 run) a WAV per run.
391
+
392
+ :param ref_aligned: 1-D reference array (post-alignment)
393
+ :param det_aligned: 1-D detected array (post-alignment)
394
+ :param mismatches: list of ``(start, end)`` run pairs
395
+ :param channel: channel index
396
+ :param artifacts_dir: output directory
397
+ :param max_plot_samples: max samples per plot
398
+ :param context_samples: guard-band around each run
399
+ :return: list of created file paths
400
+ """
401
+ os.makedirs(artifacts_dir, exist_ok=True)
402
+ saved: list[str] = []
403
+ total_runs = len(mismatches)
404
+
405
+ for run_idx, (mismatch_start, mismatch_end) in enumerate(mismatches, start=1):
406
+ png_path = _save_mismatch_plot(
407
+ ref_aligned=ref_aligned,
408
+ det_aligned=det_aligned,
409
+ start=mismatch_start,
410
+ end=mismatch_end,
411
+ run_idx=run_idx,
412
+ total_runs=total_runs,
413
+ channel=channel,
414
+ artifacts_dir=artifacts_dir,
415
+ max_plot_samples=max_plot_samples,
416
+ context_samples=context_samples,
417
+ )
418
+ saved.append(png_path)
419
+ return saved
420
+
421
+
422
+ def _build_null_test_table(rows: list[dict]) -> str:
423
+ """Format per-channel null-test results as a table string for pytest assert messages.
424
+
425
+ :param rows: per-channel result dicts produced by :func:`validate_audio_data`
426
+ :return: newline-prefixed table string
427
+ """
428
+ df = pd.DataFrame(rows).set_index("ch")
429
+ return "\n" + df.to_string(justify="left")
430
+
431
+
432
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
433
+ def validate_audio_perfect(
434
+ detected_data: np.ndarray,
435
+ reference_data: np.ndarray,
436
+ samplerate: int,
437
+ artifacts_dir: str = "test_artifacts",
438
+ broadcast_reference_ch0: bool = False,
439
+ ) -> tuple[bool, str]:
440
+ """Null-test every channel after aligning detected audio to the reference.
441
+
442
+ Alignment is based on signal onset per channel. A channel passes when every
443
+ aligned sample difference is exactly zero (bit-perfect). Timing-only drift
444
+ (sample insertions/deletions) is resolved by :func:`_segment_compare` and
445
+ does not cause a failure on its own.
446
+
447
+ On failure, artefacts are written to *artifacts_dir*:
448
+
449
+ * PNG per mismatch run (reference + detected subplots, highlighted region).
450
+ * WAV per mismatch run (ref=L, det=R) — only when there are multiple runs.
451
+
452
+ :param detected_data: capture array from ``record_audio``, shape ``(n_samples, channels)``
453
+ :param reference_data: WAV array from ``play_audio``, shape ``(n_frames, channels)``
454
+ :param samplerate: sample rate in Hz
455
+ :param artifacts_dir: directory for failure PNGs and WAVs
456
+ :param broadcast_reference_ch0: compare all detected channels against reference ch 0
457
+ :return: ``(True, table)`` on pass, ``(False, table)`` on failure —
458
+ table has one row per channel
459
+ """
460
+ if detected_data.ndim != 2:
461
+ raise ValueError(
462
+ f"detected_data must be 2-D (n_samples, channels), got shape {detected_data.shape}."
463
+ )
464
+ if reference_data.ndim != 2:
465
+ raise ValueError(
466
+ f"reference_data must be 2-D (n_frames, channels), got shape {reference_data.shape}."
467
+ )
468
+
469
+ reference_channels = reference_data.shape[1]
470
+ detected_channels = detected_data.shape[1]
471
+
472
+ if not broadcast_reference_ch0 and reference_channels != detected_channels:
473
+ return (
474
+ False,
475
+ f"Channel count mismatch: detected has {detected_channels} ch, "
476
+ f"reference has {reference_channels} ch. "
477
+ f"Use broadcast_reference_ch0=True to compare all against ch 0.",
478
+ )
479
+
480
+ channel_rows: list[dict] = []
481
+
482
+ for channel in range(detected_channels):
483
+ ref_ch_idx = 0 if broadcast_reference_ch0 else channel
484
+ ref_samples = reference_data[:, ref_ch_idx]
485
+ det_samples = detected_data[:, channel]
486
+
487
+ row: dict = {
488
+ "ch": channel,
489
+ "status": "?",
490
+ "audio_offset": "-",
491
+ "correct_samples": 0,
492
+ "incorrect_samples": 0,
493
+ "glitch_count": 0,
494
+ "artifacts_path": "-",
495
+ }
496
+
497
+ try:
498
+ ref_aligned, det_aligned, signal_offset = _align_channel_samples(
499
+ ref_samples, det_samples, channel
500
+ )
501
+ except ValueError:
502
+ row["status"] = "SILENT"
503
+ channel_rows.append(row)
504
+ continue
505
+
506
+ row["audio_offset"] = f"{signal_offset / samplerate:.3f}s ({signal_offset})"
507
+
508
+ if len(ref_aligned) == 0:
509
+ row["status"] = "NO_OVERLAP"
510
+ channel_rows.append(row)
511
+ continue
512
+
513
+ row["correct_samples"] = len(ref_aligned)
514
+
515
+ diff = det_aligned.astype(np.int64) - ref_aligned.astype(np.int64)
516
+ if int(np.count_nonzero(diff)) == 0:
517
+ row["status"] = "PASS"
518
+ channel_rows.append(row)
519
+ logger.info(
520
+ "Channel %d: PASSED (%d aligned samples).", channel, len(ref_aligned)
521
+ )
522
+ continue
523
+
524
+ mismatches, timing_drift = _segment_compare(ref_aligned, det_aligned)
525
+
526
+ if not mismatches:
527
+ row["status"] = "PASS"
528
+ channel_rows.append(row)
529
+ logger.info(
530
+ "Channel %d: PASSED (timing drift only, %+d samples).",
531
+ channel,
532
+ timing_drift,
533
+ )
534
+ continue
535
+
536
+ total_mismatch_samples, _, n_runs, run_description = _compute_mismatch_stats(
537
+ ref_aligned, det_aligned, mismatches
538
+ )
539
+ row.update(
540
+ {
541
+ "status": "FAIL",
542
+ "correct_samples": len(ref_aligned) - total_mismatch_samples,
543
+ "incorrect_samples": total_mismatch_samples,
544
+ "glitch_count": n_runs,
545
+ }
546
+ )
547
+
548
+ logger.warning(
549
+ "[Ch %d] FAILED — %s. Saving artefacts to '%s'.",
550
+ channel,
551
+ run_description,
552
+ artifacts_dir,
553
+ )
554
+ _save_mismatch_artifacts(
555
+ ref_aligned=ref_aligned,
556
+ det_aligned=det_aligned,
557
+ mismatches=mismatches,
558
+ channel=channel,
559
+ artifacts_dir=artifacts_dir,
560
+ )
561
+ row["artifacts_path"] = artifacts_dir
562
+
563
+ channel_rows.append(row)
564
+
565
+ passed = all(r["status"] == "PASS" for r in channel_rows)
566
+ table = _build_null_test_table(channel_rows)
567
+ return passed, table
@@ -0,0 +1,168 @@
1
+ """Audio waveform generation and file output.
2
+
3
+ This module provides utilities for generating various waveform types and saving them
4
+ as multi-channel WAV files. Supports the following waveform shapes:
5
+
6
+ - Sine wave: pure sinusoidal oscillation at specified frequency
7
+ - Square wave: binary waveform oscillating between +1 and -1
8
+ - Sawtooth wave: linearly increasing periodic waveform
9
+ - White noise: uniformly distributed random noise
10
+ - Pink noise: frequency-weighted noise with reduced high-frequency content
11
+
12
+ Generated audio can be multi-channel, with selective activation of specific channels
13
+ and configurable amplitude, sample rate, and duration. Output is saved as 32-bit
14
+ PCM WAV files suitable for audio playback and analysis.
15
+ """
16
+
17
+ import logging
18
+ import wave
19
+ from typing import Literal, List
20
+
21
+ import numpy as np
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _encode_pcm(data: np.ndarray, resolution_bits: int) -> bytes:
27
+ """Encode float32 audio data to raw PCM bytes.
28
+
29
+ :param data: float32 array of shape ``(n_samples, n_channels)``
30
+ :param resolution_bits: bit depth – 16, 24, or 32
31
+ :return: raw PCM bytes
32
+ :raises ValueError: if *resolution_bits* is not supported
33
+ """
34
+ if resolution_bits == 16:
35
+ max_val = np.iinfo(np.int16).max
36
+ return (data * max_val).astype(np.int16).tobytes()
37
+ if resolution_bits == 24:
38
+ max_val = 2**23 - 1
39
+ pcm = (data * max_val).astype(np.int32)
40
+ raw = pcm.tobytes()
41
+ return bytes(b for i, b in enumerate(raw) if i % 4 != 3)
42
+ if resolution_bits == 32:
43
+ max_val = np.iinfo(np.int32).max
44
+ return (data * max_val).astype(np.int32).tobytes()
45
+ raise ValueError(
46
+ f"Unsupported resolution_bits: {resolution_bits}. Supported values are 16, 24 and 32."
47
+ )
48
+
49
+
50
+ # pylint:disable=too-many-arguments, too-many-positional-arguments, too-many-locals, too-many-branches
51
+ def generate_wave_file(
52
+ shape: Literal["sine", "square", "sawtooth", "white_noise", "pink_noise"],
53
+ freq_list: List[float] = None,
54
+ sample_rate: int = 48000,
55
+ duration=10.0,
56
+ num_channels: int = 24,
57
+ active_channels=None,
58
+ amplitude: float = 0.05,
59
+ resolution_bits: int = 16,
60
+ output_dir: str = "generated_signal.wav",
61
+ ):
62
+ """Generate a multi-channel waveform and save it as a WAV file.
63
+
64
+ Creates a multi-channel audio file with one or more active channels containing
65
+ the specified waveform. Inactive channels contain silence (zeros). The function
66
+ generates the waveform based on shape, frequency, duration, and other parameters,
67
+ then encodes it as 32-bit PCM and saves to a WAV file.
68
+
69
+ :param shape: type of waveform to generate
70
+ :param freq_list: fundamental frequency in Hz for each channel
71
+ :param sample_rate: audio sample rate in Hz
72
+ :param duration: duration of the generated audio in seconds
73
+ :param num_channels: total number of channels in the output file
74
+ :param active_channels: list of channel indices to populate with signal;
75
+ if None, defaults to [0, 1, 2, 3]
76
+ :param amplitude: signal amplitude as a fraction of maximum (range: 0.0-1.0)
77
+ :param output_dir: path to output WAV file
78
+
79
+ :raises ValueError: if shape is not a supported waveform type
80
+
81
+ Note:
82
+ - Active channels must be within valid range [0, num_channels)
83
+ - Inactive channels contain silence
84
+ - Output is 32-bit PCM mono samples, saved as multi-channel WAV
85
+ """
86
+
87
+ logger.info(
88
+ "Generating audio file with %s shape, freq %f Hz, amplitude %.2f, %d sample rate,"
89
+ " %d duration, %d channels, %s active channels and %d bits audio resolution.",
90
+ shape,
91
+ freq_list,
92
+ amplitude,
93
+ sample_rate,
94
+ duration,
95
+ num_channels,
96
+ active_channels,
97
+ resolution_bits,
98
+ )
99
+ if active_channels is None:
100
+ active_channels = [0, 1, 2, 3]
101
+
102
+ if freq_list is None:
103
+ freq_list = [400]
104
+
105
+ if len(freq_list) != len(active_channels):
106
+ raise ValueError("Length of freq list must match length of active_channels")
107
+
108
+ n_samples = int(sample_rate * duration)
109
+ t = np.linspace(0, duration, n_samples)
110
+ data = np.zeros((n_samples, num_channels), dtype=np.float32)
111
+
112
+ for i, ch in enumerate(active_channels):
113
+ if 0 <= ch < num_channels:
114
+ current_freq = freq_list[i]
115
+
116
+ if shape == "sine":
117
+ signal = np.sin(2 * np.pi * current_freq * t)
118
+ elif shape == "square":
119
+ signal = np.sign(np.sin(2 * np.pi * current_freq * t))
120
+ elif shape == "sawtooth":
121
+ signal = 2 * (t * current_freq - np.floor(0.5 + t * current_freq))
122
+ elif shape == "white_noise":
123
+ signal = np.random.uniform(-1.0, 1.0, size=n_samples)
124
+ elif shape == "pink_noise":
125
+ num_rows = 16
126
+ array = np.random.randn(num_rows, n_samples)
127
+ array = np.cumsum(array, axis=1)
128
+ weights = 2.0 ** (-np.arange(num_rows))
129
+ signal = np.dot(weights, array)
130
+ signal /= np.max(np.abs(signal))
131
+ else:
132
+ raise ValueError(f"Unsupported shape: {shape}.")
133
+
134
+ signal = (signal * amplitude).astype(np.float32)
135
+ data[:, ch] = signal
136
+
137
+ raw_frames = _encode_pcm(data, resolution_bits)
138
+
139
+ # pylint:disable=no-member
140
+ with wave.open(output_dir, "wb") as wf:
141
+ wf.setnchannels(num_channels)
142
+ wf.setsampwidth(
143
+ resolution_bits // 8
144
+ ) # Pass resolution in bytes to set the sample width
145
+ wf.setframerate(sample_rate)
146
+ wf.writeframes(raw_frames)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ START_HZ = 100
151
+ STEP_HZ = 0
152
+ CHANNELS = 2
153
+ RESOLUTION_BITS = 16
154
+
155
+ generate_wave_file(
156
+ shape="sine",
157
+ freq_list=[START_HZ + i * STEP_HZ for i in range(CHANNELS)],
158
+ sample_rate=48000,
159
+ duration=10,
160
+ num_channels=CHANNELS,
161
+ active_channels=list(range(CHANNELS)),
162
+ amplitude=0.05,
163
+ resolution_bits=RESOLUTION_BITS,
164
+ output_dir=(
165
+ f"{CHANNELS}ch_{RESOLUTION_BITS}bit_freqs"
166
+ f"_start_{START_HZ}hz_step_{STEP_HZ}hz.wav"
167
+ ),
168
+ )
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: audio_validation
3
+ Version: 0.1.2
4
+ Summary: Tools for validating audio data, including feature extraction and visualization.
5
+ Maintainer-email: Hubert Stepniewski <hubert.stepniewski@int2code.com>, Marcin Tomiczek <marcin.tomiczek@int2code.com>, Piotr Sznapka <piotr.sznapka@int2code.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 int2code
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: numpy
32
+ Requires-Dist: scipy
33
+ Requires-Dist: matplotlib
34
+ Requires-Dist: pandas
35
+ Requires-Dist: pytest
36
+ Provides-Extra: test
37
+ Requires-Dist: coverage; extra == "test"
38
+ Requires-Dist: pytest; extra == "test"
39
+ Requires-Dist: pytest-cov; extra == "test"
40
+ Requires-Dist: pytest-html; extra == "test"
41
+ Provides-Extra: dev
42
+ Requires-Dist: coverage; extra == "dev"
43
+ Requires-Dist: pytest; extra == "dev"
44
+ Requires-Dist: pytest-cov; extra == "dev"
45
+ Requires-Dist: pytest-html; extra == "dev"
46
+ Requires-Dist: black; extra == "dev"
47
+ Requires-Dist: pylint; extra == "dev"
48
+ Requires-Dist: tox; extra == "dev"
49
+ Provides-Extra: docs
50
+ Requires-Dist: coverage; extra == "docs"
51
+ Requires-Dist: pytest; extra == "docs"
52
+ Requires-Dist: pytest-cov; extra == "docs"
53
+ Requires-Dist: pytest-html; extra == "docs"
54
+ Requires-Dist: sphinx; extra == "docs"
55
+ Requires-Dist: sphinx-rtd-theme; extra == "docs"
56
+ Requires-Dist: sphinxcontrib-programoutput; extra == "docs"
57
+ Requires-Dist: myst-parser; extra == "docs"
58
+ Dynamic: license-file
59
+
60
+ # audio_validation
61
+
62
+ A Python library for validating and analysing audio data, including feature extraction, bit-exact verification against a reference, and waveform generation utilities.
63
+
64
+ ## Features
65
+
66
+ - **Feature extraction** — compute RMS, peak/min/max, mean, FFT-based frequency detection, and per-channel audio onset offset for multi-channel captures.
67
+ - **Audio verification** — validate a captured audio stream against a reference WAV file with drift-tolerant re-synchronisation, detailed mismatch reporting, and automatic artefact generation (PNG plots and WAV snippets).
68
+ - **Waveform generation** — generate multi-channel WAV files with configurable waveforms: sine, square, sawtooth, white noise, and pink noise; supports 16-, 24-, and 32-bit PCM output.
69
+
70
+ ## Requirements
71
+
72
+ - Python ≥ 3.11
73
+ - See [requirements.txt](requirements.txt) for runtime dependencies (`numpy`, `scipy`, `sounddevice`, `pymodbus`, …).
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ pip install audio_validation
79
+ ```
80
+
81
+ Or, for development:
82
+
83
+ ```bash
84
+ pip install -e ".[dev]"
85
+ ```
86
+
87
+ ## Usage
88
+
89
+ ### Feature extraction
90
+
91
+ ```python
92
+ from audio_validation.audio_features import AudioFeatures
93
+
94
+ features = AudioFeatures.compute(
95
+ samples=raw_samples, # numpy array, shape (n_samples, n_channels)
96
+ sample_rate=48000,
97
+ expected_frequencies=[400, 800],
98
+ tolerance=50,
99
+ )
100
+
101
+ for ch_idx, ch in enumerate(features.channels):
102
+ print(f"Ch {ch_idx}: detected={ch.detected}, rms={ch.rms:.4f}, peaks={ch.peak_frequencies}")
103
+ ```
104
+
105
+ ### Audio verification
106
+
107
+ ```python
108
+ from audio_validation.audio_verification import verify_audio
109
+
110
+ results = verify_audio(
111
+ reference_path="reference.wav",
112
+ detected_samples=captured_array,
113
+ sample_rate=48000,
114
+ artifacts_dir="test_artifacts/",
115
+ )
116
+ ```
117
+
118
+ ### Waveform generation
119
+
120
+ ```python
121
+ from audio_validation.utils.audio_generation import generate_wave_file
122
+
123
+ generate_wave_file(
124
+ shape="sine",
125
+ freq_list=[1000.0],
126
+ sample_rate=48000,
127
+ duration=5.0,
128
+ num_channels=2,
129
+ active_channels=[0, 1],
130
+ amplitude=0.05,
131
+ resolution_bits=16,
132
+ output_dir="output.wav",
133
+ )
134
+ ```
135
+
136
+ ## Maintainers
137
+
138
+ - Hubert Stepniewski — hubert.stepniewski@int2code.com
139
+ - Marcin Tomiczek — marcin.tomiczek@int2code.com
140
+ - Piotr Sznapka — piotr.sznapka@int2code.com
141
+
142
+ ## License
143
+
144
+ See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ audio_validation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ audio_validation/_version.py,sha256=Q6ScJXlkBsUI-cazSvpYPMyIVUB-Vd0R3pq3_rM1wmI,520
3
+ audio_validation/audio_features.py,sha256=Saf9v7agorOy5Mw5eEGdlRfkZAuUkihMUMMMR-vOAT0,17466
4
+ audio_validation/audio_verification.py,sha256=wLihZDo1Qj0iOwHNCYHkXeYlsZwdywD_yMWFr9Bm_jM,20714
5
+ audio_validation/utils/audio_generation.py,sha256=1-SxE3FKyp4A1nt_BNmeggUh4ReDy2_y7EVpo4owWP8,6210
6
+ audio_validation-0.1.2.dist-info/licenses/LICENSE,sha256=e9JsLl_zrw1-Gs4lwFc1MMu71eiUUvxJhsX6DDRlOZQ,1065
7
+ audio_validation-0.1.2.dist-info/METADATA,sha256=WAdq3ClwZE6-JY1n95cAfY8uhaKPa4HvlcmzDFfmPcM,4935
8
+ audio_validation-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ audio_validation-0.1.2.dist-info/top_level.txt,sha256=4x0c2_XfHaGP84D8ehLRf_wD3lT12oDaU4adXTJkgTU,17
10
+ audio_validation-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 int2code
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ audio_validation