audio-anomaly 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audio_anomaly-0.1.0/.gitignore +30 -0
- audio_anomaly-0.1.0/LICENSE +21 -0
- audio_anomaly-0.1.0/PKG-INFO +255 -0
- audio_anomaly-0.1.0/README.md +229 -0
- audio_anomaly-0.1.0/pyproject.toml +53 -0
- audio_anomaly-0.1.0/src/audio_anomaly/__init__.py +32 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_detect.py +1465 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_io.py +400 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_report.py +247 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_runs.py +44 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_spectra.py +409 -0
- audio_anomaly-0.1.0/src/audio_anomaly/_wav.py +321 -0
- audio_anomaly-0.1.0/src/audio_anomaly/cli.py +170 -0
- audio_anomaly-0.1.0/tests/_signals.py +99 -0
- audio_anomaly-0.1.0/tests/test_cli.py +147 -0
- audio_anomaly-0.1.0/tests/test_detect.py +194 -0
- audio_anomaly-0.1.0/tests/test_edges.py +294 -0
- audio_anomaly-0.1.0/tests/test_profile_monitor.py +111 -0
- audio_anomaly-0.1.0/tests/test_reference_mode.py +252 -0
- audio_anomaly-0.1.0/tests/test_report.py +113 -0
- audio_anomaly-0.1.0/tests/test_wav.py +175 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Virtual environments and scratch space used while building. Never committed:
|
|
2
|
+
# they are large, machine-specific, and rebuilt from pyproject.toml anyway.
|
|
3
|
+
_venvs/
|
|
4
|
+
_proof/
|
|
5
|
+
.venv*/
|
|
6
|
+
|
|
7
|
+
# Build output. Wheels are built by the release workflow from this source, so a
|
|
8
|
+
# wheel in git could silently differ from the code beside it.
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
src/*.egg-info/
|
|
13
|
+
|
|
14
|
+
# Python noise
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Local verification state, not source
|
|
22
|
+
verify_results.json
|
|
23
|
+
publish-log.txt
|
|
24
|
+
published.json
|
|
25
|
+
|
|
26
|
+
# Credentials. None of these belong here, and this line is the backstop.
|
|
27
|
+
.pypirc
|
|
28
|
+
.env
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
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,255 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: audio-anomaly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect unusual sounds in machine or environment recordings without labelled faults
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/audio-anomaly/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: acoustic-monitoring,anomaly-detection,audio-anomaly,condition-monitoring,numpy,predictive-maintenance,sound-event-detection,spectrum,unsupervised,wav
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Manufacturing
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: numpy>=1.23
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# audio-anomaly
|
|
28
|
+
|
|
29
|
+
Find the knock, the new whine, the dropout or the clipping in a machine or
|
|
30
|
+
environment recording without any labelled faults, and without flagging the
|
|
31
|
+
healthy hum that makes up the rest of it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install audio-anomaly
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import numpy as np, audio_anomaly
|
|
43
|
+
|
|
44
|
+
t = np.arange(4 * 16000) / 16000 # 4 s of machine hum at 16 kHz
|
|
45
|
+
hum = 0.3 * np.sin(2 * np.pi * 120 * t) + 0.01 * np.random.default_rng(0).standard_normal(t.size)
|
|
46
|
+
hum[32000:32480] += np.random.default_rng(1).standard_normal(480) * np.exp(-np.arange(480) / 60) # a knock at 2 s
|
|
47
|
+
print(audio_anomaly.detect(hum, sample_rate=16000).summary())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
audio-anomaly: 1 anomaly in the audio (4.00 s at 16000 Hz)
|
|
52
|
+
Normal means: the recording's own typical sound (no reference given)
|
|
53
|
+
Flagged 0.2% of the recording (1 burst); highest frame score 14.9, threshold 3.0.
|
|
54
|
+
|
|
55
|
+
Anomalies, in time order:
|
|
56
|
+
1. burst at 2.000-2.010 s, score 14.9
|
|
57
|
+
Broadband burst (an impact, knock or click) at 2.000 s lasting 0.010 s: 29.8 dB above the surrounding sound in 27 of 28 bands.
|
|
58
|
+
|
|
59
|
+
What the kinds mean:
|
|
60
|
+
burst a short broadband sound: an impact, knock, click or rattle
|
|
61
|
+
|
|
62
|
+
Notes:
|
|
63
|
+
- at 16000 Hz the frequency bands reach up to 7.09 kHz; sound between that and the 8.00 kHz Nyquist limit is not monitored, so record at a higher sample rate to watch higher frequencies
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Take the knock out and the same call answers `no anomalies`, with the highest
|
|
67
|
+
frame score it saw (1.6) against the threshold (3.0), so you can see how
|
|
68
|
+
much headroom a healthy recording had.
|
|
69
|
+
|
|
70
|
+
With a known-good recording of the same machine, every frame is judged against
|
|
71
|
+
that instead of against the recording itself:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
report = audio_anomaly.detect("pump_today.wav", reference="pump_healthy.wav")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## What it does
|
|
78
|
+
|
|
79
|
+
Each recording is cut into half-overlapping frames (50 ms by default), and each
|
|
80
|
+
frame into up to 32 log-spaced frequency bands from 30 Hz to 12 kHz. Every band
|
|
81
|
+
level is the power *in* that band in dBFS, so a tone reads the same at any
|
|
82
|
+
sample rate. The top band has to end below 95% of the Nyquist frequency, so at
|
|
83
|
+
lower sample rates the bands stop sooner: with 50 ms frames they reach 3.67 kHz
|
|
84
|
+
at 8 kHz, 7.09 kHz at 16 kHz and 9.23 kHz at 22.05 kHz, and full 12 kHz from
|
|
85
|
+
32 kHz up. Nothing above that is monitored, and the report says so in a note.
|
|
86
|
+
Then it looks for:
|
|
87
|
+
|
|
88
|
+
- **`burst`** - a short broadband sound: an impact, knock, click or rattle. Each
|
|
89
|
+
frame is compared with the sound just before *and* just after it, so a knock
|
|
90
|
+
stands out wherever it happens, and the first frames of a change that then
|
|
91
|
+
persists are not mistaken for one. Burst edges are refined to the millisecond.
|
|
92
|
+
Given a reference recording of a machine that makes impacts of its own (a
|
|
93
|
+
press, a reciprocating pump), their typical strength and how much it varies
|
|
94
|
+
are learned from it band by band (it needs at least three), and a burst is
|
|
95
|
+
then reported only when it clearly exceeds them. A saved profile holds only
|
|
96
|
+
the spectrum, so for such machines pass the reference recording itself.
|
|
97
|
+
- **`tonal`** - narrowband energy that was not there before: a bearing whine, a
|
|
98
|
+
squeal, a new hum. The band levels are smoothed over 0.6 s, which a knock
|
|
99
|
+
cannot move, and compared with normal. A change has to hold at or above the
|
|
100
|
+
threshold for 0.3 s, and then lasts while it stays above three quarters of
|
|
101
|
+
it, so a band hovering at the threshold does not flicker in and out. A tone
|
|
102
|
+
is a band standing out from the rest, so it is judged above any rise the
|
|
103
|
+
whole spectrum shares: a gain change or a harder-working machine is a level
|
|
104
|
+
change, never a tone. The report names the frequency, always inside the band
|
|
105
|
+
it quotes.
|
|
106
|
+
- **`level_rise`** - the sound got louder across much of the spectrum and stayed
|
|
107
|
+
louder: a machine running rough. It must hold, like a tone.
|
|
108
|
+
- **`level_drop`** - the overall level fell well below normal, again held with
|
|
109
|
+
the same hysteresis.
|
|
110
|
+
- **`dropout`** - the signal cut out: digital silence in the middle of the
|
|
111
|
+
sound, or runs of exact zeros as short as 5 ms that no frame average would
|
|
112
|
+
notice.
|
|
113
|
+
- **`clipping`** - samples flattened against the full-scale rail, measured per
|
|
114
|
+
channel before the mix to mono and against the format's own rail: +127/128 for
|
|
115
|
+
8-bit WAV, +32767/32768 for 16-bit, 1.0 for float. (A fixed 0.995 cut-off would
|
|
116
|
+
never see 8-bit clipping at all, since 127/128 = 0.992.)
|
|
117
|
+
|
|
118
|
+
**What "normal" is.** Given a `reference`, it is the reference's typical
|
|
119
|
+
spectrum, band by band. Without one, it is learned from the recording itself:
|
|
120
|
+
the *quieter three quarters* of each band. That is why a whine that appears
|
|
121
|
+
halfway through is still caught with no reference: the normal level of its band
|
|
122
|
+
is set by the half without it. A change that covers more than about three
|
|
123
|
+
quarters of a recording is, by then, its typical sound - give a reference for a
|
|
124
|
+
machine that may already be faulty.
|
|
125
|
+
|
|
126
|
+
**Why healthy machinery stays quiet.** Every score is a robust z-score (median
|
|
127
|
+
and MAD, never mean and standard deviation) *and* must be backed by at least
|
|
128
|
+
2 dB of real change per unit, so `sensitivity=3.0` means "3 sigma and 6 dB". A
|
|
129
|
+
perfectly steady hum barely wobbles, so its sigma is tiny; without the dB floor a
|
|
130
|
+
0.3 dB flutter would read as a ten-sigma event. Spreads are measured in ways the
|
|
131
|
+
anomalies themselves cannot inflate, and widened by their own uncertainty when a
|
|
132
|
+
recording is short. Across 700 synthetic healthy machines (8-44.1 kHz, 1-10 s,
|
|
133
|
+
50-900 Hz hum, three noise levels, slow amplitude wobble, pink noise, 25-100 ms
|
|
134
|
+
frames), each judged on its own and against a second healthy recording of the
|
|
135
|
+
same machine, the highest score without a reference was 2.4; against a
|
|
136
|
+
reference, one run in 1,400 crossed the threshold (3.2, in the lowest band of a
|
|
137
|
+
pink-noise recording). Against a reference, a whole-spectrum gain change of up
|
|
138
|
+
to 5 dB raises nothing but a note, and a larger one is a single level change
|
|
139
|
+
that the note explains.
|
|
140
|
+
|
|
141
|
+
**The awkward cases.** Pure silence reports no anomalies (and says it is
|
|
142
|
+
silent) rather than flagging everything. A recording shorter than one frame
|
|
143
|
+
returns an empty report. Leading and trailing silence is noted, not called a
|
|
144
|
+
dropout. Stereo is mixed to mono. NaN samples are read as silence and counted. A
|
|
145
|
+
reference of a different length or sample rate is fine: only its typical
|
|
146
|
+
spectrum is used, mapped onto this recording's bands, and bands it cannot reach
|
|
147
|
+
are judged against the recording itself - the notes say which. A recording 3 dB
|
|
148
|
+
or more louder or quieter than its reference across the whole spectrum gets a
|
|
149
|
+
note, since that is usually the microphone, its gain or its position rather
|
|
150
|
+
than the machine. A WAV file that was cut short is analysed as far as it goes,
|
|
151
|
+
with a warning giving the length its header promised; one whose RIFF size field
|
|
152
|
+
is wrong is read chunk by chunk; a damaged one raises a `ValueError` naming the
|
|
153
|
+
file. Your array is never modified, and the same input always gives the same
|
|
154
|
+
report.
|
|
155
|
+
|
|
156
|
+
Only numpy is required: the FFT is `numpy.fft`, and WAV files are read with the
|
|
157
|
+
standard library (`wave`, plus a small reader for the float files `wave`
|
|
158
|
+
refuses).
|
|
159
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
### `detect(audio, *, sample_rate=None, reference=None, sensitivity=3.0, frame_ms=50) -> AudioAnomalyReport`
|
|
163
|
+
|
|
164
|
+
| argument | meaning |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `audio` | a `.wav` path, a numpy array (`(n,)` or `(n, channels)`), or `(samples, sample_rate)` |
|
|
167
|
+
| `sample_rate` | samples per second; required for a bare array |
|
|
168
|
+
| `reference` | a known-good recording in any of those forms, or a profile from `spectral_profile` |
|
|
169
|
+
| `sensitivity` | score a frame must reach to be anomalous; higher flags less. Try 4-5 for noisy environments |
|
|
170
|
+
| `frame_ms` | analysis frame length in milliseconds; frames overlap by half |
|
|
171
|
+
|
|
172
|
+
### `AudioAnomalyReport`
|
|
173
|
+
|
|
174
|
+
| member | what it gives you |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `.anomalies` | `list[Event]` in time order |
|
|
177
|
+
| `.scores` | numpy array, one score per frame; at or above `sensitivity` is anomalous |
|
|
178
|
+
| `.frame_times` | the centre of each frame in seconds, aligned with `.scores` |
|
|
179
|
+
| `.anomaly_ratio` | share of the recording's duration covered by anomalies, 0-1 |
|
|
180
|
+
| `.loudest(n=5)` | the `n` highest-scoring events, strongest first |
|
|
181
|
+
| `.counts`, `.of_kind(kind)`, `.has_anomalies`, `.max_score` | at a glance |
|
|
182
|
+
| `.summary()` | the human report, plain ASCII punctuation, safe to pipe anywhere |
|
|
183
|
+
| `.to_dict()` | JSON-safe dict of everything |
|
|
184
|
+
| `.notes`, `.warnings` | what was assumed, converted or fallen back to |
|
|
185
|
+
| `.mode`, `.compared_against` | `"self"` or `"reference"`, and what normal meant, in words |
|
|
186
|
+
|
|
187
|
+
`Event(start_s, end_s, kind, score, message)` is a frozen dataclass; `message`
|
|
188
|
+
is one plain sentence, for example *"New tone near 3.10 kHz from 1.98 s to the
|
|
189
|
+
end: 24.1 dB above normal in the 2.81 kHz-3.21 kHz band."*
|
|
190
|
+
|
|
191
|
+
### `Monitor(reference, *, sample_rate=None, sensitivity=3.0, frame_ms=50).check(audio) -> AudioAnomalyReport`
|
|
192
|
+
|
|
193
|
+
The reference is analysed once; each `check` compares a new recording with it.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
monitor = audio_anomaly.Monitor("pump_healthy.wav")
|
|
197
|
+
for path in todays_recordings:
|
|
198
|
+
report = monitor.check(path)
|
|
199
|
+
if report.has_anomalies:
|
|
200
|
+
print(path, report.loudest(1)[0])
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `spectral_profile(audio, *, sample_rate=None, frame_ms=50) -> numpy.ndarray`
|
|
204
|
+
|
|
205
|
+
The typical spectrum of a recording: an `(n_bands, 3)` array with columns
|
|
206
|
+
`frequency_hz`, `level_db` (the average spectrum, dBFS) and `spread_db` (how
|
|
207
|
+
much each band wanders). Save it and pass it back as `reference` later; a
|
|
208
|
+
profile made with a different `frame_ms` is recognised and its own frame length
|
|
209
|
+
is used, with a note.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
np.save("pump.npy", audio_anomaly.spectral_profile("pump_healthy.wav"))
|
|
213
|
+
report = audio_anomaly.detect("pump_today.wav", reference=np.load("pump.npy"))
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## CLI
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
audio-anomaly pump.wav
|
|
220
|
+
audio-anomaly pump_today.wav --reference pump_healthy.wav
|
|
221
|
+
audio-anomaly pump_healthy.wav --save-profile pump.npy
|
|
222
|
+
audio-anomaly pump_today.wav --reference pump.npy --sensitivity 4
|
|
223
|
+
audio-anomaly pump.wav --json > findings.json
|
|
224
|
+
audio-anomaly pump.wav --output findings.json --fail-on-anomaly
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`--save-profile` writes `.npy`, or `.csv` for any other extension, and
|
|
228
|
+
`--reference` reads either back. `--fail-on-anomaly` exits with status 1 when
|
|
229
|
+
anything was found, for scripts and scheduled checks. Any failure - an
|
|
230
|
+
unreadable or damaged recording, an `--output` path that cannot be written -
|
|
231
|
+
prints `audio-anomaly: error: ...` and exits with status 2, never 1, so a
|
|
232
|
+
scheduled check cannot mistake a failure for a finding. Run
|
|
233
|
+
`audio-anomaly --help` for the rest.
|
|
234
|
+
|
|
235
|
+
## Limits
|
|
236
|
+
|
|
237
|
+
- **Level drops that recur at a stable period are read as beating, not independent
|
|
238
|
+
faults.** Two close frequencies (two motors just out of sync, say) produce a level
|
|
239
|
+
that dips to near-zero on a completely regular cycle, and each dip looks identical
|
|
240
|
+
to a real drop from a local, before/after comparison - even against a clean
|
|
241
|
+
reference of the same machine, since that comparison is inherently local. Three or
|
|
242
|
+
more drops at a near-constant interval and depth are reported once, as a note
|
|
243
|
+
describing the period, rather than as repeated alarms.
|
|
244
|
+
- **This means a genuinely periodic mechanical fault - a loose part striking on every
|
|
245
|
+
rotation, say - can look the same as beating and gets the same treatment: reported
|
|
246
|
+
once as a note, not as repeated anomalies.** Nothing in a level trace alone can
|
|
247
|
+
tell physical beating apart from a real fault that happens to recur at a stable
|
|
248
|
+
interval; that needs either a healthy reference recorded at the *same* speed and
|
|
249
|
+
load, or knowledge of the machine's own RPM. The note is never suppressed, so a
|
|
250
|
+
recurring pattern is always visible in the report - it is just not escalated to N
|
|
251
|
+
separate alarms.
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# audio-anomaly
|
|
2
|
+
|
|
3
|
+
Find the knock, the new whine, the dropout or the clipping in a machine or
|
|
4
|
+
environment recording without any labelled faults, and without flagging the
|
|
5
|
+
healthy hum that makes up the rest of it.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install audio-anomaly
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import numpy as np, audio_anomaly
|
|
17
|
+
|
|
18
|
+
t = np.arange(4 * 16000) / 16000 # 4 s of machine hum at 16 kHz
|
|
19
|
+
hum = 0.3 * np.sin(2 * np.pi * 120 * t) + 0.01 * np.random.default_rng(0).standard_normal(t.size)
|
|
20
|
+
hum[32000:32480] += np.random.default_rng(1).standard_normal(480) * np.exp(-np.arange(480) / 60) # a knock at 2 s
|
|
21
|
+
print(audio_anomaly.detect(hum, sample_rate=16000).summary())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
audio-anomaly: 1 anomaly in the audio (4.00 s at 16000 Hz)
|
|
26
|
+
Normal means: the recording's own typical sound (no reference given)
|
|
27
|
+
Flagged 0.2% of the recording (1 burst); highest frame score 14.9, threshold 3.0.
|
|
28
|
+
|
|
29
|
+
Anomalies, in time order:
|
|
30
|
+
1. burst at 2.000-2.010 s, score 14.9
|
|
31
|
+
Broadband burst (an impact, knock or click) at 2.000 s lasting 0.010 s: 29.8 dB above the surrounding sound in 27 of 28 bands.
|
|
32
|
+
|
|
33
|
+
What the kinds mean:
|
|
34
|
+
burst a short broadband sound: an impact, knock, click or rattle
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
- at 16000 Hz the frequency bands reach up to 7.09 kHz; sound between that and the 8.00 kHz Nyquist limit is not monitored, so record at a higher sample rate to watch higher frequencies
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Take the knock out and the same call answers `no anomalies`, with the highest
|
|
41
|
+
frame score it saw (1.6) against the threshold (3.0), so you can see how
|
|
42
|
+
much headroom a healthy recording had.
|
|
43
|
+
|
|
44
|
+
With a known-good recording of the same machine, every frame is judged against
|
|
45
|
+
that instead of against the recording itself:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
report = audio_anomaly.detect("pump_today.wav", reference="pump_healthy.wav")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What it does
|
|
52
|
+
|
|
53
|
+
Each recording is cut into half-overlapping frames (50 ms by default), and each
|
|
54
|
+
frame into up to 32 log-spaced frequency bands from 30 Hz to 12 kHz. Every band
|
|
55
|
+
level is the power *in* that band in dBFS, so a tone reads the same at any
|
|
56
|
+
sample rate. The top band has to end below 95% of the Nyquist frequency, so at
|
|
57
|
+
lower sample rates the bands stop sooner: with 50 ms frames they reach 3.67 kHz
|
|
58
|
+
at 8 kHz, 7.09 kHz at 16 kHz and 9.23 kHz at 22.05 kHz, and full 12 kHz from
|
|
59
|
+
32 kHz up. Nothing above that is monitored, and the report says so in a note.
|
|
60
|
+
Then it looks for:
|
|
61
|
+
|
|
62
|
+
- **`burst`** - a short broadband sound: an impact, knock, click or rattle. Each
|
|
63
|
+
frame is compared with the sound just before *and* just after it, so a knock
|
|
64
|
+
stands out wherever it happens, and the first frames of a change that then
|
|
65
|
+
persists are not mistaken for one. Burst edges are refined to the millisecond.
|
|
66
|
+
Given a reference recording of a machine that makes impacts of its own (a
|
|
67
|
+
press, a reciprocating pump), their typical strength and how much it varies
|
|
68
|
+
are learned from it band by band (it needs at least three), and a burst is
|
|
69
|
+
then reported only when it clearly exceeds them. A saved profile holds only
|
|
70
|
+
the spectrum, so for such machines pass the reference recording itself.
|
|
71
|
+
- **`tonal`** - narrowband energy that was not there before: a bearing whine, a
|
|
72
|
+
squeal, a new hum. The band levels are smoothed over 0.6 s, which a knock
|
|
73
|
+
cannot move, and compared with normal. A change has to hold at or above the
|
|
74
|
+
threshold for 0.3 s, and then lasts while it stays above three quarters of
|
|
75
|
+
it, so a band hovering at the threshold does not flicker in and out. A tone
|
|
76
|
+
is a band standing out from the rest, so it is judged above any rise the
|
|
77
|
+
whole spectrum shares: a gain change or a harder-working machine is a level
|
|
78
|
+
change, never a tone. The report names the frequency, always inside the band
|
|
79
|
+
it quotes.
|
|
80
|
+
- **`level_rise`** - the sound got louder across much of the spectrum and stayed
|
|
81
|
+
louder: a machine running rough. It must hold, like a tone.
|
|
82
|
+
- **`level_drop`** - the overall level fell well below normal, again held with
|
|
83
|
+
the same hysteresis.
|
|
84
|
+
- **`dropout`** - the signal cut out: digital silence in the middle of the
|
|
85
|
+
sound, or runs of exact zeros as short as 5 ms that no frame average would
|
|
86
|
+
notice.
|
|
87
|
+
- **`clipping`** - samples flattened against the full-scale rail, measured per
|
|
88
|
+
channel before the mix to mono and against the format's own rail: +127/128 for
|
|
89
|
+
8-bit WAV, +32767/32768 for 16-bit, 1.0 for float. (A fixed 0.995 cut-off would
|
|
90
|
+
never see 8-bit clipping at all, since 127/128 = 0.992.)
|
|
91
|
+
|
|
92
|
+
**What "normal" is.** Given a `reference`, it is the reference's typical
|
|
93
|
+
spectrum, band by band. Without one, it is learned from the recording itself:
|
|
94
|
+
the *quieter three quarters* of each band. That is why a whine that appears
|
|
95
|
+
halfway through is still caught with no reference: the normal level of its band
|
|
96
|
+
is set by the half without it. A change that covers more than about three
|
|
97
|
+
quarters of a recording is, by then, its typical sound - give a reference for a
|
|
98
|
+
machine that may already be faulty.
|
|
99
|
+
|
|
100
|
+
**Why healthy machinery stays quiet.** Every score is a robust z-score (median
|
|
101
|
+
and MAD, never mean and standard deviation) *and* must be backed by at least
|
|
102
|
+
2 dB of real change per unit, so `sensitivity=3.0` means "3 sigma and 6 dB". A
|
|
103
|
+
perfectly steady hum barely wobbles, so its sigma is tiny; without the dB floor a
|
|
104
|
+
0.3 dB flutter would read as a ten-sigma event. Spreads are measured in ways the
|
|
105
|
+
anomalies themselves cannot inflate, and widened by their own uncertainty when a
|
|
106
|
+
recording is short. Across 700 synthetic healthy machines (8-44.1 kHz, 1-10 s,
|
|
107
|
+
50-900 Hz hum, three noise levels, slow amplitude wobble, pink noise, 25-100 ms
|
|
108
|
+
frames), each judged on its own and against a second healthy recording of the
|
|
109
|
+
same machine, the highest score without a reference was 2.4; against a
|
|
110
|
+
reference, one run in 1,400 crossed the threshold (3.2, in the lowest band of a
|
|
111
|
+
pink-noise recording). Against a reference, a whole-spectrum gain change of up
|
|
112
|
+
to 5 dB raises nothing but a note, and a larger one is a single level change
|
|
113
|
+
that the note explains.
|
|
114
|
+
|
|
115
|
+
**The awkward cases.** Pure silence reports no anomalies (and says it is
|
|
116
|
+
silent) rather than flagging everything. A recording shorter than one frame
|
|
117
|
+
returns an empty report. Leading and trailing silence is noted, not called a
|
|
118
|
+
dropout. Stereo is mixed to mono. NaN samples are read as silence and counted. A
|
|
119
|
+
reference of a different length or sample rate is fine: only its typical
|
|
120
|
+
spectrum is used, mapped onto this recording's bands, and bands it cannot reach
|
|
121
|
+
are judged against the recording itself - the notes say which. A recording 3 dB
|
|
122
|
+
or more louder or quieter than its reference across the whole spectrum gets a
|
|
123
|
+
note, since that is usually the microphone, its gain or its position rather
|
|
124
|
+
than the machine. A WAV file that was cut short is analysed as far as it goes,
|
|
125
|
+
with a warning giving the length its header promised; one whose RIFF size field
|
|
126
|
+
is wrong is read chunk by chunk; a damaged one raises a `ValueError` naming the
|
|
127
|
+
file. Your array is never modified, and the same input always gives the same
|
|
128
|
+
report.
|
|
129
|
+
|
|
130
|
+
Only numpy is required: the FFT is `numpy.fft`, and WAV files are read with the
|
|
131
|
+
standard library (`wave`, plus a small reader for the float files `wave`
|
|
132
|
+
refuses).
|
|
133
|
+
|
|
134
|
+
## API
|
|
135
|
+
|
|
136
|
+
### `detect(audio, *, sample_rate=None, reference=None, sensitivity=3.0, frame_ms=50) -> AudioAnomalyReport`
|
|
137
|
+
|
|
138
|
+
| argument | meaning |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `audio` | a `.wav` path, a numpy array (`(n,)` or `(n, channels)`), or `(samples, sample_rate)` |
|
|
141
|
+
| `sample_rate` | samples per second; required for a bare array |
|
|
142
|
+
| `reference` | a known-good recording in any of those forms, or a profile from `spectral_profile` |
|
|
143
|
+
| `sensitivity` | score a frame must reach to be anomalous; higher flags less. Try 4-5 for noisy environments |
|
|
144
|
+
| `frame_ms` | analysis frame length in milliseconds; frames overlap by half |
|
|
145
|
+
|
|
146
|
+
### `AudioAnomalyReport`
|
|
147
|
+
|
|
148
|
+
| member | what it gives you |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `.anomalies` | `list[Event]` in time order |
|
|
151
|
+
| `.scores` | numpy array, one score per frame; at or above `sensitivity` is anomalous |
|
|
152
|
+
| `.frame_times` | the centre of each frame in seconds, aligned with `.scores` |
|
|
153
|
+
| `.anomaly_ratio` | share of the recording's duration covered by anomalies, 0-1 |
|
|
154
|
+
| `.loudest(n=5)` | the `n` highest-scoring events, strongest first |
|
|
155
|
+
| `.counts`, `.of_kind(kind)`, `.has_anomalies`, `.max_score` | at a glance |
|
|
156
|
+
| `.summary()` | the human report, plain ASCII punctuation, safe to pipe anywhere |
|
|
157
|
+
| `.to_dict()` | JSON-safe dict of everything |
|
|
158
|
+
| `.notes`, `.warnings` | what was assumed, converted or fallen back to |
|
|
159
|
+
| `.mode`, `.compared_against` | `"self"` or `"reference"`, and what normal meant, in words |
|
|
160
|
+
|
|
161
|
+
`Event(start_s, end_s, kind, score, message)` is a frozen dataclass; `message`
|
|
162
|
+
is one plain sentence, for example *"New tone near 3.10 kHz from 1.98 s to the
|
|
163
|
+
end: 24.1 dB above normal in the 2.81 kHz-3.21 kHz band."*
|
|
164
|
+
|
|
165
|
+
### `Monitor(reference, *, sample_rate=None, sensitivity=3.0, frame_ms=50).check(audio) -> AudioAnomalyReport`
|
|
166
|
+
|
|
167
|
+
The reference is analysed once; each `check` compares a new recording with it.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
monitor = audio_anomaly.Monitor("pump_healthy.wav")
|
|
171
|
+
for path in todays_recordings:
|
|
172
|
+
report = monitor.check(path)
|
|
173
|
+
if report.has_anomalies:
|
|
174
|
+
print(path, report.loudest(1)[0])
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `spectral_profile(audio, *, sample_rate=None, frame_ms=50) -> numpy.ndarray`
|
|
178
|
+
|
|
179
|
+
The typical spectrum of a recording: an `(n_bands, 3)` array with columns
|
|
180
|
+
`frequency_hz`, `level_db` (the average spectrum, dBFS) and `spread_db` (how
|
|
181
|
+
much each band wanders). Save it and pass it back as `reference` later; a
|
|
182
|
+
profile made with a different `frame_ms` is recognised and its own frame length
|
|
183
|
+
is used, with a note.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
np.save("pump.npy", audio_anomaly.spectral_profile("pump_healthy.wav"))
|
|
187
|
+
report = audio_anomaly.detect("pump_today.wav", reference=np.load("pump.npy"))
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## CLI
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
audio-anomaly pump.wav
|
|
194
|
+
audio-anomaly pump_today.wav --reference pump_healthy.wav
|
|
195
|
+
audio-anomaly pump_healthy.wav --save-profile pump.npy
|
|
196
|
+
audio-anomaly pump_today.wav --reference pump.npy --sensitivity 4
|
|
197
|
+
audio-anomaly pump.wav --json > findings.json
|
|
198
|
+
audio-anomaly pump.wav --output findings.json --fail-on-anomaly
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`--save-profile` writes `.npy`, or `.csv` for any other extension, and
|
|
202
|
+
`--reference` reads either back. `--fail-on-anomaly` exits with status 1 when
|
|
203
|
+
anything was found, for scripts and scheduled checks. Any failure - an
|
|
204
|
+
unreadable or damaged recording, an `--output` path that cannot be written -
|
|
205
|
+
prints `audio-anomaly: error: ...` and exits with status 2, never 1, so a
|
|
206
|
+
scheduled check cannot mistake a failure for a finding. Run
|
|
207
|
+
`audio-anomaly --help` for the rest.
|
|
208
|
+
|
|
209
|
+
## Limits
|
|
210
|
+
|
|
211
|
+
- **Level drops that recur at a stable period are read as beating, not independent
|
|
212
|
+
faults.** Two close frequencies (two motors just out of sync, say) produce a level
|
|
213
|
+
that dips to near-zero on a completely regular cycle, and each dip looks identical
|
|
214
|
+
to a real drop from a local, before/after comparison - even against a clean
|
|
215
|
+
reference of the same machine, since that comparison is inherently local. Three or
|
|
216
|
+
more drops at a near-constant interval and depth are reported once, as a note
|
|
217
|
+
describing the period, rather than as repeated alarms.
|
|
218
|
+
- **This means a genuinely periodic mechanical fault - a loose part striking on every
|
|
219
|
+
rotation, say - can look the same as beating and gets the same treatment: reported
|
|
220
|
+
once as a note, not as repeated anomalies.** Nothing in a level trace alone can
|
|
221
|
+
tell physical beating apart from a real fault that happens to recur at a stable
|
|
222
|
+
interval; that needs either a healthy reference recorded at the *same* speed and
|
|
223
|
+
load, or knowledge of the machine's own RPM. The note is never suppressed, so a
|
|
224
|
+
recurring pattern is always visible in the report - it is just not escalated to N
|
|
225
|
+
separate alarms.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audio-anomaly"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Detect unusual sounds in machine or environment recordings without labelled faults"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"audio-anomaly",
|
|
16
|
+
"anomaly-detection",
|
|
17
|
+
"acoustic-monitoring",
|
|
18
|
+
"condition-monitoring",
|
|
19
|
+
"predictive-maintenance",
|
|
20
|
+
"sound-event-detection",
|
|
21
|
+
"unsupervised",
|
|
22
|
+
"spectrum",
|
|
23
|
+
"wav",
|
|
24
|
+
"numpy",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Intended Audience :: Science/Research",
|
|
30
|
+
"Intended Audience :: Manufacturing",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
33
|
+
"Operating System :: OS Independent",
|
|
34
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
35
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
36
|
+
"Topic :: System :: Monitoring",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"numpy>=1.23",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = ["pytest>=7"]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
audio-anomaly = "audio_anomaly.cli:main"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://pypi.org/project/audio-anomaly/"
|
|
50
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/audio_anomaly"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""audio-anomaly: detect unusual sounds in machine or environment recordings without labelled faults.
|
|
2
|
+
|
|
3
|
+
The 90% case is one line::
|
|
4
|
+
|
|
5
|
+
report = audio_anomaly.detect("pump.wav")
|
|
6
|
+
print(report.summary())
|
|
7
|
+
|
|
8
|
+
Give it a known-good recording and every frame is judged against that instead::
|
|
9
|
+
|
|
10
|
+
report = audio_anomaly.detect("pump_today.wav", reference="pump_healthy.wav")
|
|
11
|
+
|
|
12
|
+
For checking many recordings against one reference, build a :class:`Monitor`
|
|
13
|
+
once and call :meth:`Monitor.check` on each. :func:`spectral_profile` gives the
|
|
14
|
+
typical spectrum of a recording, to save with ``numpy.save`` and pass back as
|
|
15
|
+
``reference=`` later.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._detect import Monitor, detect, spectral_profile
|
|
19
|
+
from ._report import KIND_MEANINGS, KINDS, AudioAnomalyReport, Event
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"detect",
|
|
25
|
+
"Monitor",
|
|
26
|
+
"spectral_profile",
|
|
27
|
+
"AudioAnomalyReport",
|
|
28
|
+
"Event",
|
|
29
|
+
"KINDS",
|
|
30
|
+
"KIND_MEANINGS",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|