tempocheck 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
tempocheck/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tempocheck/check.py ADDED
@@ -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)
tempocheck/cli.py ADDED
@@ -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())
tempocheck/estimate.py ADDED
@@ -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)
tempocheck/features.py ADDED
@@ -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()
tempocheck/labels.py ADDED
@@ -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
tempocheck/report.py ADDED
@@ -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,14 @@
1
+ tempocheck/__init__.py,sha256=swy7Pgj4fobSPZMDuSVa60C74-NbK2utj46d-k4jfAI,211
2
+ tempocheck/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ tempocheck/check.py,sha256=UswBtns09DmJPIxGzRJBHPcMVb1Zo3loVxGZU0uDdBU,2809
4
+ tempocheck/cli.py,sha256=0GIZBnT5uhCaa0KMmEfoRrYF6UHPUBSFerFVDqnqqJs,2216
5
+ tempocheck/estimate.py,sha256=ZM5koPQvEgUtmC7rkrRo35nY8aK6np4L8452zCB92pY,3007
6
+ tempocheck/features.py,sha256=hL5gZdjiy-rajtzVQ6Xy0ebWnRor5GlhmRVLmekhTk8,1683
7
+ tempocheck/labels.py,sha256=YvYwiJcXUX-P351LiNA5-STuh7LT9xkNEIN4Xo7zYuw,2535
8
+ tempocheck/report.py,sha256=dKZqiPGffaekv7Bp12uTTI91LUFjXbj6Tsbpeiouwi8,1346
9
+ tempocheck-0.1.0.dist-info/licenses/LICENSE,sha256=brmceFF8Uw_1KeQBOshibuDU12xBGtsi2Y4mzSoRlgE,1462
10
+ tempocheck-0.1.0.dist-info/METADATA,sha256=1_b6xJGowymM8FI5VarH4_qXwAOtXYaPW01NXsz8QBw,6847
11
+ tempocheck-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ tempocheck-0.1.0.dist-info/entry_points.txt,sha256=9B1hXc9qSh5b7SqKjE9Qe1xQI-2bmUo4q54gLhIHUys,51
13
+ tempocheck-0.1.0.dist-info/top_level.txt,sha256=ge_y-er0PGW99ujhNmr5uvkhr9VUOBQDWkcYhJpizGc,11
14
+ tempocheck-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tempocheck = tempocheck.cli:main
@@ -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 @@
1
+ tempocheck