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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """tempocheck: does the BPM label on a music file match the audio?"""
2
+ from ._version import __version__
3
+ from .check import Verdict, check_file, judge
4
+
5
+ __all__ = ["__version__", "Verdict", "check_file", "judge"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ tempocheck = tempocheck.cli:main
@@ -0,0 +1,6 @@
1
+ numpy>=1.23
2
+ scipy>=1.9
3
+ soundfile>=0.12
4
+
5
+ [dev]
6
+ pytest
@@ -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