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.
- audio_validation/__init__.py +0 -0
- audio_validation/_version.py +24 -0
- audio_validation/audio_features.py +430 -0
- audio_validation/audio_verification.py +567 -0
- audio_validation/utils/audio_generation.py +168 -0
- audio_validation-0.1.2.dist-info/METADATA +144 -0
- audio_validation-0.1.2.dist-info/RECORD +10 -0
- audio_validation-0.1.2.dist-info/WHEEL +5 -0
- audio_validation-0.1.2.dist-info/licenses/LICENSE +21 -0
- audio_validation-0.1.2.dist-info/top_level.txt +1 -0
|
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,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
|