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.
@@ -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
+ ]