bandcheck 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.
- bandcheck-0.1.0/LICENSE +29 -0
- bandcheck-0.1.0/PKG-INFO +109 -0
- bandcheck-0.1.0/README.md +86 -0
- bandcheck-0.1.0/pyproject.toml +40 -0
- bandcheck-0.1.0/setup.cfg +4 -0
- bandcheck-0.1.0/src/bandcheck/__init__.py +5 -0
- bandcheck-0.1.0/src/bandcheck/_version.py +1 -0
- bandcheck-0.1.0/src/bandcheck/check.py +89 -0
- bandcheck-0.1.0/src/bandcheck/cli.py +54 -0
- bandcheck-0.1.0/src/bandcheck/report.py +39 -0
- bandcheck-0.1.0/src/bandcheck/spectrum.py +108 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/PKG-INFO +109 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/SOURCES.txt +16 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/dependency_links.txt +1 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/entry_points.txt +2 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/requires.txt +6 -0
- bandcheck-0.1.0/src/bandcheck.egg-info/top_level.txt +1 -0
- bandcheck-0.1.0/tests/test_bandcheck.py +359 -0
bandcheck-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boobalan Arjunan
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
This project depends on third-party libraries (numpy, scipy, soundfile,
|
|
25
|
+
librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
|
|
26
|
+
optionally openai-whisper, torch/torchaudio). Each carries its own license
|
|
27
|
+
(mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
|
|
28
|
+
license before redistributing, especially if your GTV program has its own
|
|
29
|
+
licensing rules.
|
bandcheck-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bandcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded).
|
|
5
|
+
Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
6
|
+
Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/BoobalanArjunan/Bandcheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Bandcheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Bandcheck
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.23
|
|
18
|
+
Requires-Dist: scipy>=1.9
|
|
19
|
+
Requires-Dist: soundfile>=0.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# bandcheck
|
|
25
|
+
|
|
26
|
+
**Find audio files whose real frequency range is lower than their sample rate claims.**
|
|
27
|
+
|
|
28
|
+
A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
|
|
29
|
+
|
|
30
|
+
> **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
|
|
36
|
+
```
|
|
37
|
+
Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bandcheck check my_dataset/ # every audio file in the folder
|
|
43
|
+
bandcheck check clip.wav
|
|
44
|
+
bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
|
|
45
|
+
```
|
|
46
|
+
Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
|
|
47
|
+
The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
|
|
48
|
+
|
|
49
|
+
## What the verdicts mean
|
|
50
|
+
|
|
51
|
+
| verdict | meaning | exit code |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
|
|
54
|
+
| WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
|
|
55
|
+
| FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
|
|
56
|
+
| REVIEW | too short, silent, or too few loud parts to measure | 0 |
|
|
57
|
+
| ERROR | file could not be read | 0 |
|
|
58
|
+
|
|
59
|
+
For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
|
|
60
|
+
|
|
61
|
+
## How it works (short)
|
|
62
|
+
1. Reads the file at its own sample rate (no resampling) and averages the channels.
|
|
63
|
+
2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
|
|
64
|
+
3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
|
|
65
|
+
4. Compares the cut-off with the Nyquist frequency of the file.
|
|
66
|
+
|
|
67
|
+
## What has been measured
|
|
68
|
+
|
|
69
|
+
### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
|
|
70
|
+
- **Original recordings:**
|
|
71
|
+
- 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
|
|
72
|
+
- Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
|
|
73
|
+
- **Planted upsampling test (185 files evaluated):**
|
|
74
|
+
- From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
|
|
75
|
+
- From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
|
|
76
|
+
- Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
|
|
77
|
+
|
|
78
|
+

|
|
79
|
+
|
|
80
|
+
### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
|
|
81
|
+
- **Original recordings:**
|
|
82
|
+
- 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
|
|
83
|
+
- Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
|
|
84
|
+
- **Planted upsampling test (197 files evaluated):**
|
|
85
|
+
- From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
|
|
86
|
+
- From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
|
|
87
|
+
- From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
|
|
88
|
+
- **Planted MP3 encodes:**
|
|
89
|
+
- 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
|
|
90
|
+
- 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
|
|
91
|
+
|
|
92
|
+
### 3. Synthetic Audio & Bug Injection
|
|
93
|
+
- Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
|
|
94
|
+
- Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
|
|
95
|
+
- Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
|
|
96
|
+
- MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
|
|
97
|
+
- 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
|
|
98
|
+
|
|
99
|
+
## Limits (read these)
|
|
100
|
+
- **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
|
|
101
|
+
- **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
|
|
102
|
+
- **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
|
|
103
|
+
- **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
|
|
104
|
+
- A quiet recording or one with long silences is judged on its louder parts only.
|
|
105
|
+
- The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
|
|
106
|
+
- It tells you the bandwidth, not whether the audio is good.
|
|
107
|
+
|
|
108
|
+
## Licence
|
|
109
|
+
MIT.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# bandcheck
|
|
2
|
+
|
|
3
|
+
**Find audio files whose real frequency range is lower than their sample rate claims.**
|
|
4
|
+
|
|
5
|
+
A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
|
|
13
|
+
```
|
|
14
|
+
Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bandcheck check my_dataset/ # every audio file in the folder
|
|
20
|
+
bandcheck check clip.wav
|
|
21
|
+
bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
|
|
22
|
+
```
|
|
23
|
+
Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
|
|
24
|
+
The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
|
|
25
|
+
|
|
26
|
+
## What the verdicts mean
|
|
27
|
+
|
|
28
|
+
| verdict | meaning | exit code |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
|
|
31
|
+
| WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
|
|
32
|
+
| FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
|
|
33
|
+
| REVIEW | too short, silent, or too few loud parts to measure | 0 |
|
|
34
|
+
| ERROR | file could not be read | 0 |
|
|
35
|
+
|
|
36
|
+
For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
|
|
37
|
+
|
|
38
|
+
## How it works (short)
|
|
39
|
+
1. Reads the file at its own sample rate (no resampling) and averages the channels.
|
|
40
|
+
2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
|
|
41
|
+
3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
|
|
42
|
+
4. Compares the cut-off with the Nyquist frequency of the file.
|
|
43
|
+
|
|
44
|
+
## What has been measured
|
|
45
|
+
|
|
46
|
+
### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
|
|
47
|
+
- **Original recordings:**
|
|
48
|
+
- 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
|
|
49
|
+
- Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
|
|
50
|
+
- **Planted upsampling test (185 files evaluated):**
|
|
51
|
+
- From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
|
|
52
|
+
- From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
|
|
53
|
+
- Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
|
|
58
|
+
- **Original recordings:**
|
|
59
|
+
- 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
|
|
60
|
+
- Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
|
|
61
|
+
- **Planted upsampling test (197 files evaluated):**
|
|
62
|
+
- From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
|
|
63
|
+
- From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
|
|
64
|
+
- From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
|
|
65
|
+
- **Planted MP3 encodes:**
|
|
66
|
+
- 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
|
|
67
|
+
- 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
|
|
68
|
+
|
|
69
|
+
### 3. Synthetic Audio & Bug Injection
|
|
70
|
+
- Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
|
|
71
|
+
- Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
|
|
72
|
+
- Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
|
|
73
|
+
- MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
|
|
74
|
+
- 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
|
|
75
|
+
|
|
76
|
+
## Limits (read these)
|
|
77
|
+
- **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
|
|
78
|
+
- **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
|
|
79
|
+
- **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
|
|
80
|
+
- **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
|
|
81
|
+
- A quiet recording or one with long silences is judged on its louder parts only.
|
|
82
|
+
- The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
|
|
83
|
+
- It tells you the bandwidth, not whether the audio is good.
|
|
84
|
+
|
|
85
|
+
## Licence
|
|
86
|
+
MIT.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bandcheck"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["numpy>=1.23", "scipy>=1.9", "soundfile>=0.12"]
|
|
19
|
+
classifiers = ["Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Topic :: Multimedia :: Sound/Audio :: Analysis"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/BoobalanArjunan/Bandcheck"
|
|
23
|
+
Issues = "https://github.com/BoobalanArjunan/Bandcheck/issues"
|
|
24
|
+
Source = "https://github.com/BoobalanArjunan/Bandcheck"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
bandcheck = "bandcheck.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.dynamic]
|
|
33
|
+
version = { attr = "bandcheck._version.__version__" }
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Compare the real bandwidth of a file with what its sample rate promises, and give a verdict."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .spectrum import Spectrum, find_cliff, load_mono, mean_spectrum
|
|
11
|
+
|
|
12
|
+
FULL_FRACTION = 0.90 # cut-off at or above this share of the Nyquist frequency: full band
|
|
13
|
+
FULL_HZ = 19500.0 # ... or at or above this frequency, whatever the sample rate
|
|
14
|
+
FAKE_FRACTION = 0.60 # cut-off below this share of Nyquist: the file holds much less than it claims
|
|
15
|
+
NATIVE_RATES = (8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100)
|
|
16
|
+
RATE_LOW, RATE_HIGH = 0.90, 1.12 # the measured cut-off sits at 0.9 to 1.12 times the source's Nyquist (resampler filters)
|
|
17
|
+
|
|
18
|
+
OK, LIMITED, FAKE, UNSURE, ERROR = "OK", "LIMITED", "FAKE", "UNSURE", "ERROR"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Verdict:
|
|
23
|
+
file: str
|
|
24
|
+
sample_rate: Optional[int]
|
|
25
|
+
status: str
|
|
26
|
+
cutoff_hz: Optional[float]
|
|
27
|
+
reason: str
|
|
28
|
+
likely_source: str = ""
|
|
29
|
+
seconds: float = 0.0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def level(self) -> str:
|
|
33
|
+
return {OK: "PASS", LIMITED: "WARN", UNSURE: "REVIEW", FAKE: "FAIL", ERROR: "ERROR"}[self.status]
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def fraction(self) -> Optional[float]:
|
|
37
|
+
return None if (self.cutoff_hz is None or not self.sample_rate) else self.cutoff_hz / (self.sample_rate / 2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def source_guess(cutoff_hz: float, sample_rate: int) -> str:
|
|
41
|
+
"""One cut-off cannot tell an upsampled file from a lossy encode, so every candidate is listed."""
|
|
42
|
+
rates = [r for r in NATIVE_RATES if r < sample_rate and RATE_LOW <= cutoff_hz / (r / 2) <= RATE_HIGH]
|
|
43
|
+
parts = (["upsampled from " + " or ".join(f"{r} Hz" for r in rates)] if rates else []) + ["a lossy encoder's lowpass (MP3/AAC)"]
|
|
44
|
+
return " or ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def judge(sp: Optional[Spectrum], file: str = "", sample_rate: Optional[int] = None, need_hz: Optional[float] = None) -> Verdict:
|
|
48
|
+
if sp is None:
|
|
49
|
+
return Verdict(file, sample_rate, UNSURE, None, "too short, silent or too few loud parts to measure a bandwidth")
|
|
50
|
+
sr, nyq, secs = sp.sample_rate, sp.sample_rate / 2, sp.active_seconds
|
|
51
|
+
c = find_cliff(sp)
|
|
52
|
+
if c is None:
|
|
53
|
+
return Verdict(file, sr, OK, None, "no sharp cut-off found", "", secs)
|
|
54
|
+
frac = c.cutoff_hz / nyq
|
|
55
|
+
kHz = c.cutoff_hz / 1000
|
|
56
|
+
if need_hz is not None and c.cutoff_hz < need_hz:
|
|
57
|
+
return Verdict(file, sr, FAKE, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz, but {need_hz / 1000:g} kHz is required", source_guess(c.cutoff_hz, sr), secs)
|
|
58
|
+
if frac >= FULL_FRACTION or c.cutoff_hz >= FULL_HZ:
|
|
59
|
+
return Verdict(file, sr, OK, c.cutoff_hz, f"sound reaches {kHz:.1f} kHz ({100 * frac:.0f}% of the way to {nyq / 1000:g} kHz)", "", secs)
|
|
60
|
+
if frac < FAKE_FRACTION:
|
|
61
|
+
return Verdict(file, sr, FAKE, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz, only {100 * frac:.0f}% of the way to {nyq / 1000:g} kHz", source_guess(c.cutoff_hz, sr), secs)
|
|
62
|
+
return Verdict(file, sr, LIMITED, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz ({100 * frac:.0f}% of the way to {nyq / 1000:g} kHz)", source_guess(c.cutoff_hz, sr), secs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_file(path, need_hz: Optional[float] = None) -> Verdict:
|
|
66
|
+
p = Path(path)
|
|
67
|
+
try:
|
|
68
|
+
x, sr = load_mono(p)
|
|
69
|
+
except Exception as e: # unreadable audio
|
|
70
|
+
return Verdict(p.name, None, ERROR, None, f"could not read audio: {e}")
|
|
71
|
+
return judge(mean_spectrum(x, sr), p.name, sr, need_hz)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def summary_by_rate(vs: List[Verdict]) -> List[dict]:
|
|
75
|
+
"""One row per sample rate: files, how many are flagged (WARN or FAIL), median cut-off among those with a cut-off."""
|
|
76
|
+
rows = {}
|
|
77
|
+
for v in vs:
|
|
78
|
+
if v.sample_rate is None:
|
|
79
|
+
continue
|
|
80
|
+
r = rows.setdefault(v.sample_rate, {"rate": v.sample_rate, "files": 0, "flagged": 0, "cutoffs": []})
|
|
81
|
+
r["files"] += 1
|
|
82
|
+
r["flagged"] += v.level in ("WARN", "FAIL")
|
|
83
|
+
if v.cutoff_hz:
|
|
84
|
+
r["cutoffs"].append(v.cutoff_hz)
|
|
85
|
+
out = []
|
|
86
|
+
for r in sorted(rows.values(), key=lambda r: r["rate"]):
|
|
87
|
+
out.append({"rate": r["rate"], "files": r["files"], "flagged": r["flagged"],
|
|
88
|
+
"median_cutoff_hz": float(np.median(r["cutoffs"])) if r["cutoffs"] else None})
|
|
89
|
+
return out
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
6
|
+
from functools import partial
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .check import check_file
|
|
11
|
+
from .report import text, write_csv
|
|
12
|
+
|
|
13
|
+
AUDIO_EXT = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aiff", ".aif"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_audio(source: Path):
|
|
17
|
+
if source.is_file():
|
|
18
|
+
return [source]
|
|
19
|
+
return sorted(p for p in source.rglob("*") if p.suffix.lower() in AUDIO_EXT and not p.name.startswith("._") and "__MACOSX" not in p.parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv=None) -> int:
|
|
23
|
+
ap = argparse.ArgumentParser(prog="bandcheck", description="Find audio files whose real frequency range is lower than their sample rate claims.")
|
|
24
|
+
ap.add_argument("--version", action="version", version=f"bandcheck {__version__}")
|
|
25
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
26
|
+
c = sub.add_parser("check", help="check one file or every audio file in a folder")
|
|
27
|
+
c.add_argument("source", type=Path)
|
|
28
|
+
c.add_argument("--need-hz", type=float, help="fail files whose sound stops below this frequency (for example 8000 for 16 kHz speech models)")
|
|
29
|
+
c.add_argument("--out", type=Path, help="write every verdict to this CSV")
|
|
30
|
+
c.add_argument("--show-pass", action="store_true", help="also list files that passed")
|
|
31
|
+
c.add_argument("--jobs", type=int, default=1, help="files analysed in parallel (default 1)")
|
|
32
|
+
a = ap.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
if not a.source.exists():
|
|
35
|
+
print(f"error: {a.source} not found", file=sys.stderr)
|
|
36
|
+
return 2
|
|
37
|
+
files = find_audio(a.source)
|
|
38
|
+
if not files:
|
|
39
|
+
print("error: no audio files found", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
job = partial(check_file, need_hz=a.need_hz)
|
|
42
|
+
if a.jobs > 1:
|
|
43
|
+
with ProcessPoolExecutor(a.jobs) as ex:
|
|
44
|
+
vs = list(ex.map(job, files, chunksize=1))
|
|
45
|
+
else:
|
|
46
|
+
vs = [job(f) for f in files]
|
|
47
|
+
print(text(vs, a.show_pass))
|
|
48
|
+
if a.out:
|
|
49
|
+
write_csv(a.out, vs)
|
|
50
|
+
return 1 if any(v.level == "FAIL" for v in vs) else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .check import Verdict, summary_by_rate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def summary_line(vs: List[Verdict]) -> str:
|
|
11
|
+
c = Counter(v.level for v in vs)
|
|
12
|
+
return (f"{len(vs)} files: {c['PASS']} pass, {c['WARN']} warn (band-limited), {c['REVIEW']} need a look, "
|
|
13
|
+
f"{c['FAIL']} fail (much less bandwidth than claimed), {c['ERROR']} unreadable")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def text(vs: List[Verdict], show_pass: bool = False) -> str:
|
|
17
|
+
L = []
|
|
18
|
+
for v in vs:
|
|
19
|
+
if v.level == "PASS" and not show_pass:
|
|
20
|
+
continue
|
|
21
|
+
src = f" likely: {v.likely_source}" if v.likely_source else ""
|
|
22
|
+
L.append(f"{v.level:6} {v.file} {v.sample_rate or '-'} Hz {v.reason}{src}")
|
|
23
|
+
rows = summary_by_rate(vs)
|
|
24
|
+
if rows:
|
|
25
|
+
L += ["", "By sample rate:"]
|
|
26
|
+
for r in rows:
|
|
27
|
+
mc = f", median cut-off {r['median_cutoff_hz'] / 1000:.1f} kHz" if r["median_cutoff_hz"] else ""
|
|
28
|
+
L.append(f" {r['rate']} Hz: {r['files']} files, {r['flagged']} flagged{mc}")
|
|
29
|
+
L += ["", summary_line(vs)]
|
|
30
|
+
return "\n".join(L)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_csv(path, vs: List[Verdict]) -> None:
|
|
34
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
35
|
+
w = csv.writer(f)
|
|
36
|
+
w.writerow(["file", "sample_rate", "cutoff_hz", "cutoff_fraction", "verdict", "reason", "likely_source", "seconds_analysed"])
|
|
37
|
+
for v in vs:
|
|
38
|
+
w.writerow([v.file, v.sample_rate or "", "" if v.cutoff_hz is None else round(v.cutoff_hz, 1),
|
|
39
|
+
"" if v.fraction is None else round(v.fraction, 3), v.level, v.reason, v.likely_source, round(v.seconds, 1)])
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Audio -> long-term average spectrum, and the search for a sharp cut-off in it.
|
|
2
|
+
|
|
3
|
+
A file that was upsampled, or lowpassed by a lossy encoder, has real sound up to some frequency and almost nothing
|
|
4
|
+
above it, with a steep drop in between (a "cliff"). Natural sound fades gradually. So BandCheck looks for the steepest
|
|
5
|
+
drop in the average spectrum and reports where it is. Light on purpose: numpy, scipy, soundfile only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import soundfile as sf
|
|
14
|
+
from scipy.signal import medfilt, stft
|
|
15
|
+
|
|
16
|
+
MAX_SECONDS = 120.0 # long files: only the middle 2 minutes are used
|
|
17
|
+
MIN_SECONDS = 2.0
|
|
18
|
+
ACTIVE_FLOOR_DB = -30.0 # frames more than 30 dB below the loudest frame are ignored
|
|
19
|
+
MIN_ACTIVE_FRAMES = 4
|
|
20
|
+
SEARCH_FROM_HZ = 2500.0 # a cliff below this is not looked for (speech and music change a lot down there)
|
|
21
|
+
EDGE_MARGIN_HZ = 500.0 # a cliff must leave at least this much spectrum above it to compare
|
|
22
|
+
SIDE_GAP_HZ, SIDE_SPAN_HZ = 200.0, 1000.0 # compare the level just below the cliff with the level just above it
|
|
23
|
+
GRID_HZ = 50.0
|
|
24
|
+
CLIFF_DB = 25.0 # the drop that counts as a cliff
|
|
25
|
+
KNEE_DB = 10.0 # the cut-off is reported where the level has fallen this far below the band's own level
|
|
26
|
+
SMOOTH_HZ = 100.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Spectrum:
|
|
31
|
+
freqs: np.ndarray
|
|
32
|
+
level_db: np.ndarray # average power per bin, dB, relative to its own maximum
|
|
33
|
+
sample_rate: int
|
|
34
|
+
seconds: float # length of the audio analysed
|
|
35
|
+
active_seconds: float # time in frames that were loud enough to use
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Cliff:
|
|
40
|
+
cutoff_hz: float
|
|
41
|
+
drop_db: float # level just below the cut-off minus level just above it
|
|
42
|
+
floor_db: float # median level of everything above the cut-off, relative to just below it
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_mono(path, max_seconds: float = MAX_SECONDS):
|
|
46
|
+
"""Reads the file at its OWN sample rate (no resampling) -> (mono float64, sample rate)."""
|
|
47
|
+
x, sr = sf.read(str(path), dtype="float64", always_2d=True)
|
|
48
|
+
x = x.mean(axis=1)
|
|
49
|
+
n = int(max_seconds * sr)
|
|
50
|
+
if len(x) > n:
|
|
51
|
+
s = (len(x) - n) // 2
|
|
52
|
+
x = x[s:s + n]
|
|
53
|
+
return x, int(sr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mean_spectrum(x: np.ndarray, sr: int) -> Optional[Spectrum]:
|
|
57
|
+
"""Average power spectrum over the loud frames. None if the audio is too short, silent or has too few loud frames."""
|
|
58
|
+
if len(x) < MIN_SECONDS * sr or not np.any(np.abs(x) > 1e-9):
|
|
59
|
+
return None
|
|
60
|
+
n = int(2 ** round(np.log2(max(sr / 10.0, 256)))) # about 10 Hz per bin
|
|
61
|
+
n = min(max(n, 512), 8192)
|
|
62
|
+
_, _, Z = stft(x, fs=sr, nperseg=n, noverlap=n // 2, boundary=None, padded=False)
|
|
63
|
+
P = np.abs(Z) ** 2
|
|
64
|
+
energy = P.sum(axis=0)
|
|
65
|
+
if energy.max() <= 0:
|
|
66
|
+
return None
|
|
67
|
+
active = energy > energy.max() * 10 ** (ACTIVE_FLOOR_DB / 10)
|
|
68
|
+
if active.sum() < MIN_ACTIVE_FRAMES:
|
|
69
|
+
return None
|
|
70
|
+
S = P[:, active].mean(axis=1)
|
|
71
|
+
L = 10 * np.log10(S + 1e-30)
|
|
72
|
+
L -= L.max()
|
|
73
|
+
freqs = np.linspace(0, sr / 2, len(L))
|
|
74
|
+
hop = n // 2
|
|
75
|
+
return Spectrum(freqs, L, sr, len(x) / sr, float(active.sum() * hop / sr))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_cliff(sp: Spectrum) -> Optional[Cliff]:
|
|
79
|
+
"""The sharpest drop (at least CLIFF_DB) in the smoothed average spectrum, or None if the spectrum fades gradually."""
|
|
80
|
+
f, nyq = sp.freqs, sp.sample_rate / 2
|
|
81
|
+
df = f[1] - f[0]
|
|
82
|
+
k = max(3, int(SMOOTH_HZ / df) | 1)
|
|
83
|
+
Ls = medfilt(sp.level_db, k)
|
|
84
|
+
best_g, best_d = None, 0.0
|
|
85
|
+
for g in np.arange(SEARCH_FROM_HZ, nyq - EDGE_MARGIN_HZ, GRID_HZ):
|
|
86
|
+
lo = (f >= g - SIDE_SPAN_HZ) & (f < g - SIDE_GAP_HZ)
|
|
87
|
+
hi = (f > g + SIDE_GAP_HZ) & (f <= min(g + SIDE_SPAN_HZ, nyq))
|
|
88
|
+
if lo.sum() < 3 or hi.sum() < 3:
|
|
89
|
+
continue
|
|
90
|
+
d = float(np.median(Ls[lo]) - np.median(Ls[hi]))
|
|
91
|
+
if d > best_d:
|
|
92
|
+
best_g, best_d = g, d
|
|
93
|
+
if best_g is None or best_d < CLIFF_DB:
|
|
94
|
+
return None
|
|
95
|
+
lo = (f >= best_g - SIDE_SPAN_HZ) & (f < best_g - SIDE_GAP_HZ)
|
|
96
|
+
hi = (f > best_g + SIDE_GAP_HZ) & (f <= min(best_g + SIDE_SPAN_HZ, nyq))
|
|
97
|
+
below, above = float(np.median(Ls[lo])), float(np.median(Ls[hi]))
|
|
98
|
+
knee = below - min(KNEE_DB, (below - above) / 2) # where the level has fallen 10 dB: the edge of the usable band
|
|
99
|
+
win = (f >= best_g - SIDE_SPAN_HZ) & (f <= best_g + SIDE_SPAN_HZ)
|
|
100
|
+
idx = np.nonzero(win & (Ls <= knee))[0]
|
|
101
|
+
cutoff = float(f[idx[0]]) if len(idx) else float(best_g)
|
|
102
|
+
rest = f > cutoff + SIDE_GAP_HZ
|
|
103
|
+
if rest.sum() < 3:
|
|
104
|
+
return None
|
|
105
|
+
floor = float(np.median(Ls[rest]) - below)
|
|
106
|
+
if -floor < CLIFF_DB - 5: # content comes back above the drop: a notch or a gap, not a cut-off
|
|
107
|
+
return None
|
|
108
|
+
return Cliff(cutoff, best_d, floor)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bandcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded).
|
|
5
|
+
Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
6
|
+
Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/BoobalanArjunan/Bandcheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Bandcheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Bandcheck
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.23
|
|
18
|
+
Requires-Dist: scipy>=1.9
|
|
19
|
+
Requires-Dist: soundfile>=0.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# bandcheck
|
|
25
|
+
|
|
26
|
+
**Find audio files whose real frequency range is lower than their sample rate claims.**
|
|
27
|
+
|
|
28
|
+
A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
|
|
29
|
+
|
|
30
|
+
> **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
|
|
36
|
+
```
|
|
37
|
+
Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bandcheck check my_dataset/ # every audio file in the folder
|
|
43
|
+
bandcheck check clip.wav
|
|
44
|
+
bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
|
|
45
|
+
```
|
|
46
|
+
Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
|
|
47
|
+
The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
|
|
48
|
+
|
|
49
|
+
## What the verdicts mean
|
|
50
|
+
|
|
51
|
+
| verdict | meaning | exit code |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
|
|
54
|
+
| WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
|
|
55
|
+
| FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
|
|
56
|
+
| REVIEW | too short, silent, or too few loud parts to measure | 0 |
|
|
57
|
+
| ERROR | file could not be read | 0 |
|
|
58
|
+
|
|
59
|
+
For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
|
|
60
|
+
|
|
61
|
+
## How it works (short)
|
|
62
|
+
1. Reads the file at its own sample rate (no resampling) and averages the channels.
|
|
63
|
+
2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
|
|
64
|
+
3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
|
|
65
|
+
4. Compares the cut-off with the Nyquist frequency of the file.
|
|
66
|
+
|
|
67
|
+
## What has been measured
|
|
68
|
+
|
|
69
|
+
### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
|
|
70
|
+
- **Original recordings:**
|
|
71
|
+
- 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
|
|
72
|
+
- Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
|
|
73
|
+
- **Planted upsampling test (185 files evaluated):**
|
|
74
|
+
- From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
|
|
75
|
+
- From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
|
|
76
|
+
- Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
|
|
77
|
+
|
|
78
|
+

|
|
79
|
+
|
|
80
|
+
### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
|
|
81
|
+
- **Original recordings:**
|
|
82
|
+
- 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
|
|
83
|
+
- Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
|
|
84
|
+
- **Planted upsampling test (197 files evaluated):**
|
|
85
|
+
- From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
|
|
86
|
+
- From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
|
|
87
|
+
- From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
|
|
88
|
+
- **Planted MP3 encodes:**
|
|
89
|
+
- 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
|
|
90
|
+
- 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
|
|
91
|
+
|
|
92
|
+
### 3. Synthetic Audio & Bug Injection
|
|
93
|
+
- Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
|
|
94
|
+
- Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
|
|
95
|
+
- Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
|
|
96
|
+
- MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
|
|
97
|
+
- 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
|
|
98
|
+
|
|
99
|
+
## Limits (read these)
|
|
100
|
+
- **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
|
|
101
|
+
- **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
|
|
102
|
+
- **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
|
|
103
|
+
- **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
|
|
104
|
+
- A quiet recording or one with long silences is judged on its louder parts only.
|
|
105
|
+
- The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
|
|
106
|
+
- It tells you the bandwidth, not whether the audio is good.
|
|
107
|
+
|
|
108
|
+
## Licence
|
|
109
|
+
MIT.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bandcheck/__init__.py
|
|
5
|
+
src/bandcheck/_version.py
|
|
6
|
+
src/bandcheck/check.py
|
|
7
|
+
src/bandcheck/cli.py
|
|
8
|
+
src/bandcheck/report.py
|
|
9
|
+
src/bandcheck/spectrum.py
|
|
10
|
+
src/bandcheck.egg-info/PKG-INFO
|
|
11
|
+
src/bandcheck.egg-info/SOURCES.txt
|
|
12
|
+
src/bandcheck.egg-info/dependency_links.txt
|
|
13
|
+
src/bandcheck.egg-info/entry_points.txt
|
|
14
|
+
src/bandcheck.egg-info/requires.txt
|
|
15
|
+
src/bandcheck.egg-info/top_level.txt
|
|
16
|
+
tests/test_bandcheck.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bandcheck
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
import soundfile as sf
|
|
6
|
+
from scipy.signal import firwin, fftconvolve
|
|
7
|
+
|
|
8
|
+
import bandcheck.check as chk
|
|
9
|
+
import synth
|
|
10
|
+
from bandcheck.check import (FAKE, LIMITED, OK, UNSURE, check_file, judge, source_guess, summary_by_rate)
|
|
11
|
+
from bandcheck.cli import find_audio, main
|
|
12
|
+
from bandcheck.report import summary_line, text, write_csv
|
|
13
|
+
from bandcheck.spectrum import Cliff, Spectrum, find_cliff, load_mono, mean_spectrum
|
|
14
|
+
from conftest import cut, full, up, write
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def spec_of(x, sr):
|
|
18
|
+
return mean_spectrum(synth.to_pcm16(x).astype(float) / 32767, sr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fake_spec(sr=44100, secs=10.0):
|
|
22
|
+
f = np.linspace(0, sr / 2, 2049)
|
|
23
|
+
return Spectrum(f, np.zeros_like(f), sr, secs, secs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------- full-band audio passes
|
|
27
|
+
@pytest.mark.parametrize("sr", [16000, 22050, 44100, 48000])
|
|
28
|
+
def test_full_band_audio_passes(tmp_path, sr):
|
|
29
|
+
v = check_file(write(tmp_path / "a.wav", full(sr), sr))
|
|
30
|
+
assert v.status == OK and v.level == "PASS" and v.cutoff_hz is None
|
|
31
|
+
assert v.sample_rate == sr and v.likely_source == ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("tilt", [1.0, 1.5, 2.0, 2.5])
|
|
35
|
+
def test_steeply_falling_natural_spectra_have_no_cliff(tilt):
|
|
36
|
+
"""Dull sound that fades gradually is not a cut-off."""
|
|
37
|
+
assert find_cliff(spec_of(synth.make_full(44100, 12, 1, tilt=tilt), 44100)) is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_quiet_recordings_are_not_flagged(tmp_path):
|
|
41
|
+
for level in (0.001, 0.01):
|
|
42
|
+
assert check_file(write(tmp_path / "q.wav", synth.make_full(44100, 12, 2, level=level), 44100)).status == OK
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_a_gap_in_the_spectrum_is_not_a_cutoff():
|
|
46
|
+
"""A band-stop (sound on both sides) is a notch, not the end of the band."""
|
|
47
|
+
x = full(44100)
|
|
48
|
+
band = firwin(2001, [10000, 11500], pass_zero=False, fs=44100)
|
|
49
|
+
y = x - fftconvolve(x, band, mode="same")
|
|
50
|
+
assert find_cliff(spec_of(y, 44100)) is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- lowpass (lossy-encoder-like) detection
|
|
54
|
+
@pytest.mark.parametrize("sr,fc", [(44100, 16000), (44100, 19000), (44100, 20000), (48000, 16000), (48000, 20000), (16000, 6000), (16000, 7000)])
|
|
55
|
+
def test_cutoff_is_found_where_it_was_put(sr, fc):
|
|
56
|
+
c = find_cliff(spec_of(cut(sr, fc), sr))
|
|
57
|
+
assert c is not None
|
|
58
|
+
assert abs(c.cutoff_hz - fc) / fc < 0.02
|
|
59
|
+
assert c.drop_db > 30 and c.floor_db < -25
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_cutoff_found_for_other_seeds():
|
|
63
|
+
for seed in (1, 2, 3):
|
|
64
|
+
c = find_cliff(spec_of(cut(44100, 17000, seed), 44100))
|
|
65
|
+
assert c is not None and abs(c.cutoff_hz - 17000) < 400
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize("sr,fc,status", [
|
|
69
|
+
(44100, 16000, LIMITED), (48000, 16000, LIMITED), (48000, 19000, LIMITED),
|
|
70
|
+
(44100, 20000, OK), (48000, 20000, OK), (16000, 7500, OK), (48000, 10000, FAKE), (44100, 12000, FAKE)])
|
|
71
|
+
def test_verdict_for_lowpassed_audio(tmp_path, sr, fc, status):
|
|
72
|
+
v = check_file(write(tmp_path / "a.wav", cut(sr, fc), sr))
|
|
73
|
+
assert v.status == status, v.reason
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------- upsampled audio
|
|
77
|
+
@pytest.mark.parametrize("sr,src,status", [(16000, 8000, FAKE), (16000, 11025, LIMITED), (44100, 16000, FAKE), (44100, 22050, FAKE),
|
|
78
|
+
(48000, 16000, FAKE), (48000, 24000, FAKE), (48000, 8000, FAKE)])
|
|
79
|
+
def test_upsampled_audio_is_flagged_and_names_its_source(tmp_path, sr, src, status):
|
|
80
|
+
"""Upsampling to a rate close to the source (11.025 to 16 kHz keeps 72% of the band) is a warning; far from it, a failure."""
|
|
81
|
+
v = check_file(write(tmp_path / "a.wav", up(sr, src), sr))
|
|
82
|
+
assert v.status == status, v.reason
|
|
83
|
+
assert 0.9 * src / 2 <= v.cutoff_hz <= 1.12 * src / 2
|
|
84
|
+
assert f"upsampled from {src} Hz" in v.likely_source or f"{src} Hz" in v.likely_source
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_the_reason_says_how_much_is_missing(tmp_path):
|
|
88
|
+
v = check_file(write(tmp_path / "a.wav", up(48000, 16000), 48000))
|
|
89
|
+
assert "kHz" in v.reason and "% of the way to 24 kHz" in v.reason
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_source_guess_lists_every_candidate_and_the_lossy_option():
|
|
93
|
+
g = source_guess(5781, 16000)
|
|
94
|
+
assert "11025 Hz" in g and "12000 Hz" in g and "lossy" in g
|
|
95
|
+
assert source_guess(8636, 48000).startswith("upsampled from 16000 Hz")
|
|
96
|
+
assert "upsampled" not in source_guess(3000, 48000) and "lossy" in source_guess(3000, 48000)
|
|
97
|
+
assert "from 48000" not in source_guess(24000, 48000) # never "upsampled from" the file's own rate or higher
|
|
98
|
+
assert "from 44100" not in source_guess(20000, 44100)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------- need-hz
|
|
102
|
+
def test_need_hz_fails_files_that_stop_too_low(tmp_path):
|
|
103
|
+
p = write(tmp_path / "a.wav", cut(44100, 16000), 44100)
|
|
104
|
+
assert check_file(p, need_hz=12000).status == LIMITED
|
|
105
|
+
v = check_file(p, need_hz=18000)
|
|
106
|
+
assert v.status == FAKE and "18 kHz is required" in v.reason
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_need_hz_does_nothing_without_a_cutoff(tmp_path):
|
|
110
|
+
assert check_file(write(tmp_path / "a.wav", full(44100), 44100), need_hz=20000).status == OK
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------- verdict boundaries (cut-off position forced)
|
|
114
|
+
def force(monkeypatch, cutoff):
|
|
115
|
+
monkeypatch.setattr(chk, "find_cliff", lambda sp: Cliff(cutoff, 40.0, -40.0))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("sr,cutoff,status", [
|
|
119
|
+
(32000, 0.9 * 16000, OK), (32000, 0.9 * 16000 - 1, LIMITED), (44100, 19500, OK), (48000, 19499, LIMITED),
|
|
120
|
+
(48000, 0.6 * 24000, LIMITED), (48000, 0.6 * 24000 - 1, FAKE), (16000, 0.9 * 8000, OK), (16000, 0.9 * 8000 - 1, LIMITED),
|
|
121
|
+
(16000, 0.6 * 8000 - 1, FAKE)])
|
|
122
|
+
def test_thresholds(monkeypatch, sr, cutoff, status):
|
|
123
|
+
force(monkeypatch, cutoff)
|
|
124
|
+
assert judge(fake_spec(sr), "a.wav", sr).status == status
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_need_hz_boundary(monkeypatch):
|
|
128
|
+
force(monkeypatch, 15000.0)
|
|
129
|
+
assert judge(fake_spec(), "a", 44100, need_hz=15000.0).status == LIMITED
|
|
130
|
+
assert judge(fake_spec(), "a", 44100, need_hz=15000.1).status == FAKE
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_levels_and_fraction():
|
|
134
|
+
v = chk.Verdict("a", 48000, FAKE, 12000.0, "r")
|
|
135
|
+
assert v.level == "FAIL" and v.fraction == 0.5
|
|
136
|
+
assert chk.Verdict("a", None, chk.ERROR, None, "r").fraction is None
|
|
137
|
+
assert {chk.Verdict("a", 1, s, None, "").level for s in (OK, LIMITED, FAKE, UNSURE, chk.ERROR)} == {"PASS", "WARN", "FAIL", "REVIEW", "ERROR"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- edge cases
|
|
141
|
+
def test_silent_and_short_files_are_review(tmp_path):
|
|
142
|
+
assert check_file(write(tmp_path / "s.wav", np.zeros(44100 * 10), 44100)).status == UNSURE
|
|
143
|
+
assert check_file(write(tmp_path / "t.wav", full(44100)[:44100], 44100)).status == UNSURE
|
|
144
|
+
assert judge(None, "a", 44100).status == UNSURE
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_one_click_in_silence_has_too_few_loud_frames(tmp_path):
|
|
148
|
+
x = np.zeros(44100 * 10)
|
|
149
|
+
x[44100] = 0.5
|
|
150
|
+
assert check_file(write(tmp_path / "c.wav", x, 44100)).status == UNSURE
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_unreadable_file_is_error(tmp_path):
|
|
154
|
+
p = tmp_path / "bad.wav"
|
|
155
|
+
p.write_bytes(b"this is not audio")
|
|
156
|
+
v = check_file(p)
|
|
157
|
+
assert v.status == chk.ERROR and "could not read" in v.reason and v.sample_rate is None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_low_sample_rate_without_room_to_search():
|
|
161
|
+
assert judge(spec_of(synth.make_full(8000, 12, 1), 8000), "a", 8000).status == OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_cutoff_below_the_search_start_is_not_reported(tmp_path):
|
|
165
|
+
"""A cut-off far below 2.5 kHz is outside the search (speech and music vary a lot there), so it is not flagged."""
|
|
166
|
+
assert find_cliff(spec_of(cut(16000, 1000), 16000)) is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_stereo_equals_mono(tmp_path):
|
|
170
|
+
x = cut(44100, 16000)
|
|
171
|
+
st = np.stack([x, x], axis=1)
|
|
172
|
+
p = tmp_path / "s.wav"
|
|
173
|
+
sf.write(str(p), synth.to_pcm16(st), 44100, subtype="PCM_16")
|
|
174
|
+
a, b = check_file(p), check_file(write(tmp_path / "m.wav", x, 44100))
|
|
175
|
+
assert a.status == b.status and abs(a.cutoff_hz - b.cutoff_hz) < 1.0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_one_dead_channel_still_measured(tmp_path):
|
|
179
|
+
x = cut(44100, 16000)
|
|
180
|
+
p = tmp_path / "d.wav"
|
|
181
|
+
sf.write(str(p), synth.to_pcm16(np.stack([x, 0 * x], axis=1)), 44100, subtype="PCM_16")
|
|
182
|
+
assert check_file(p).status == LIMITED
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_flac_and_24_bit(tmp_path):
|
|
186
|
+
x = cut(48000, 16000)
|
|
187
|
+
sf.write(str(tmp_path / "a.flac"), x, 48000, subtype="PCM_24")
|
|
188
|
+
assert check_file(tmp_path / "a.flac").status == LIMITED
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_gain_does_not_change_the_result():
|
|
192
|
+
for g in (0.05, 1.0, 3.0):
|
|
193
|
+
c = find_cliff(mean_spectrum(g * cut(44100, 16000), 44100))
|
|
194
|
+
assert c is not None and abs(c.cutoff_hz - 16000) < 400
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_load_mono_keeps_the_files_own_rate_and_uses_the_middle(tmp_path):
|
|
198
|
+
p = write(tmp_path / "a.wav", full(22050), 22050)
|
|
199
|
+
x, sr = load_mono(p)
|
|
200
|
+
assert sr == 22050 and len(x) == 12 * 22050
|
|
201
|
+
x2, _ = load_mono(p, max_seconds=4)
|
|
202
|
+
assert len(x2) == 4 * 22050
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_active_seconds_counts_only_loud_parts():
|
|
206
|
+
x = np.concatenate([full(44100)[:44100 * 4], np.zeros(44100 * 8)])
|
|
207
|
+
sp = mean_spectrum(x, 44100)
|
|
208
|
+
assert sp is not None and sp.seconds == pytest.approx(12.0, abs=0.01) and 3.0 < sp.active_seconds < 5.5
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_quiet_sections_do_not_hide_the_cutoff():
|
|
212
|
+
quiet = 0.001 * full(44100)
|
|
213
|
+
loud = cut(44100, 16000)
|
|
214
|
+
x = np.concatenate([quiet[:44100 * 6], loud[:44100 * 6]])
|
|
215
|
+
c = find_cliff(mean_spectrum(x, 44100))
|
|
216
|
+
assert c is not None and abs(c.cutoff_hz - 16000) < 400
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_two_cutoffs_the_sharpest_drop_wins():
|
|
220
|
+
x = cut(48000, 20000)
|
|
221
|
+
y = cut(48000, 10000)
|
|
222
|
+
c = find_cliff(spec_of(0.5 * x + 0.5 * y, 48000))
|
|
223
|
+
assert c is not None and (abs(c.cutoff_hz - 10000) < 500 or abs(c.cutoff_hz - 20000) < 500)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- summary and reports
|
|
227
|
+
def test_summary_by_rate():
|
|
228
|
+
vs = [chk.Verdict("a", 48000, FAKE, 8000.0, ""), chk.Verdict("b", 48000, OK, None, ""), chk.Verdict("c", 48000, LIMITED, 16000.0, ""),
|
|
229
|
+
chk.Verdict("d", 16000, OK, None, ""), chk.Verdict("e", None, chk.ERROR, None, "")]
|
|
230
|
+
rows = summary_by_rate(vs)
|
|
231
|
+
assert rows == [{"rate": 16000, "files": 1, "flagged": 0, "median_cutoff_hz": None},
|
|
232
|
+
{"rate": 48000, "files": 3, "flagged": 2, "median_cutoff_hz": 12000.0}]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_text_report_hides_passes_by_default():
|
|
236
|
+
vs = [chk.Verdict("good.wav", 48000, OK, None, "no sharp cut-off found"), chk.Verdict("bad.wav", 48000, FAKE, 8000.0, "stops low", "upsampled from 16000 Hz")]
|
|
237
|
+
t = text(vs)
|
|
238
|
+
assert "bad.wav" in t and "good.wav" not in t and "likely: upsampled from 16000 Hz" in t
|
|
239
|
+
assert "good.wav" in text(vs, show_pass=True)
|
|
240
|
+
assert "48000 Hz: 2 files, 1 flagged" in t
|
|
241
|
+
assert "2 files: 1 pass, 0 warn" in summary_line(vs)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_csv_columns_and_values(tmp_path):
|
|
245
|
+
out = tmp_path / "v.csv"
|
|
246
|
+
write_csv(out, [chk.Verdict("a.wav", 48000, FAKE, 8000.0, "r", "x", 9.96)])
|
|
247
|
+
row = list(csv.DictReader(open(out, encoding="utf-8")))[0]
|
|
248
|
+
assert list(row) == ["file", "sample_rate", "cutoff_hz", "cutoff_fraction", "verdict", "reason", "likely_source", "seconds_analysed"]
|
|
249
|
+
assert (row["verdict"], row["cutoff_hz"], row["cutoff_fraction"], row["seconds_analysed"]) == ("FAIL", "8000.0", "0.333", "10.0")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------- command line
|
|
253
|
+
def make_folder(tmp_path):
|
|
254
|
+
write(tmp_path / "good.wav", full(44100), 44100)
|
|
255
|
+
write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
|
|
256
|
+
(tmp_path / "sub").mkdir()
|
|
257
|
+
write(tmp_path / "sub" / "fake.wav", up(48000, 16000), 48000)
|
|
258
|
+
return tmp_path
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_cli_folder_exit_code_and_output(tmp_path, capsys):
|
|
262
|
+
rc = main(["check", str(make_folder(tmp_path))])
|
|
263
|
+
out = capsys.readouterr().out
|
|
264
|
+
assert rc == 1
|
|
265
|
+
assert "FAIL fake.wav" in out and "WARN limited.wav" in out and "good.wav" not in out
|
|
266
|
+
assert "3 files: 1 pass, 1 warn" in out and "By sample rate:" in out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_cli_exit_zero_when_nothing_fails(tmp_path, capsys):
|
|
270
|
+
write(tmp_path / "good.wav", full(44100), 44100)
|
|
271
|
+
write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
|
|
272
|
+
assert main(["check", str(tmp_path)]) == 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_cli_single_file_show_pass_and_out(tmp_path, capsys):
|
|
276
|
+
p = write(tmp_path / "good.wav", full(16000), 16000)
|
|
277
|
+
o = tmp_path / "v.csv"
|
|
278
|
+
assert main(["check", str(p), "--show-pass", "--out", str(o)]) == 0
|
|
279
|
+
assert "good.wav" in capsys.readouterr().out
|
|
280
|
+
assert list(csv.DictReader(open(o, encoding="utf-8")))[0]["verdict"] == "PASS"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_cli_need_hz(tmp_path, capsys):
|
|
284
|
+
write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
|
|
285
|
+
assert main(["check", str(tmp_path)]) == 0
|
|
286
|
+
assert main(["check", str(tmp_path), "--need-hz", "18000"]) == 1
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_cli_parallel_jobs_same_result(tmp_path, capsys):
|
|
290
|
+
d = make_folder(tmp_path)
|
|
291
|
+
main(["check", str(d)])
|
|
292
|
+
one = capsys.readouterr().out
|
|
293
|
+
main(["check", str(d), "--jobs", "2"])
|
|
294
|
+
assert capsys.readouterr().out == one
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_cli_skips_macos_junk_and_non_audio(tmp_path):
|
|
298
|
+
write(tmp_path / "a.wav", full(16000), 16000)
|
|
299
|
+
(tmp_path / "._a.wav").write_bytes(b"junk")
|
|
300
|
+
(tmp_path / "notes.txt").write_text("x")
|
|
301
|
+
(tmp_path / "__MACOSX").mkdir()
|
|
302
|
+
(tmp_path / "__MACOSX" / "b.wav").write_bytes(b"junk")
|
|
303
|
+
assert [p.name for p in find_audio(tmp_path)] == ["a.wav"]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_cli_usage_errors(tmp_path, capsys):
|
|
307
|
+
assert main(["check", str(tmp_path / "missing")]) == 2
|
|
308
|
+
assert main(["check", str(tmp_path)]) == 2
|
|
309
|
+
assert "error" in capsys.readouterr().err
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_cli_unreadable_file_listed_but_not_failing(tmp_path, capsys):
|
|
313
|
+
(tmp_path / "bad.wav").write_bytes(b"not audio")
|
|
314
|
+
assert main(["check", str(tmp_path)]) == 0
|
|
315
|
+
assert "ERROR" in capsys.readouterr().out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_version(capsys):
|
|
319
|
+
with pytest.raises(SystemExit):
|
|
320
|
+
main(["--version"])
|
|
321
|
+
assert "bandcheck 0.1.0" in capsys.readouterr().out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------- tests added after the planted-bug check
|
|
325
|
+
def test_a_20_db_step_is_not_a_cutoff():
|
|
326
|
+
"""A shelf that drops 20 dB is below the 25 dB needed to call something a cut-off."""
|
|
327
|
+
x = 0.1 * full(44100) + cut(44100, 16000)
|
|
328
|
+
assert find_cliff(spec_of(x, 44100)) is None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_a_30_db_step_is_a_cutoff():
|
|
332
|
+
x = 0.03 * full(44100) + cut(44100, 16000)
|
|
333
|
+
c = find_cliff(spec_of(x, 44100))
|
|
334
|
+
assert c is not None and abs(c.cutoff_hz - 16000) < 500
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_quiet_full_band_passages_do_not_hide_a_cutoff():
|
|
338
|
+
"""Parts more than 30 dB under the loudest frame are ignored, so a quiet hiss does not fill in the empty band."""
|
|
339
|
+
x = np.concatenate([cut(44100, 16000)[:44100 * 3], 0.03 * full(44100)[:44100 * 9]]) # 3 s loud, 9 s quiet
|
|
340
|
+
c = find_cliff(mean_spectrum(synth.to_pcm16(x).astype(float) / 32767, 44100))
|
|
341
|
+
assert c is not None and abs(c.cutoff_hz - 16000) < 500
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_stereo_channels_are_averaged_so_a_full_band_channel_counts(tmp_path):
|
|
345
|
+
p = tmp_path / "s.wav"
|
|
346
|
+
sf.write(str(p), synth.to_pcm16(np.stack([cut(44100, 16000), full(44100)], axis=1)), 44100, subtype="PCM_16")
|
|
347
|
+
assert check_file(p).status == OK
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_a_long_file_is_judged_by_its_middle(tmp_path):
|
|
351
|
+
x = np.concatenate([cut(44100, 16000)[:44100 * 4], full(44100)[:44100 * 4], cut(44100, 16000)[:44100 * 4]])
|
|
352
|
+
p = write(tmp_path / "long.wav", x, 44100)
|
|
353
|
+
xs, sr = load_mono(p, max_seconds=4)
|
|
354
|
+
assert find_cliff(mean_spectrum(xs, sr)) is None # the full-band middle, not the cut first 4 s
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_spectrum_level_is_relative_to_its_loudest_bin():
|
|
358
|
+
sp = spec_of(full(44100), 44100)
|
|
359
|
+
assert sp.level_db.max() == pytest.approx(0.0, abs=1e-9) and sp.level_db.min() < -20
|