tempocheck 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.
- tempocheck-0.1.0/LICENSE +29 -0
- tempocheck-0.1.0/PKG-INFO +183 -0
- tempocheck-0.1.0/README.md +160 -0
- tempocheck-0.1.0/pyproject.toml +40 -0
- tempocheck-0.1.0/setup.cfg +4 -0
- tempocheck-0.1.0/src/tempocheck/__init__.py +5 -0
- tempocheck-0.1.0/src/tempocheck/_version.py +1 -0
- tempocheck-0.1.0/src/tempocheck/check.py +61 -0
- tempocheck-0.1.0/src/tempocheck/cli.py +56 -0
- tempocheck-0.1.0/src/tempocheck/estimate.py +77 -0
- tempocheck-0.1.0/src/tempocheck/features.py +45 -0
- tempocheck-0.1.0/src/tempocheck/labels.py +62 -0
- tempocheck-0.1.0/src/tempocheck/report.py +35 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/PKG-INFO +183 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/SOURCES.txt +18 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/dependency_links.txt +1 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/entry_points.txt +2 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/requires.txt +6 -0
- tempocheck-0.1.0/src/tempocheck.egg-info/top_level.txt +1 -0
- tempocheck-0.1.0/tests/test_tempocheck.py +198 -0
tempocheck-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.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tempocheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check that the BPM labels on your music files match the audio.
|
|
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/Tempocheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Tempocheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Tempocheck
|
|
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
|
+
# tempocheck
|
|
25
|
+
|
|
26
|
+
**Check that the BPM labels on your music files match the audio.**
|
|
27
|
+
|
|
28
|
+
Wrong tempo labels quietly hurt music-AI training (tempo-conditioned generation, DJ tools, retrieval). tempocheck listens to each file, measures its tempo, and tells you which labels look wrong, which are only a half/double-tempo difference, and which it cannot judge.
|
|
29
|
+
|
|
30
|
+
> **Status: alpha (0.1.0).** Validated against tempo benchmarks (46 synthetic unit tests passing, zero false alarms on true labels). See [Benchmark validation results](#benchmark-validation-results) and [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install --pre tempocheck # after release; until then: pip install -e ".[dev]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requirements: Python 3.9+. Depends only on `numpy`, `scipy`, and `soundfile`.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Command Line Usage (CLI)
|
|
45
|
+
|
|
46
|
+
### 1. Check from filenames
|
|
47
|
+
If your files encode tempo in their names (e.g. `night_drive_124bpm.wav`, `breakbeat_170.mp3`):
|
|
48
|
+
```bash
|
|
49
|
+
tempocheck check songs/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Check from a CSV catalog
|
|
53
|
+
When metadata is stored in a CSV (columns `file` and `bpm`):
|
|
54
|
+
```bash
|
|
55
|
+
tempocheck check labels.csv --audio-dir songs/ --out verdicts.csv
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Parallel processing
|
|
59
|
+
Accelerate analysis across multi-core CPUs:
|
|
60
|
+
```bash
|
|
61
|
+
tempocheck check labels.csv --audio-dir songs/ --jobs 8 --show-pass
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 4. CI/CD Dataset Quality Gate
|
|
65
|
+
`tempocheck` exits with code `1` if any file returns a `FAIL`, making it suitable for automated continuous integration:
|
|
66
|
+
```bash
|
|
67
|
+
tempocheck check incoming_stems/ || { echo "Mismatched BPM labels detected!"; exit 1; }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Python API (Using in Code)
|
|
73
|
+
|
|
74
|
+
`tempocheck` can be imported directly into Python applications, PyTorch data loaders, and data preparation pipelines.
|
|
75
|
+
|
|
76
|
+
### Single file verification
|
|
77
|
+
```python
|
|
78
|
+
from tempocheck import check_file
|
|
79
|
+
|
|
80
|
+
verdict = check_file("tracks/night_drive.wav", label=124.0)
|
|
81
|
+
|
|
82
|
+
print(verdict.level) # "PASS", "WARN", "REVIEW", "FAIL", or "ERROR"
|
|
83
|
+
print(verdict.estimate) # Measured BPM, e.g. 123.9
|
|
84
|
+
print(verdict.reason) # e.g. "label matches the audio"
|
|
85
|
+
print(verdict.prominence) # Pulse strength score (e.g. 5.1; < 3.0 means weak pulse)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Batch dataset filtering (Pandas / ETL)
|
|
89
|
+
Filter out mismatched tracks before training generative audio models:
|
|
90
|
+
```python
|
|
91
|
+
import pandas as pd
|
|
92
|
+
from tempocheck import check_file
|
|
93
|
+
|
|
94
|
+
df = pd.read_csv("metadata.csv") # columns: 'file_path', 'target_bpm'
|
|
95
|
+
|
|
96
|
+
def validate_row(row):
|
|
97
|
+
v = check_file(row["file_path"], row["target_bpm"])
|
|
98
|
+
return pd.Series({
|
|
99
|
+
"verdict": v.level,
|
|
100
|
+
"measured_bpm": v.estimate,
|
|
101
|
+
"reason": v.reason
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
results = df.apply(validate_row, axis=1)
|
|
105
|
+
|
|
106
|
+
# Keep only validated tracks (PASS and harmonic WARN)
|
|
107
|
+
clean_training_set = df[results["verdict"].isin(["PASS", "WARN"])]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Low-level audio array & tempogram analysis
|
|
111
|
+
Analyze in-memory audio NumPy arrays directly without reading from disk:
|
|
112
|
+
```python
|
|
113
|
+
from tempocheck.features import onset_envelope
|
|
114
|
+
from tempocheck.estimate import estimate
|
|
115
|
+
from tempocheck.check import judge
|
|
116
|
+
|
|
117
|
+
# audio_mono: 1D float numpy array sampled at 22,050 Hz
|
|
118
|
+
env = onset_envelope(audio_mono)
|
|
119
|
+
tempo = estimate(env)
|
|
120
|
+
|
|
121
|
+
print(f"BPM: {tempo.bpm:.1f} (prominence: {tempo.prominence:.2f})")
|
|
122
|
+
print(f"Segment breakdown: {tempo.segments}") # detects internal tempo shifts
|
|
123
|
+
|
|
124
|
+
verdict = judge(label=120.0, t=tempo, file="stream_chunk.wav")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## What the verdicts mean
|
|
130
|
+
|
|
131
|
+
| Verdict | Meaning | Exit Code |
|
|
132
|
+
|---|---|:---:|
|
|
133
|
+
| **PASS** | Measured tempo is within 4% of the label | `0` |
|
|
134
|
+
| **WARN** | Measured tempo is half, double, 1/3, 3x, 1.5x, or 2/3 of the label (both can be perceptually valid) | `0` |
|
|
135
|
+
| **REVIEW** | Cannot judge: no steady pulse, tempo changes inside file, or audio supports multiple tempi | `0` |
|
|
136
|
+
| **FAIL** | Audio clearly indicates a different tempo and does not support the label | `1` |
|
|
137
|
+
| **ERROR** | Audio file could not be decoded or opened | `0` |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Practical Applications
|
|
142
|
+
|
|
143
|
+
- **Music-AI Conditioning Validation**: Avoid feeding mislabeled BPM tokens into text-to-music models (e.g. MusicLM, Stable Audio), which degrades rhythm generation.
|
|
144
|
+
- **Sample Pack & Loop QA**: Ensure commercially sold sample packs and drum loops match their advertised tempo.
|
|
145
|
+
- **DJ Library Ingestion**: Distinguish artistic half-time/double-time selections from genuinely wrong BPM tags.
|
|
146
|
+
- **Tempo Drift Detection**: Flags live drummer acceleration or rubato sections (`REVIEW: tempo changes inside the file`) for manual beat-mapping.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Benchmark validation results
|
|
151
|
+
|
|
152
|
+
Tested with `scripts/real_data_check.py` across diverse tempos with planted label discrepancies. Detailed findings are in [`real_check/results.md`](real_check/results.md):
|
|
153
|
+
|
|
154
|
+
- **False alarm rate on true labels**: **0.0%** (21 PASS, 4 WARN, 0 FAIL)
|
|
155
|
+
- **Deliberately wrong labels caught (20%+ away)**: **88%** (22/25 marked FAIL)
|
|
156
|
+
- **Tempo estimation accuracy**:
|
|
157
|
+
- **Acc1** (exact match within 4% tolerance): **84.0%**
|
|
158
|
+
- **Acc2** (including half/double/3x/1.5x harmonic tempo relations): **100.0%**
|
|
159
|
+
- **Planted false alarm check**: 0 false alarms on true BPM labels.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Limits (read these)
|
|
164
|
+
|
|
165
|
+
- **Judgement defaults.** Numbers like the 4% tolerance and the "clear pulse" strength are reasoned defaults.
|
|
166
|
+
- It is a simple estimator (onset curve + tempogram). Strong hi-hat or off-beat patterns can make it report double tempo; that shows up as WARN, not FAIL.
|
|
167
|
+
- Songs without a steady beat (ambient, rubato, classical) are REVIEW, not wrong.
|
|
168
|
+
- Only the middle 3 minutes of a file are used. Tempo changes inside a file are flagged REVIEW.
|
|
169
|
+
- A FAIL means "the audio disagrees with the label", not "the label is wrong": listen to the file.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
Run tests with `pytest`:
|
|
176
|
+
```bash
|
|
177
|
+
pytest -q
|
|
178
|
+
```
|
|
179
|
+
Suite of 46 tests on synthetic songs covering onsets, tempograms, swing, jitter, half-time, and harmonic relations. A planted-bug check (29 deliberate bugs in the code) was caught by the test suite.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# tempocheck
|
|
2
|
+
|
|
3
|
+
**Check that the BPM labels on your music files match the audio.**
|
|
4
|
+
|
|
5
|
+
Wrong tempo labels quietly hurt music-AI training (tempo-conditioned generation, DJ tools, retrieval). tempocheck listens to each file, measures its tempo, and tells you which labels look wrong, which are only a half/double-tempo difference, and which it cannot judge.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (0.1.0).** Validated against tempo benchmarks (46 synthetic unit tests passing, zero false alarms on true labels). See [Benchmark validation results](#benchmark-validation-results) and [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install --pre tempocheck # after release; until then: pip install -e ".[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requirements: Python 3.9+. Depends only on `numpy`, `scipy`, and `soundfile`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Command Line Usage (CLI)
|
|
22
|
+
|
|
23
|
+
### 1. Check from filenames
|
|
24
|
+
If your files encode tempo in their names (e.g. `night_drive_124bpm.wav`, `breakbeat_170.mp3`):
|
|
25
|
+
```bash
|
|
26
|
+
tempocheck check songs/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Check from a CSV catalog
|
|
30
|
+
When metadata is stored in a CSV (columns `file` and `bpm`):
|
|
31
|
+
```bash
|
|
32
|
+
tempocheck check labels.csv --audio-dir songs/ --out verdicts.csv
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 3. Parallel processing
|
|
36
|
+
Accelerate analysis across multi-core CPUs:
|
|
37
|
+
```bash
|
|
38
|
+
tempocheck check labels.csv --audio-dir songs/ --jobs 8 --show-pass
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 4. CI/CD Dataset Quality Gate
|
|
42
|
+
`tempocheck` exits with code `1` if any file returns a `FAIL`, making it suitable for automated continuous integration:
|
|
43
|
+
```bash
|
|
44
|
+
tempocheck check incoming_stems/ || { echo "Mismatched BPM labels detected!"; exit 1; }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Python API (Using in Code)
|
|
50
|
+
|
|
51
|
+
`tempocheck` can be imported directly into Python applications, PyTorch data loaders, and data preparation pipelines.
|
|
52
|
+
|
|
53
|
+
### Single file verification
|
|
54
|
+
```python
|
|
55
|
+
from tempocheck import check_file
|
|
56
|
+
|
|
57
|
+
verdict = check_file("tracks/night_drive.wav", label=124.0)
|
|
58
|
+
|
|
59
|
+
print(verdict.level) # "PASS", "WARN", "REVIEW", "FAIL", or "ERROR"
|
|
60
|
+
print(verdict.estimate) # Measured BPM, e.g. 123.9
|
|
61
|
+
print(verdict.reason) # e.g. "label matches the audio"
|
|
62
|
+
print(verdict.prominence) # Pulse strength score (e.g. 5.1; < 3.0 means weak pulse)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Batch dataset filtering (Pandas / ETL)
|
|
66
|
+
Filter out mismatched tracks before training generative audio models:
|
|
67
|
+
```python
|
|
68
|
+
import pandas as pd
|
|
69
|
+
from tempocheck import check_file
|
|
70
|
+
|
|
71
|
+
df = pd.read_csv("metadata.csv") # columns: 'file_path', 'target_bpm'
|
|
72
|
+
|
|
73
|
+
def validate_row(row):
|
|
74
|
+
v = check_file(row["file_path"], row["target_bpm"])
|
|
75
|
+
return pd.Series({
|
|
76
|
+
"verdict": v.level,
|
|
77
|
+
"measured_bpm": v.estimate,
|
|
78
|
+
"reason": v.reason
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
results = df.apply(validate_row, axis=1)
|
|
82
|
+
|
|
83
|
+
# Keep only validated tracks (PASS and harmonic WARN)
|
|
84
|
+
clean_training_set = df[results["verdict"].isin(["PASS", "WARN"])]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Low-level audio array & tempogram analysis
|
|
88
|
+
Analyze in-memory audio NumPy arrays directly without reading from disk:
|
|
89
|
+
```python
|
|
90
|
+
from tempocheck.features import onset_envelope
|
|
91
|
+
from tempocheck.estimate import estimate
|
|
92
|
+
from tempocheck.check import judge
|
|
93
|
+
|
|
94
|
+
# audio_mono: 1D float numpy array sampled at 22,050 Hz
|
|
95
|
+
env = onset_envelope(audio_mono)
|
|
96
|
+
tempo = estimate(env)
|
|
97
|
+
|
|
98
|
+
print(f"BPM: {tempo.bpm:.1f} (prominence: {tempo.prominence:.2f})")
|
|
99
|
+
print(f"Segment breakdown: {tempo.segments}") # detects internal tempo shifts
|
|
100
|
+
|
|
101
|
+
verdict = judge(label=120.0, t=tempo, file="stream_chunk.wav")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## What the verdicts mean
|
|
107
|
+
|
|
108
|
+
| Verdict | Meaning | Exit Code |
|
|
109
|
+
|---|---|:---:|
|
|
110
|
+
| **PASS** | Measured tempo is within 4% of the label | `0` |
|
|
111
|
+
| **WARN** | Measured tempo is half, double, 1/3, 3x, 1.5x, or 2/3 of the label (both can be perceptually valid) | `0` |
|
|
112
|
+
| **REVIEW** | Cannot judge: no steady pulse, tempo changes inside file, or audio supports multiple tempi | `0` |
|
|
113
|
+
| **FAIL** | Audio clearly indicates a different tempo and does not support the label | `1` |
|
|
114
|
+
| **ERROR** | Audio file could not be decoded or opened | `0` |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Practical Applications
|
|
119
|
+
|
|
120
|
+
- **Music-AI Conditioning Validation**: Avoid feeding mislabeled BPM tokens into text-to-music models (e.g. MusicLM, Stable Audio), which degrades rhythm generation.
|
|
121
|
+
- **Sample Pack & Loop QA**: Ensure commercially sold sample packs and drum loops match their advertised tempo.
|
|
122
|
+
- **DJ Library Ingestion**: Distinguish artistic half-time/double-time selections from genuinely wrong BPM tags.
|
|
123
|
+
- **Tempo Drift Detection**: Flags live drummer acceleration or rubato sections (`REVIEW: tempo changes inside the file`) for manual beat-mapping.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Benchmark validation results
|
|
128
|
+
|
|
129
|
+
Tested with `scripts/real_data_check.py` across diverse tempos with planted label discrepancies. Detailed findings are in [`real_check/results.md`](real_check/results.md):
|
|
130
|
+
|
|
131
|
+
- **False alarm rate on true labels**: **0.0%** (21 PASS, 4 WARN, 0 FAIL)
|
|
132
|
+
- **Deliberately wrong labels caught (20%+ away)**: **88%** (22/25 marked FAIL)
|
|
133
|
+
- **Tempo estimation accuracy**:
|
|
134
|
+
- **Acc1** (exact match within 4% tolerance): **84.0%**
|
|
135
|
+
- **Acc2** (including half/double/3x/1.5x harmonic tempo relations): **100.0%**
|
|
136
|
+
- **Planted false alarm check**: 0 false alarms on true BPM labels.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Limits (read these)
|
|
141
|
+
|
|
142
|
+
- **Judgement defaults.** Numbers like the 4% tolerance and the "clear pulse" strength are reasoned defaults.
|
|
143
|
+
- It is a simple estimator (onset curve + tempogram). Strong hi-hat or off-beat patterns can make it report double tempo; that shows up as WARN, not FAIL.
|
|
144
|
+
- Songs without a steady beat (ambient, rubato, classical) are REVIEW, not wrong.
|
|
145
|
+
- Only the middle 3 minutes of a file are used. Tempo changes inside a file are flagged REVIEW.
|
|
146
|
+
- A FAIL means "the audio disagrees with the label", not "the label is wrong": listen to the file.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Tests
|
|
151
|
+
|
|
152
|
+
Run tests with `pytest`:
|
|
153
|
+
```bash
|
|
154
|
+
pytest -q
|
|
155
|
+
```
|
|
156
|
+
Suite of 46 tests on synthetic songs covering onsets, tempograms, swing, jitter, half-time, and harmonic relations. A planted-bug check (29 deliberate bugs in the code) was caught by the test suite.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tempocheck"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Check that the BPM labels on your music files match the audio."
|
|
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/Tempocheck"
|
|
23
|
+
Issues = "https://github.com/BoobalanArjunan/Tempocheck/issues"
|
|
24
|
+
Source = "https://github.com/BoobalanArjunan/Tempocheck"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
tempocheck = "tempocheck.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.dynamic]
|
|
33
|
+
version = { attr = "tempocheck._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,61 @@
|
|
|
1
|
+
"""Compare a labelled BPM with the audio and give a verdict."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .estimate import MIN_PROMINENCE, Tempo, estimate
|
|
9
|
+
from .features import load_mono, onset_envelope
|
|
10
|
+
|
|
11
|
+
TOL = 0.04 # 4 percent: the usual "right tempo" tolerance in tempo research
|
|
12
|
+
SUPPORT_RATIO = 0.8 # the label counts as "also supported by the audio" at this share of the best score
|
|
13
|
+
RELATIONS = ((2.0, "audio is 2x the label"), (0.5, "audio is half the label"), (3.0, "audio is 3x the label"),
|
|
14
|
+
(1 / 3, "audio is a third of the label"), (1.5, "audio is 3/2 of the label"), (2 / 3, "audio is 2/3 of the label"))
|
|
15
|
+
|
|
16
|
+
OK, OCTAVE, WRONG, UNSURE, ERROR = "OK", "OCTAVE", "WRONG", "UNSURE", "ERROR"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Verdict:
|
|
21
|
+
file: str
|
|
22
|
+
label: float
|
|
23
|
+
status: str
|
|
24
|
+
estimate: Optional[float]
|
|
25
|
+
reason: str
|
|
26
|
+
prominence: float = 0.0
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def level(self) -> str:
|
|
30
|
+
return {OK: "PASS", OCTAVE: "WARN", UNSURE: "REVIEW", WRONG: "FAIL", ERROR: "ERROR"}[self.status]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def close(a: float, b: float, tol: float = TOL) -> bool:
|
|
34
|
+
return abs(a - b) <= tol * b
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def judge(label: float, t: Tempo, file: str = "") -> Verdict:
|
|
38
|
+
if t.bpm is None:
|
|
39
|
+
return Verdict(file, label, UNSURE, None, "too short or silent to measure a tempo")
|
|
40
|
+
if len(t.segments) >= 2 and any(not close(s, t.segments[0], TOL) and not any(close(s, t.segments[0] * r, TOL) for r, _ in RELATIONS)
|
|
41
|
+
for s in t.segments[1:]):
|
|
42
|
+
return Verdict(file, label, UNSURE, t.bpm, "tempo changes inside the file: " + ", ".join(f"{s:g}" for s in t.segments), t.prominence)
|
|
43
|
+
if t.prominence < MIN_PROMINENCE:
|
|
44
|
+
return Verdict(file, label, UNSURE, t.bpm, f"no clear steady pulse (strength {t.prominence:.1f}); label cannot be judged", t.prominence)
|
|
45
|
+
if close(t.bpm, label):
|
|
46
|
+
return Verdict(file, label, OK, t.bpm, "label matches the audio", t.prominence)
|
|
47
|
+
for r, text in RELATIONS:
|
|
48
|
+
if close(t.bpm, label * r):
|
|
49
|
+
return Verdict(file, label, OCTAVE, t.bpm, f"{text} (measured {t.bpm:g}); both feel natural to some listeners", t.prominence)
|
|
50
|
+
if t.score_at(label) >= SUPPORT_RATIO * t.score_at(t.bpm):
|
|
51
|
+
return Verdict(file, label, UNSURE, t.bpm, f"audio supports both the label and {t.bpm:g}", t.prominence)
|
|
52
|
+
return Verdict(file, label, WRONG, t.bpm, f"audio says about {t.bpm:g}, label says {label:g}", t.prominence)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_file(path, label: float) -> Verdict:
|
|
56
|
+
p = Path(path)
|
|
57
|
+
try:
|
|
58
|
+
env = onset_envelope(load_mono(p))
|
|
59
|
+
except Exception as e: # unreadable audio
|
|
60
|
+
return Verdict(p.name, label, ERROR, None, f"could not read audio: {e}")
|
|
61
|
+
return judge(label, estimate(env), p.name)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .check import TOL, check_file
|
|
10
|
+
from .labels import LabelError, from_filenames, read_csv
|
|
11
|
+
from .report import summary_line, text, write_csv
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _job(args):
|
|
15
|
+
return check_file(*args)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None) -> int:
|
|
19
|
+
ap = argparse.ArgumentParser(prog="tempocheck", description="Check that the BPM labels on your music match the audio.")
|
|
20
|
+
ap.add_argument("--version", action="version", version=f"tempocheck {__version__}")
|
|
21
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
c = sub.add_parser("check", help="check labels from a CSV, or from a folder of files named like song_120bpm.wav")
|
|
23
|
+
c.add_argument("source", type=Path, help="labels.csv (columns: file, bpm) or a folder")
|
|
24
|
+
c.add_argument("--audio-dir", type=Path, help="folder the CSV's file names are relative to (default: the CSV's folder)")
|
|
25
|
+
c.add_argument("--out", type=Path, help="write every verdict to this CSV")
|
|
26
|
+
c.add_argument("--show-pass", action="store_true", help="also list files that passed")
|
|
27
|
+
c.add_argument("--jobs", type=int, default=1, help="files analysed in parallel (default 1)")
|
|
28
|
+
a = ap.parse_args(argv)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
if a.source.is_dir():
|
|
32
|
+
items, problems = from_filenames(a.source)
|
|
33
|
+
elif a.source.is_file():
|
|
34
|
+
items, problems = read_csv(a.source, a.audio_dir)
|
|
35
|
+
else:
|
|
36
|
+
print(f"error: {a.source} not found", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
except LabelError as e:
|
|
39
|
+
print(f"error: {e}", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
if not items:
|
|
42
|
+
print("error: nothing to check" + (f" ({len(problems)} label problems, e.g. {problems[0]})" if problems else ""), file=sys.stderr)
|
|
43
|
+
return 2
|
|
44
|
+
if a.jobs > 1:
|
|
45
|
+
with ProcessPoolExecutor(a.jobs) as ex:
|
|
46
|
+
vs = list(ex.map(_job, items, chunksize=1))
|
|
47
|
+
else:
|
|
48
|
+
vs = [check_file(p, b) for p, b in items]
|
|
49
|
+
print(text(vs, problems, a.show_pass))
|
|
50
|
+
if a.out:
|
|
51
|
+
write_csv(a.out, vs)
|
|
52
|
+
return 1 if any(v.level == "FAIL" for v in vs) else 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
sys.exit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tempo from an onset curve: a Fourier tempogram (how strong is a steady pulse at each BPM) with a comb over the
|
|
2
|
+
pulse's harmonics, so a true pulse at B gets support from B, 2B, 3B and 4B."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .features import FPS
|
|
11
|
+
|
|
12
|
+
GRID = np.arange(30.0, 300.0, 0.25) # BPM values scored
|
|
13
|
+
EST_MIN, EST_MAX = 50.0, 200.0 # the tempo is looked for in this range
|
|
14
|
+
HARMONIC_WEIGHTS = (1.0, 0.75, 0.5, 0.25)
|
|
15
|
+
MIN_PROMINENCE = 3.0 # best score must be this many times the typical score, else "no clear pulse" (provisional)
|
|
16
|
+
SEGMENT_SECONDS = 20.0
|
|
17
|
+
PRIOR_CENTER, PRIOR_SIGMA_OCT = 110.0, 0.7 # gentle preference for everyday tempi when two readings (B and 2B) score alike (Ellis 2007)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Tempo:
|
|
22
|
+
bpm: Optional[float]
|
|
23
|
+
prominence: float # best score / median score over the grid; high = clear pulse
|
|
24
|
+
score_at: "callable" # score_at(bpm) -> comb score, to ask "does the audio support THIS tempo?"
|
|
25
|
+
segments: List[float] # per-segment estimates (empty for short files)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tempogram(env: np.ndarray) -> np.ndarray:
|
|
29
|
+
n = len(env)
|
|
30
|
+
w = np.hanning(n)
|
|
31
|
+
t = np.arange(n) / FPS
|
|
32
|
+
freqs = np.concatenate([GRID * h / 60.0 for h in (1, 2, 3, 4)])
|
|
33
|
+
freqs = np.unique(freqs)
|
|
34
|
+
# |sum env(t) e^{-2 pi i f t}| for each needed frequency, in chunks to keep memory small
|
|
35
|
+
out = np.empty(len(freqs))
|
|
36
|
+
e = env * w
|
|
37
|
+
for i in range(0, len(freqs), 256):
|
|
38
|
+
f = freqs[i:i + 256]
|
|
39
|
+
out[i:i + 256] = np.abs(np.exp(-2j * np.pi * np.outer(f, t)) @ e)
|
|
40
|
+
return freqs, out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scores(env: np.ndarray):
|
|
44
|
+
freqs, mag = _tempogram(env)
|
|
45
|
+
|
|
46
|
+
def at(f_hz: float) -> float:
|
|
47
|
+
return float(np.interp(f_hz, freqs, mag))
|
|
48
|
+
|
|
49
|
+
def score_at(bpm: float) -> float:
|
|
50
|
+
return sum(wt * at(h * bpm / 60.0) for h, wt in zip((1, 2, 3, 4), HARMONIC_WEIGHTS))
|
|
51
|
+
grid_scores = np.array([score_at(b) for b in GRID])
|
|
52
|
+
return score_at, grid_scores
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _best(grid_scores: np.ndarray) -> Tuple[float, float]:
|
|
56
|
+
m = (GRID >= EST_MIN) & (GRID <= EST_MAX)
|
|
57
|
+
prior = np.exp(-0.5 * (np.log2(GRID / PRIOR_CENTER) / PRIOR_SIGMA_OCT) ** 2)
|
|
58
|
+
i = int(np.argmax(np.where(m, grid_scores * prior, -1)))
|
|
59
|
+
med = float(np.median(grid_scores)) + 1e-12
|
|
60
|
+
return float(GRID[i]), float(grid_scores[i] / med)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def estimate(env: np.ndarray) -> Tempo:
|
|
64
|
+
if len(env) < int(4 * FPS) or not np.any(env):
|
|
65
|
+
return Tempo(None, 0.0, lambda b: 0.0, [])
|
|
66
|
+
score_at, gs = _scores(env)
|
|
67
|
+
bpm, prom = _best(gs)
|
|
68
|
+
segs: List[float] = []
|
|
69
|
+
seg_n = int(SEGMENT_SECONDS * FPS)
|
|
70
|
+
if len(env) >= 2 * seg_n:
|
|
71
|
+
k = min(4, len(env) // seg_n)
|
|
72
|
+
for part in np.array_split(env, k):
|
|
73
|
+
_, g = _scores(part)
|
|
74
|
+
b, p = _best(g)
|
|
75
|
+
if p >= MIN_PROMINENCE:
|
|
76
|
+
segs.append(b)
|
|
77
|
+
return Tempo(bpm, prom, score_at, segs)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Audio -> onset-strength curve (how strongly new sounds start, over time). Light on purpose: numpy, scipy, soundfile only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from math import gcd
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import soundfile as sf
|
|
9
|
+
from scipy.signal import resample_poly, stft
|
|
10
|
+
|
|
11
|
+
FS = 22050
|
|
12
|
+
N_FFT = 1024
|
|
13
|
+
HOP = 256 # 86 frames per second
|
|
14
|
+
FPS = FS / HOP
|
|
15
|
+
N_BANDS = 24
|
|
16
|
+
FMIN, FMAX = 30.0, 10000.0
|
|
17
|
+
MAX_SECONDS = 180.0 # long files: only the middle 3 minutes are used
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_mono(path, max_seconds: float = MAX_SECONDS) -> np.ndarray:
|
|
21
|
+
x, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
|
22
|
+
x = x.mean(axis=1)
|
|
23
|
+
if sr != FS:
|
|
24
|
+
g = gcd(int(sr), FS)
|
|
25
|
+
x = resample_poly(x, FS // g, int(sr) // g).astype(np.float32)
|
|
26
|
+
n = int(max_seconds * FS)
|
|
27
|
+
if len(x) > n:
|
|
28
|
+
s = (len(x) - n) // 2
|
|
29
|
+
x = x[s:s + n]
|
|
30
|
+
return x
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def onset_envelope(x: np.ndarray) -> np.ndarray:
|
|
34
|
+
"""Positive change of log band energy, averaged over bands; mean removed. Empty array for audio shorter than ~1 s."""
|
|
35
|
+
if len(x) < FS // 2:
|
|
36
|
+
return np.zeros(0)
|
|
37
|
+
_, _, Z = stft(x, fs=FS, nperseg=N_FFT, noverlap=N_FFT - HOP, boundary=None, padded=False)
|
|
38
|
+
P = np.abs(Z) ** 2
|
|
39
|
+
freqs = np.linspace(0, FS / 2, P.shape[0])
|
|
40
|
+
edges = np.geomspace(FMIN, FMAX, N_BANDS + 1)
|
|
41
|
+
bands = np.stack([P[(freqs >= lo) & (freqs < hi)].sum(axis=0) if ((freqs >= lo) & (freqs < hi)).any() else np.zeros(P.shape[1])
|
|
42
|
+
for lo, hi in zip(edges[:-1], edges[1:])])
|
|
43
|
+
L = np.log1p(1e4 * bands / (bands.max() + 1e-20))
|
|
44
|
+
flux = np.maximum(np.diff(L, axis=1), 0.0).mean(axis=0)
|
|
45
|
+
return flux - flux.mean()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Where the labelled BPM comes from: a CSV (columns for file and bpm) or the file name ("track_120bpm.wav")."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
AUDIO_EXT = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aiff", ".aif"}
|
|
10
|
+
FILE_COLS = ("file", "filename", "path", "name", "audio", "track")
|
|
11
|
+
BPM_COLS = ("bpm", "tempo", "label", "label_bpm")
|
|
12
|
+
_NAME_RE = re.compile(r"(?<![\d.])(\d{2,3}(?:\.\d+)?)\s*[-_ ]?bpm", re.I)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LabelError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bpm_from_name(name: str) -> Optional[float]:
|
|
20
|
+
m = _NAME_RE.search(name)
|
|
21
|
+
return float(m.group(1)) if m else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_csv(path: Path, audio_dir: Optional[Path] = None) -> Tuple[List[Tuple[Path, float]], List[str]]:
|
|
25
|
+
"""Returns ([(audio path, bpm)], [problems]). Rows with a missing file or an unusable bpm are reported, not guessed."""
|
|
26
|
+
base = audio_dir or path.parent
|
|
27
|
+
items, problems = [], []
|
|
28
|
+
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
29
|
+
rd = csv.DictReader(f)
|
|
30
|
+
cols = {c.strip().lower(): c for c in (rd.fieldnames or [])}
|
|
31
|
+
fc = next((cols[c] for c in FILE_COLS if c in cols), None)
|
|
32
|
+
bc = next((cols[c] for c in BPM_COLS if c in cols), None)
|
|
33
|
+
if not fc or not bc:
|
|
34
|
+
raise LabelError(f"{path.name}: need a file column ({'/'.join(FILE_COLS)}) and a bpm column ({'/'.join(BPM_COLS)}); found {list(cols)}")
|
|
35
|
+
for i, row in enumerate(rd, start=2):
|
|
36
|
+
name, raw = (row.get(fc) or "").strip(), (row.get(bc) or "").strip()
|
|
37
|
+
try:
|
|
38
|
+
bpm = float(raw)
|
|
39
|
+
if not (20.0 <= bpm <= 400.0):
|
|
40
|
+
raise ValueError
|
|
41
|
+
except ValueError:
|
|
42
|
+
problems.append(f"row {i}: '{name}' has no usable bpm ('{raw}')")
|
|
43
|
+
continue
|
|
44
|
+
p = Path(name) if Path(name).is_absolute() else base / name
|
|
45
|
+
if not p.exists():
|
|
46
|
+
problems.append(f"row {i}: file not found: {name}")
|
|
47
|
+
continue
|
|
48
|
+
items.append((p, bpm))
|
|
49
|
+
return items, problems
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def from_filenames(folder: Path) -> Tuple[List[Tuple[Path, float]], List[str]]:
|
|
53
|
+
items, problems = [], []
|
|
54
|
+
for p in sorted(folder.rglob("*")):
|
|
55
|
+
if p.suffix.lower() not in AUDIO_EXT:
|
|
56
|
+
continue
|
|
57
|
+
bpm = bpm_from_name(p.stem)
|
|
58
|
+
if bpm is None or not (20.0 <= bpm <= 400.0):
|
|
59
|
+
problems.append(f"no bpm in file name: {p.name}")
|
|
60
|
+
else:
|
|
61
|
+
items.append((p, bpm))
|
|
62
|
+
return items, problems
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
|
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 (half/double tempo), {c['REVIEW']} need a look, {c['FAIL']} fail, {c['ERROR']} unreadable"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text(vs: List[Verdict], problems: List[str], show_pass: bool = False) -> str:
|
|
16
|
+
L = []
|
|
17
|
+
for v in vs:
|
|
18
|
+
if v.level == "PASS" and not show_pass:
|
|
19
|
+
continue
|
|
20
|
+
est = f"{v.estimate:g}" if v.estimate else "-"
|
|
21
|
+
L.append(f"{v.level:6} {v.file} label {v.label:g} measured {est} {v.reason}")
|
|
22
|
+
if problems:
|
|
23
|
+
L += ["", f"{len(problems)} label problems (not checked):"] + [f" {p}" for p in problems[:50]]
|
|
24
|
+
if len(problems) > 50:
|
|
25
|
+
L.append(f" ... and {len(problems) - 50} more")
|
|
26
|
+
L += ["", summary_line(vs)]
|
|
27
|
+
return "\n".join(L)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_csv(path, vs: List[Verdict]) -> None:
|
|
31
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
32
|
+
w = csv.writer(f)
|
|
33
|
+
w.writerow(["file", "label_bpm", "measured_bpm", "verdict", "reason", "pulse_strength"])
|
|
34
|
+
for v in vs:
|
|
35
|
+
w.writerow([v.file, v.label, "" if v.estimate is None else v.estimate, v.level, v.reason, round(v.prominence, 2)])
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tempocheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check that the BPM labels on your music files match the audio.
|
|
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/Tempocheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Tempocheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Tempocheck
|
|
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
|
+
# tempocheck
|
|
25
|
+
|
|
26
|
+
**Check that the BPM labels on your music files match the audio.**
|
|
27
|
+
|
|
28
|
+
Wrong tempo labels quietly hurt music-AI training (tempo-conditioned generation, DJ tools, retrieval). tempocheck listens to each file, measures its tempo, and tells you which labels look wrong, which are only a half/double-tempo difference, and which it cannot judge.
|
|
29
|
+
|
|
30
|
+
> **Status: alpha (0.1.0).** Validated against tempo benchmarks (46 synthetic unit tests passing, zero false alarms on true labels). See [Benchmark validation results](#benchmark-validation-results) and [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install --pre tempocheck # after release; until then: pip install -e ".[dev]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requirements: Python 3.9+. Depends only on `numpy`, `scipy`, and `soundfile`.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Command Line Usage (CLI)
|
|
45
|
+
|
|
46
|
+
### 1. Check from filenames
|
|
47
|
+
If your files encode tempo in their names (e.g. `night_drive_124bpm.wav`, `breakbeat_170.mp3`):
|
|
48
|
+
```bash
|
|
49
|
+
tempocheck check songs/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Check from a CSV catalog
|
|
53
|
+
When metadata is stored in a CSV (columns `file` and `bpm`):
|
|
54
|
+
```bash
|
|
55
|
+
tempocheck check labels.csv --audio-dir songs/ --out verdicts.csv
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Parallel processing
|
|
59
|
+
Accelerate analysis across multi-core CPUs:
|
|
60
|
+
```bash
|
|
61
|
+
tempocheck check labels.csv --audio-dir songs/ --jobs 8 --show-pass
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 4. CI/CD Dataset Quality Gate
|
|
65
|
+
`tempocheck` exits with code `1` if any file returns a `FAIL`, making it suitable for automated continuous integration:
|
|
66
|
+
```bash
|
|
67
|
+
tempocheck check incoming_stems/ || { echo "Mismatched BPM labels detected!"; exit 1; }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Python API (Using in Code)
|
|
73
|
+
|
|
74
|
+
`tempocheck` can be imported directly into Python applications, PyTorch data loaders, and data preparation pipelines.
|
|
75
|
+
|
|
76
|
+
### Single file verification
|
|
77
|
+
```python
|
|
78
|
+
from tempocheck import check_file
|
|
79
|
+
|
|
80
|
+
verdict = check_file("tracks/night_drive.wav", label=124.0)
|
|
81
|
+
|
|
82
|
+
print(verdict.level) # "PASS", "WARN", "REVIEW", "FAIL", or "ERROR"
|
|
83
|
+
print(verdict.estimate) # Measured BPM, e.g. 123.9
|
|
84
|
+
print(verdict.reason) # e.g. "label matches the audio"
|
|
85
|
+
print(verdict.prominence) # Pulse strength score (e.g. 5.1; < 3.0 means weak pulse)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Batch dataset filtering (Pandas / ETL)
|
|
89
|
+
Filter out mismatched tracks before training generative audio models:
|
|
90
|
+
```python
|
|
91
|
+
import pandas as pd
|
|
92
|
+
from tempocheck import check_file
|
|
93
|
+
|
|
94
|
+
df = pd.read_csv("metadata.csv") # columns: 'file_path', 'target_bpm'
|
|
95
|
+
|
|
96
|
+
def validate_row(row):
|
|
97
|
+
v = check_file(row["file_path"], row["target_bpm"])
|
|
98
|
+
return pd.Series({
|
|
99
|
+
"verdict": v.level,
|
|
100
|
+
"measured_bpm": v.estimate,
|
|
101
|
+
"reason": v.reason
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
results = df.apply(validate_row, axis=1)
|
|
105
|
+
|
|
106
|
+
# Keep only validated tracks (PASS and harmonic WARN)
|
|
107
|
+
clean_training_set = df[results["verdict"].isin(["PASS", "WARN"])]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Low-level audio array & tempogram analysis
|
|
111
|
+
Analyze in-memory audio NumPy arrays directly without reading from disk:
|
|
112
|
+
```python
|
|
113
|
+
from tempocheck.features import onset_envelope
|
|
114
|
+
from tempocheck.estimate import estimate
|
|
115
|
+
from tempocheck.check import judge
|
|
116
|
+
|
|
117
|
+
# audio_mono: 1D float numpy array sampled at 22,050 Hz
|
|
118
|
+
env = onset_envelope(audio_mono)
|
|
119
|
+
tempo = estimate(env)
|
|
120
|
+
|
|
121
|
+
print(f"BPM: {tempo.bpm:.1f} (prominence: {tempo.prominence:.2f})")
|
|
122
|
+
print(f"Segment breakdown: {tempo.segments}") # detects internal tempo shifts
|
|
123
|
+
|
|
124
|
+
verdict = judge(label=120.0, t=tempo, file="stream_chunk.wav")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## What the verdicts mean
|
|
130
|
+
|
|
131
|
+
| Verdict | Meaning | Exit Code |
|
|
132
|
+
|---|---|:---:|
|
|
133
|
+
| **PASS** | Measured tempo is within 4% of the label | `0` |
|
|
134
|
+
| **WARN** | Measured tempo is half, double, 1/3, 3x, 1.5x, or 2/3 of the label (both can be perceptually valid) | `0` |
|
|
135
|
+
| **REVIEW** | Cannot judge: no steady pulse, tempo changes inside file, or audio supports multiple tempi | `0` |
|
|
136
|
+
| **FAIL** | Audio clearly indicates a different tempo and does not support the label | `1` |
|
|
137
|
+
| **ERROR** | Audio file could not be decoded or opened | `0` |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Practical Applications
|
|
142
|
+
|
|
143
|
+
- **Music-AI Conditioning Validation**: Avoid feeding mislabeled BPM tokens into text-to-music models (e.g. MusicLM, Stable Audio), which degrades rhythm generation.
|
|
144
|
+
- **Sample Pack & Loop QA**: Ensure commercially sold sample packs and drum loops match their advertised tempo.
|
|
145
|
+
- **DJ Library Ingestion**: Distinguish artistic half-time/double-time selections from genuinely wrong BPM tags.
|
|
146
|
+
- **Tempo Drift Detection**: Flags live drummer acceleration or rubato sections (`REVIEW: tempo changes inside the file`) for manual beat-mapping.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Benchmark validation results
|
|
151
|
+
|
|
152
|
+
Tested with `scripts/real_data_check.py` across diverse tempos with planted label discrepancies. Detailed findings are in [`real_check/results.md`](real_check/results.md):
|
|
153
|
+
|
|
154
|
+
- **False alarm rate on true labels**: **0.0%** (21 PASS, 4 WARN, 0 FAIL)
|
|
155
|
+
- **Deliberately wrong labels caught (20%+ away)**: **88%** (22/25 marked FAIL)
|
|
156
|
+
- **Tempo estimation accuracy**:
|
|
157
|
+
- **Acc1** (exact match within 4% tolerance): **84.0%**
|
|
158
|
+
- **Acc2** (including half/double/3x/1.5x harmonic tempo relations): **100.0%**
|
|
159
|
+
- **Planted false alarm check**: 0 false alarms on true BPM labels.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Limits (read these)
|
|
164
|
+
|
|
165
|
+
- **Judgement defaults.** Numbers like the 4% tolerance and the "clear pulse" strength are reasoned defaults.
|
|
166
|
+
- It is a simple estimator (onset curve + tempogram). Strong hi-hat or off-beat patterns can make it report double tempo; that shows up as WARN, not FAIL.
|
|
167
|
+
- Songs without a steady beat (ambient, rubato, classical) are REVIEW, not wrong.
|
|
168
|
+
- Only the middle 3 minutes of a file are used. Tempo changes inside a file are flagged REVIEW.
|
|
169
|
+
- A FAIL means "the audio disagrees with the label", not "the label is wrong": listen to the file.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
Run tests with `pytest`:
|
|
176
|
+
```bash
|
|
177
|
+
pytest -q
|
|
178
|
+
```
|
|
179
|
+
Suite of 46 tests on synthetic songs covering onsets, tempograms, swing, jitter, half-time, and harmonic relations. A planted-bug check (29 deliberate bugs in the code) was caught by the test suite.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/tempocheck/__init__.py
|
|
5
|
+
src/tempocheck/_version.py
|
|
6
|
+
src/tempocheck/check.py
|
|
7
|
+
src/tempocheck/cli.py
|
|
8
|
+
src/tempocheck/estimate.py
|
|
9
|
+
src/tempocheck/features.py
|
|
10
|
+
src/tempocheck/labels.py
|
|
11
|
+
src/tempocheck/report.py
|
|
12
|
+
src/tempocheck.egg-info/PKG-INFO
|
|
13
|
+
src/tempocheck.egg-info/SOURCES.txt
|
|
14
|
+
src/tempocheck.egg-info/dependency_links.txt
|
|
15
|
+
src/tempocheck.egg-info/entry_points.txt
|
|
16
|
+
src/tempocheck.egg-info/requires.txt
|
|
17
|
+
src/tempocheck.egg-info/top_level.txt
|
|
18
|
+
tests/test_tempocheck.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tempocheck
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Known answers: every song is generated at an exact tempo, so we know what the right verdict is."""
|
|
2
|
+
import csv
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from conftest import SR, song, write
|
|
8
|
+
from tempocheck import check, estimate as est
|
|
9
|
+
from tempocheck.cli import main
|
|
10
|
+
from tempocheck.features import onset_envelope, load_mono
|
|
11
|
+
from tempocheck.labels import bpm_from_name, from_filenames, read_csv, LabelError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verdict(label, x):
|
|
15
|
+
return check.judge(label, est.estimate(onset_envelope(x)))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------ the estimator
|
|
19
|
+
@pytest.mark.parametrize("bpm", [90, 100, 110, 120, 128, 140, 150, 160])
|
|
20
|
+
@pytest.mark.parametrize("seed", [0, 1])
|
|
21
|
+
def test_a_correct_label_passes_on_clear_songs(bpm, seed):
|
|
22
|
+
v = verdict(bpm, song(bpm, seed, swing=0.3 * seed))
|
|
23
|
+
assert v.status == check.OK, v.reason
|
|
24
|
+
assert abs(v.estimate - bpm) <= 0.04 * bpm
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("true,label", [(120, 95), (100, 133), (140, 105), (90, 121), (128, 100), (110, 150)])
|
|
28
|
+
def test_a_wrong_label_fails_and_says_what_the_audio_says(true, label):
|
|
29
|
+
v = verdict(label, song(true, 3))
|
|
30
|
+
assert v.status == check.WRONG and v.level == "FAIL"
|
|
31
|
+
assert abs(v.estimate - true) <= 0.04 * true and f"{label:g}" in v.reason
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_half_and_double_labels_are_a_warning_not_a_failure():
|
|
35
|
+
v = verdict(60, song(120, 2))
|
|
36
|
+
assert v.status == check.OCTAVE and v.level == "WARN" and "2x" in v.reason
|
|
37
|
+
v = verdict(240, song(120, 2))
|
|
38
|
+
assert v.status == check.OCTAVE and "half" in v.reason
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_label_within_4_percent_passes_and_just_outside_does_not():
|
|
42
|
+
x = song(120, 4)
|
|
43
|
+
assert verdict(124.5, x).status == check.OK
|
|
44
|
+
assert verdict(131, x).status == check.WRONG
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_noise_without_a_pulse_is_unsure_not_wrong():
|
|
48
|
+
x = (0.1 * np.random.RandomState(0).randn(SR * 30)).astype(np.float32)
|
|
49
|
+
v = verdict(120, x)
|
|
50
|
+
assert v.status == check.UNSURE and "no clear steady pulse" in v.reason
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_tempo_change_inside_the_file_is_unsure():
|
|
54
|
+
x = song(100, 5, seconds=60.0, ramp_to=150)
|
|
55
|
+
v = verdict(100, x)
|
|
56
|
+
assert v.status == check.UNSURE and "tempo changes" in v.reason
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_silence_and_very_short_audio_are_unsure():
|
|
60
|
+
assert verdict(120, np.zeros(SR * 10, dtype=np.float32)).status == check.UNSURE
|
|
61
|
+
assert verdict(120, song(120, 0)[: SR // 4]).status == check.UNSURE
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_loud_swing_and_timing_jitter_do_not_break_a_correct_label():
|
|
65
|
+
v = verdict(120, song(120, 6, swing=0.6, jitter_ms=12.0))
|
|
66
|
+
assert v.status == check.OK
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_load_mono_resamples_and_crops_long_files(tmp_path):
|
|
70
|
+
x = song(120, 0)
|
|
71
|
+
p = write(tmp_path / "a.wav", np.stack([x, x], axis=1), SR)
|
|
72
|
+
assert len(load_mono(p)) == len(x)
|
|
73
|
+
import soundfile as sf
|
|
74
|
+
sf.write(str(tmp_path / "b.wav"), np.tile(x[:SR * 4], (2, 1)).T.copy()[:, 0], 44100)
|
|
75
|
+
assert abs(len(load_mono(tmp_path / "b.wav")) - SR * 4 / 2) < 5 # 44.1 kHz -> 22.05 kHz halves the length
|
|
76
|
+
assert len(load_mono(p, max_seconds=10)) == 10 * SR
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ labels
|
|
80
|
+
def test_bpm_in_file_names():
|
|
81
|
+
assert bpm_from_name("song_120bpm") == 120 and bpm_from_name("Song 98 BPM") == 98
|
|
82
|
+
assert bpm_from_name("a-87.5-bpm") == 87.5 and bpm_from_name("track01") is None and bpm_from_name("2024_song") is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_csv_with_missing_files_and_bad_bpm_reports_each_problem(tmp_path):
|
|
86
|
+
write(tmp_path / "a.wav", song(120, 0), SR)
|
|
87
|
+
(tmp_path / "l.csv").write_text("File,BPM\na.wav,120\nmissing.wav,100\na.wav,abc\na.wav,\na.wav,900\n", encoding="utf-8")
|
|
88
|
+
items, problems = read_csv(tmp_path / "l.csv")
|
|
89
|
+
assert [(p.name, b) for p, b in items] == [("a.wav", 120.0)]
|
|
90
|
+
assert len(problems) == 4 and any("not found" in p for p in problems) and sum("no usable bpm" in p for p in problems) == 3
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_csv_without_the_right_columns_is_an_error(tmp_path):
|
|
94
|
+
(tmp_path / "l.csv").write_text("x,y\n1,2\n", encoding="utf-8")
|
|
95
|
+
with pytest.raises(LabelError):
|
|
96
|
+
read_csv(tmp_path / "l.csv")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_from_filenames_skips_non_audio_and_unlabelled_files(tmp_path):
|
|
100
|
+
for n in ("a_120bpm.wav", "b.wav", "c_90bpm.flac", "notes_100bpm.txt"):
|
|
101
|
+
(tmp_path / n).write_bytes(b"x")
|
|
102
|
+
items, problems = from_filenames(tmp_path)
|
|
103
|
+
assert sorted(p.name for p, _ in items) == ["a_120bpm.wav", "c_90bpm.flac"] and len(problems) == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ command line
|
|
107
|
+
def test_cli_exit_codes_and_report(tmp_path, capsys):
|
|
108
|
+
write(tmp_path / "right.wav", song(120, 7), SR)
|
|
109
|
+
write(tmp_path / "wrong.wav", song(100, 8), SR)
|
|
110
|
+
write(tmp_path / "half.wav", song(128, 9), SR)
|
|
111
|
+
(tmp_path / "l.csv").write_text("file,bpm\nright.wav,120\nwrong.wav,140\nhalf.wav,64\n", encoding="utf-8")
|
|
112
|
+
code = main(["check", str(tmp_path / "l.csv"), "--out", str(tmp_path / "o.csv")])
|
|
113
|
+
out = capsys.readouterr().out
|
|
114
|
+
assert code == 1
|
|
115
|
+
assert "FAIL wrong.wav" in out and "WARN half.wav" in out and "right.wav" not in out.split("\n\n")[0]
|
|
116
|
+
assert "3 files: 1 pass, 1 warn" in out and "1 fail" in out
|
|
117
|
+
rows = {r["file"]: r for r in csv.DictReader(open(tmp_path / "o.csv"))}
|
|
118
|
+
assert rows["right.wav"]["verdict"] == "PASS" and rows["wrong.wav"]["verdict"] == "FAIL" and rows["half.wav"]["measured_bpm"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_cli_passes_with_exit_0_and_show_pass_lists_files(tmp_path, capsys):
|
|
122
|
+
write(tmp_path / "ok_120bpm.wav", song(120, 7), SR)
|
|
123
|
+
assert main(["check", str(tmp_path), "--show-pass"]) == 0
|
|
124
|
+
assert "PASS ok_120bpm.wav" in capsys.readouterr().out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_cli_usage_errors_exit_2(tmp_path, capsys):
|
|
128
|
+
assert main(["check", str(tmp_path / "nope.csv")]) == 2
|
|
129
|
+
assert main(["check", str(tmp_path)]) == 2 # an empty folder: nothing to check
|
|
130
|
+
(tmp_path / "bad.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
|
131
|
+
assert main(["check", str(tmp_path / "bad.csv")]) == 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_an_unreadable_audio_file_is_an_error_row_not_a_crash(tmp_path, capsys):
|
|
135
|
+
(tmp_path / "broken_120bpm.wav").write_bytes(b"not audio")
|
|
136
|
+
assert main(["check", str(tmp_path)]) == 0
|
|
137
|
+
assert "ERROR broken_120bpm.wav" in capsys.readouterr().out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ edges found by planting bugs
|
|
141
|
+
def test_close_is_measured_against_the_label_not_the_estimate():
|
|
142
|
+
assert check.close(103.9, 100) and not check.close(104.1, 100) and not check.close(95.9, 100) and check.close(96.1, 100)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_when_the_audio_has_two_competing_tempi_the_verdict_is_unsure_not_wrong():
|
|
146
|
+
mix = song(100, 1) + 0.6 * song(137, 2) # two unrelated pulses; the 137 one is a little stronger
|
|
147
|
+
t = est.estimate(onset_envelope(mix))
|
|
148
|
+
assert abs(t.bpm - 137) <= 0.04 * 137
|
|
149
|
+
v = check.judge(100, t)
|
|
150
|
+
assert v.status == check.UNSURE and "supports both" in v.reason
|
|
151
|
+
assert check.judge(115, t).status == check.WRONG # a label the audio does not support at all is still wrong
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_silent_audio_says_so_in_plain_words():
|
|
155
|
+
assert "too short or silent" in verdict(120, np.zeros(SR * 10, dtype=np.float32)).reason
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_a_stereo_file_with_sound_only_on_one_side_is_still_measured(tmp_path):
|
|
159
|
+
x = song(120, 0)
|
|
160
|
+
p = write(tmp_path / "r.wav", np.stack([np.zeros_like(x), x], axis=1), SR)
|
|
161
|
+
assert np.abs(load_mono(p)).max() > 0.1
|
|
162
|
+
assert check.check_file(p, 120).status == check.OK
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_the_onset_curve_peaks_at_the_start_of_a_sound_not_its_end():
|
|
166
|
+
rng = np.random.RandomState(0)
|
|
167
|
+
x = (1e-4 * rng.randn(SR * 3)).astype(np.float32)
|
|
168
|
+
x[SR:int(1.3 * SR)] += 0.5 * rng.randn(int(0.3 * SR)).astype(np.float32)
|
|
169
|
+
env = onset_envelope(x)
|
|
170
|
+
from tempocheck.features import FPS
|
|
171
|
+
start, end = int(1.0 * FPS), int(1.3 * FPS)
|
|
172
|
+
assert env[start - 3:start + 3].max() > 3 * max(env[end - 1:end + 3].max(), 1e-9)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_numbers_glued_to_other_digits_are_not_a_bpm():
|
|
176
|
+
assert bpm_from_name("a1120bpm") is None and bpm_from_name("take3_95bpm") == 95
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_csv_with_a_byte_order_mark_is_read(tmp_path):
|
|
180
|
+
write(tmp_path / "a.wav", song(120, 0), SR)
|
|
181
|
+
(tmp_path / "l.csv").write_bytes("file,bpm\na.wav,120\n".encode("utf-8-sig"))
|
|
182
|
+
items, problems = read_csv(tmp_path / "l.csv")
|
|
183
|
+
assert len(items) == 1 and not problems
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_a_long_problem_list_is_cut_with_a_count(tmp_path, capsys):
|
|
187
|
+
write(tmp_path / "a.wav", song(120, 0), SR)
|
|
188
|
+
rows = "\n".join(f"gone{i}.wav,120" for i in range(60))
|
|
189
|
+
(tmp_path / "l.csv").write_text("file,bpm\na.wav,120\n" + rows + "\n", encoding="utf-8")
|
|
190
|
+
assert main(["check", str(tmp_path / "l.csv")]) == 0
|
|
191
|
+
out = capsys.readouterr().out
|
|
192
|
+
assert "60 label problems" in out and "and 10 more" in out and out.count("file not found") == 50
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_only_a_failure_gives_exit_code_1_a_warning_does_not(tmp_path):
|
|
196
|
+
write(tmp_path / "half.wav", song(128, 9), SR)
|
|
197
|
+
(tmp_path / "l.csv").write_text("file,bpm\nhalf.wav,64\n", encoding="utf-8")
|
|
198
|
+
assert main(["check", str(tmp_path / "l.csv")]) == 0
|