tonicfind 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.
- tonicfind-0.1.0/LICENSE +29 -0
- tonicfind-0.1.0/PKG-INFO +231 -0
- tonicfind-0.1.0/README.md +200 -0
- tonicfind-0.1.0/pyproject.toml +51 -0
- tonicfind-0.1.0/setup.cfg +4 -0
- tonicfind-0.1.0/src/tonicfind/__init__.py +21 -0
- tonicfind-0.1.0/src/tonicfind/_version.py +1 -0
- tonicfind-0.1.0/src/tonicfind/check.py +107 -0
- tonicfind-0.1.0/src/tonicfind/cli.py +60 -0
- tonicfind-0.1.0/src/tonicfind/estimate.py +73 -0
- tonicfind-0.1.0/src/tonicfind/features.py +81 -0
- tonicfind-0.1.0/src/tonicfind/labels.py +102 -0
- tonicfind-0.1.0/src/tonicfind/report.py +35 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/PKG-INFO +231 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/SOURCES.txt +18 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/dependency_links.txt +1 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/entry_points.txt +2 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/requires.txt +6 -0
- tonicfind-0.1.0/src/tonicfind.egg-info/top_level.txt +1 -0
- tonicfind-0.1.0/tests/test_tonicfind.py +407 -0
tonicfind-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.
|
tonicfind-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tonicfind
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check that the key labels on your music files (C major, Am, 8A) 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/Tonicfind
|
|
9
|
+
Project-URL: Repository, https://github.com/BoobalanArjunan/Tonicfind.git
|
|
10
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Tonicfind/issues
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.23
|
|
26
|
+
Requires-Dist: scipy>=1.9
|
|
27
|
+
Requires-Dist: soundfile>=0.12
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# tonicfind
|
|
33
|
+
|
|
34
|
+
**Check that the key labels on your music files match the audio.**
|
|
35
|
+
|
|
36
|
+
Wrong key labels quietly hurt music-AI training (key-conditioned generation, harmonic mixing, retrieval). `tonicfind` listens to each file, finds its key, and tells you which labels look wrong, which are only a commonly confused neighbour (relative, parallel, a fifth away), and which it cannot judge.
|
|
37
|
+
|
|
38
|
+
> **Status: Production-Ready (v0.1.0).** Calibrated and validated against key benchmarks and real studio mixes (120 tests passing, zero false alarms on true labels). See [Benchmark validation results](#benchmark-validation-results).
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install tonicfind # or editable: pip install -e ".[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requirements: Python 3.9+. Depends only on `numpy`, `scipy`, and `soundfile`.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 3-Line Python Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import tonicfind
|
|
56
|
+
|
|
57
|
+
verdict = tonicfind.verify_file("song.wav", "Am")
|
|
58
|
+
print(f"{verdict.level}: {verdict.reason}") # PASS: label matches the audio
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Python API (Using in Code)
|
|
64
|
+
|
|
65
|
+
`tonicfind` can be imported directly into Python applications, PyTorch data loaders, and audio curation pipelines.
|
|
66
|
+
|
|
67
|
+
### 1. Verify audio against a key label
|
|
68
|
+
```python
|
|
69
|
+
from tonicfind import verify_file
|
|
70
|
+
|
|
71
|
+
verdict = verify_file("tracks/night_drive.wav", label="C# minor")
|
|
72
|
+
|
|
73
|
+
print(verdict.level) # "PASS", "WARN", "REVIEW", "FAIL", or "ERROR"
|
|
74
|
+
print(verdict.estimate) # Measured key, e.g. "C# minor"
|
|
75
|
+
print(verdict.reason) # e.g. "label matches the audio"
|
|
76
|
+
print(verdict.clarity) # Key correlation clarity (0.0 to 1.0; < 0.55 is ambiguous)
|
|
77
|
+
print(verdict.tuning_cents) # Detune offset from A440 in cents (e.g. -4.5c)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Estimate the musical key of any audio file
|
|
81
|
+
```python
|
|
82
|
+
from tonicfind import estimate_file, key_name
|
|
83
|
+
|
|
84
|
+
key_info = estimate_file("tracks/melody.wav")
|
|
85
|
+
|
|
86
|
+
print(key_name(key_info.key)) # e.g. "G major"
|
|
87
|
+
print(f"Clarity: {key_info.score:.2f}")
|
|
88
|
+
print(f"Segments: {[key_name(s) for s in key_info.segments]}") # Tracks key across 3rds of song
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Batch dataset filtering (Pandas / ETL)
|
|
92
|
+
Filter out mismatched tracks before training key-conditioned generative audio models:
|
|
93
|
+
```python
|
|
94
|
+
import pandas as pd
|
|
95
|
+
from tonicfind import verify_file
|
|
96
|
+
|
|
97
|
+
df = pd.read_csv("catalog.csv") # columns: 'file_path', 'key_label'
|
|
98
|
+
|
|
99
|
+
def validate_row(row):
|
|
100
|
+
v = verify_file(row["file_path"], row["key_label"])
|
|
101
|
+
return pd.Series({
|
|
102
|
+
"verdict": v.level,
|
|
103
|
+
"measured_key": v.estimate,
|
|
104
|
+
"reason": v.reason,
|
|
105
|
+
"clarity": v.clarity
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
results = df.apply(validate_row, axis=1)
|
|
109
|
+
|
|
110
|
+
# Keep only verified tracks (PASS and harmonic neighbor WARN)
|
|
111
|
+
clean_training_set = df[results["verdict"].isin(["PASS", "WARN"])]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 4. Low-level chroma & Krumhansl-Kessler correlation analysis
|
|
115
|
+
Analyze in-memory audio NumPy arrays directly:
|
|
116
|
+
```python
|
|
117
|
+
from tonicfind.features import chroma_frames
|
|
118
|
+
from tonicfind.estimate import estimate, key_name
|
|
119
|
+
from tonicfind.check import judge, parse_key
|
|
120
|
+
|
|
121
|
+
# x: 1D float numpy array sampled at 22,050 Hz
|
|
122
|
+
key_info = estimate(x)
|
|
123
|
+
label_id = parse_key("8A") # Camelot 8A -> (9, 'minor')
|
|
124
|
+
|
|
125
|
+
verdict = judge(label=label_id, k=key_info, file="in_memory_sample.wav")
|
|
126
|
+
print(verdict.level, verdict.reason)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Command Line Usage (CLI)
|
|
132
|
+
|
|
133
|
+
### 1. Check from filenames
|
|
134
|
+
If your files encode keys in their names (e.g. `night_drive_key-Am.wav`, `lead_key-8B.flac`):
|
|
135
|
+
```bash
|
|
136
|
+
tonicfind check songs/
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 2. Check from a CSV catalog
|
|
140
|
+
When metadata is stored in a CSV (columns `file` and `key`):
|
|
141
|
+
```bash
|
|
142
|
+
tonicfind check labels.csv --audio-dir songs/ --out verdicts.csv
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 3. Multi-core parallel processing
|
|
146
|
+
Accelerate analysis across CPU cores:
|
|
147
|
+
```bash
|
|
148
|
+
tonicfind check labels.csv --audio-dir songs/ --jobs 8 --show-pass
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4. CI/CD Dataset Quality Gate
|
|
152
|
+
`tonicfind` exits with code `1` if any file returns a `FAIL`, making it suitable for automated CI quality checks:
|
|
153
|
+
```bash
|
|
154
|
+
tonicfind check incoming_stems/ || { echo "Mismatched key labels detected!"; exit 1; }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 5. Tunable calibration thresholds
|
|
158
|
+
Adjust detection parameters for heavily distorted rock or dense multi-instrumental arrangements:
|
|
159
|
+
```bash
|
|
160
|
+
tonicfind check labels.csv --audio-dir songs/ --min-clarity 0.50 --support-gap 0.06
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## What the verdicts mean
|
|
166
|
+
|
|
167
|
+
| Verdict | Meaning | Exit Code |
|
|
168
|
+
|---|---|:---:|
|
|
169
|
+
| **PASS** | Measured key matches the label | `0` |
|
|
170
|
+
| **WARN** | Measured key is the label's relative major/minor, parallel key, or fifth neighbor (the most common perceptual confusions) | `0` |
|
|
171
|
+
| **REVIEW** | Cannot judge: no clear key, key modulates inside the file, audio is too short or silent, or audio supports multiple keys | `0` |
|
|
172
|
+
| **FAIL** | Audio clearly indicates an unrelated key and does not support the label | `1` |
|
|
173
|
+
| **ERROR** | Audio file could not be decoded or opened | `0` |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Supported Key Notations
|
|
178
|
+
|
|
179
|
+
`tonicfind` natively parses standard and modern DJ notation schemes:
|
|
180
|
+
- **Standard note names**: `C`, `C major`, `Cmaj`, `Am`, `A minor`, `C#m`, `Db minor`, `F♯ min`, `Bbm` (bare note name means major).
|
|
181
|
+
- **Camelot Wheel**: `8B` (C major), `8A` (A minor), `11B` (A major), etc.
|
|
182
|
+
- **Open Key**: `1d` (C major), `1m` (A minor), etc.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Benchmark validation results
|
|
187
|
+
|
|
188
|
+
Tested with `scripts/real_data_check.py` across 25 benchmark tracks spanning all 24 musical keys and Camelot notations with planted label discrepancies. Detailed findings are in [`real_check/results.md`](real_check/results.md):
|
|
189
|
+
|
|
190
|
+
- **False alarm rate on true labels**: **0.0%** (25 PASS, 0 WARN, 0 FAIL)
|
|
191
|
+
- **Deliberately wrong labels caught (unrelated key swapped)**: **100.0%** (25/25 marked FAIL, 0% missed)
|
|
192
|
+
- **Key estimation accuracy**:
|
|
193
|
+
- **Exact match**: **100.0%**
|
|
194
|
+
- **MIREX weighted score**: **1.000**
|
|
195
|
+
- **Planted false alarm check**: 0 false alarms on true musical key labels.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Real-World Calibration & Dense Studio Mixes
|
|
200
|
+
|
|
201
|
+
Real recordings contain dense percussion, distorted guitars, heavy sub-bass, and vocals with pitch vibrato.
|
|
202
|
+
|
|
203
|
+
- **Tuning offset compensation**: Detects global recording detuning (cents offset from A=440 Hz) and corrects chroma bin alignment prior to template matching.
|
|
204
|
+
- **`MIN_CLARITY = 0.55`**: Distinguishes valid tonality from unpitched noise (noise scores ~0.30; studio mixes score 0.50–0.95). For heavily distorted metal or ambient textures, `--min-clarity 0.50` can be supplied.
|
|
205
|
+
- **`SUPPORT_GAP = 0.05`**: Prevents false alarms on complex chord progressions that temporarily tonicize secondary degrees.
|
|
206
|
+
- **Modulation & segment check**: Analyzes files in thirds. Disagreements between sections are flagged as `REVIEW` rather than misattributed.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Practical Applications
|
|
211
|
+
|
|
212
|
+
- **Key-Conditioned AI Model Training**: Eliminate corrupted key labels from text-to-music and harmonic generation training pipelines (e.g. MusicLM, Stable Audio).
|
|
213
|
+
- **Audio Sample Pack QA**: Automatically audit commercial sound libraries, loops, and stem packs before distribution.
|
|
214
|
+
- **DJ Library & Harmonic Mixing**: Validate Camelot and Open Key metadata tags across track collections to prevent dissonant transitions.
|
|
215
|
+
- **Catalogue & DAW Project Verification**: Bulk audit archival recordings and stem exports against project notes.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Tests
|
|
220
|
+
|
|
221
|
+
Run tests with `pytest`:
|
|
222
|
+
```bash
|
|
223
|
+
pytest -q
|
|
224
|
+
```
|
|
225
|
+
Suite of 120 tests covering pitch-refinement interpolation, tuning offsets, chroma normalization, relation classification, programmatic APIs, and planted bug detection.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# tonicfind
|
|
2
|
+
|
|
3
|
+
**Check that the key labels on your music files match the audio.**
|
|
4
|
+
|
|
5
|
+
Wrong key labels quietly hurt music-AI training (key-conditioned generation, harmonic mixing, retrieval). `tonicfind` listens to each file, finds its key, and tells you which labels look wrong, which are only a commonly confused neighbour (relative, parallel, a fifth away), and which it cannot judge.
|
|
6
|
+
|
|
7
|
+
> **Status: Production-Ready (v0.1.0).** Calibrated and validated against key benchmarks and real studio mixes (120 tests passing, zero false alarms on true labels). See [Benchmark validation results](#benchmark-validation-results).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install tonicfind # or editable: pip install -e ".[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requirements: Python 3.9+. Depends only on `numpy`, `scipy`, and `soundfile`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 3-Line Python Quickstart
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import tonicfind
|
|
25
|
+
|
|
26
|
+
verdict = tonicfind.verify_file("song.wav", "Am")
|
|
27
|
+
print(f"{verdict.level}: {verdict.reason}") # PASS: label matches the audio
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Python API (Using in Code)
|
|
33
|
+
|
|
34
|
+
`tonicfind` can be imported directly into Python applications, PyTorch data loaders, and audio curation pipelines.
|
|
35
|
+
|
|
36
|
+
### 1. Verify audio against a key label
|
|
37
|
+
```python
|
|
38
|
+
from tonicfind import verify_file
|
|
39
|
+
|
|
40
|
+
verdict = verify_file("tracks/night_drive.wav", label="C# minor")
|
|
41
|
+
|
|
42
|
+
print(verdict.level) # "PASS", "WARN", "REVIEW", "FAIL", or "ERROR"
|
|
43
|
+
print(verdict.estimate) # Measured key, e.g. "C# minor"
|
|
44
|
+
print(verdict.reason) # e.g. "label matches the audio"
|
|
45
|
+
print(verdict.clarity) # Key correlation clarity (0.0 to 1.0; < 0.55 is ambiguous)
|
|
46
|
+
print(verdict.tuning_cents) # Detune offset from A440 in cents (e.g. -4.5c)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Estimate the musical key of any audio file
|
|
50
|
+
```python
|
|
51
|
+
from tonicfind import estimate_file, key_name
|
|
52
|
+
|
|
53
|
+
key_info = estimate_file("tracks/melody.wav")
|
|
54
|
+
|
|
55
|
+
print(key_name(key_info.key)) # e.g. "G major"
|
|
56
|
+
print(f"Clarity: {key_info.score:.2f}")
|
|
57
|
+
print(f"Segments: {[key_name(s) for s in key_info.segments]}") # Tracks key across 3rds of song
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Batch dataset filtering (Pandas / ETL)
|
|
61
|
+
Filter out mismatched tracks before training key-conditioned generative audio models:
|
|
62
|
+
```python
|
|
63
|
+
import pandas as pd
|
|
64
|
+
from tonicfind import verify_file
|
|
65
|
+
|
|
66
|
+
df = pd.read_csv("catalog.csv") # columns: 'file_path', 'key_label'
|
|
67
|
+
|
|
68
|
+
def validate_row(row):
|
|
69
|
+
v = verify_file(row["file_path"], row["key_label"])
|
|
70
|
+
return pd.Series({
|
|
71
|
+
"verdict": v.level,
|
|
72
|
+
"measured_key": v.estimate,
|
|
73
|
+
"reason": v.reason,
|
|
74
|
+
"clarity": v.clarity
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
results = df.apply(validate_row, axis=1)
|
|
78
|
+
|
|
79
|
+
# Keep only verified tracks (PASS and harmonic neighbor WARN)
|
|
80
|
+
clean_training_set = df[results["verdict"].isin(["PASS", "WARN"])]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Low-level chroma & Krumhansl-Kessler correlation analysis
|
|
84
|
+
Analyze in-memory audio NumPy arrays directly:
|
|
85
|
+
```python
|
|
86
|
+
from tonicfind.features import chroma_frames
|
|
87
|
+
from tonicfind.estimate import estimate, key_name
|
|
88
|
+
from tonicfind.check import judge, parse_key
|
|
89
|
+
|
|
90
|
+
# x: 1D float numpy array sampled at 22,050 Hz
|
|
91
|
+
key_info = estimate(x)
|
|
92
|
+
label_id = parse_key("8A") # Camelot 8A -> (9, 'minor')
|
|
93
|
+
|
|
94
|
+
verdict = judge(label=label_id, k=key_info, file="in_memory_sample.wav")
|
|
95
|
+
print(verdict.level, verdict.reason)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Command Line Usage (CLI)
|
|
101
|
+
|
|
102
|
+
### 1. Check from filenames
|
|
103
|
+
If your files encode keys in their names (e.g. `night_drive_key-Am.wav`, `lead_key-8B.flac`):
|
|
104
|
+
```bash
|
|
105
|
+
tonicfind check songs/
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 2. Check from a CSV catalog
|
|
109
|
+
When metadata is stored in a CSV (columns `file` and `key`):
|
|
110
|
+
```bash
|
|
111
|
+
tonicfind check labels.csv --audio-dir songs/ --out verdicts.csv
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 3. Multi-core parallel processing
|
|
115
|
+
Accelerate analysis across CPU cores:
|
|
116
|
+
```bash
|
|
117
|
+
tonicfind check labels.csv --audio-dir songs/ --jobs 8 --show-pass
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4. CI/CD Dataset Quality Gate
|
|
121
|
+
`tonicfind` exits with code `1` if any file returns a `FAIL`, making it suitable for automated CI quality checks:
|
|
122
|
+
```bash
|
|
123
|
+
tonicfind check incoming_stems/ || { echo "Mismatched key labels detected!"; exit 1; }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 5. Tunable calibration thresholds
|
|
127
|
+
Adjust detection parameters for heavily distorted rock or dense multi-instrumental arrangements:
|
|
128
|
+
```bash
|
|
129
|
+
tonicfind check labels.csv --audio-dir songs/ --min-clarity 0.50 --support-gap 0.06
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## What the verdicts mean
|
|
135
|
+
|
|
136
|
+
| Verdict | Meaning | Exit Code |
|
|
137
|
+
|---|---|:---:|
|
|
138
|
+
| **PASS** | Measured key matches the label | `0` |
|
|
139
|
+
| **WARN** | Measured key is the label's relative major/minor, parallel key, or fifth neighbor (the most common perceptual confusions) | `0` |
|
|
140
|
+
| **REVIEW** | Cannot judge: no clear key, key modulates inside the file, audio is too short or silent, or audio supports multiple keys | `0` |
|
|
141
|
+
| **FAIL** | Audio clearly indicates an unrelated key and does not support the label | `1` |
|
|
142
|
+
| **ERROR** | Audio file could not be decoded or opened | `0` |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Supported Key Notations
|
|
147
|
+
|
|
148
|
+
`tonicfind` natively parses standard and modern DJ notation schemes:
|
|
149
|
+
- **Standard note names**: `C`, `C major`, `Cmaj`, `Am`, `A minor`, `C#m`, `Db minor`, `F♯ min`, `Bbm` (bare note name means major).
|
|
150
|
+
- **Camelot Wheel**: `8B` (C major), `8A` (A minor), `11B` (A major), etc.
|
|
151
|
+
- **Open Key**: `1d` (C major), `1m` (A minor), etc.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Benchmark validation results
|
|
156
|
+
|
|
157
|
+
Tested with `scripts/real_data_check.py` across 25 benchmark tracks spanning all 24 musical keys and Camelot notations with planted label discrepancies. Detailed findings are in [`real_check/results.md`](real_check/results.md):
|
|
158
|
+
|
|
159
|
+
- **False alarm rate on true labels**: **0.0%** (25 PASS, 0 WARN, 0 FAIL)
|
|
160
|
+
- **Deliberately wrong labels caught (unrelated key swapped)**: **100.0%** (25/25 marked FAIL, 0% missed)
|
|
161
|
+
- **Key estimation accuracy**:
|
|
162
|
+
- **Exact match**: **100.0%**
|
|
163
|
+
- **MIREX weighted score**: **1.000**
|
|
164
|
+
- **Planted false alarm check**: 0 false alarms on true musical key labels.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Real-World Calibration & Dense Studio Mixes
|
|
169
|
+
|
|
170
|
+
Real recordings contain dense percussion, distorted guitars, heavy sub-bass, and vocals with pitch vibrato.
|
|
171
|
+
|
|
172
|
+
- **Tuning offset compensation**: Detects global recording detuning (cents offset from A=440 Hz) and corrects chroma bin alignment prior to template matching.
|
|
173
|
+
- **`MIN_CLARITY = 0.55`**: Distinguishes valid tonality from unpitched noise (noise scores ~0.30; studio mixes score 0.50–0.95). For heavily distorted metal or ambient textures, `--min-clarity 0.50` can be supplied.
|
|
174
|
+
- **`SUPPORT_GAP = 0.05`**: Prevents false alarms on complex chord progressions that temporarily tonicize secondary degrees.
|
|
175
|
+
- **Modulation & segment check**: Analyzes files in thirds. Disagreements between sections are flagged as `REVIEW` rather than misattributed.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Practical Applications
|
|
180
|
+
|
|
181
|
+
- **Key-Conditioned AI Model Training**: Eliminate corrupted key labels from text-to-music and harmonic generation training pipelines (e.g. MusicLM, Stable Audio).
|
|
182
|
+
- **Audio Sample Pack QA**: Automatically audit commercial sound libraries, loops, and stem packs before distribution.
|
|
183
|
+
- **DJ Library & Harmonic Mixing**: Validate Camelot and Open Key metadata tags across track collections to prevent dissonant transitions.
|
|
184
|
+
- **Catalogue & DAW Project Verification**: Bulk audit archival recordings and stem exports against project notes.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Tests
|
|
189
|
+
|
|
190
|
+
Run tests with `pytest`:
|
|
191
|
+
```bash
|
|
192
|
+
pytest -q
|
|
193
|
+
```
|
|
194
|
+
Suite of 120 tests covering pitch-refinement interpolation, tuning offsets, chroma normalization, relation classification, programmatic APIs, and planted bug detection.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tonicfind"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Check that the key labels on your music files (C major, Am, 8A) 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 = [
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Intended Audience :: Science/Research",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/BoobalanArjunan/Tonicfind"
|
|
35
|
+
Repository = "https://github.com/BoobalanArjunan/Tonicfind.git"
|
|
36
|
+
Issues = "https://github.com/BoobalanArjunan/Tonicfind/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = ["pytest"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
tonicfind = "tonicfind.cli:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.dynamic]
|
|
45
|
+
version = { attr = "tonicfind._version.__version__" }
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""tonicfind: does the key label on a music file match the audio?"""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .check import MIN_CLARITY, SUPPORT_GAP, Verdict, check_file, estimate_file, judge, verify_file
|
|
4
|
+
from .estimate import Key, all_keys, estimate, key_name
|
|
5
|
+
from .labels import parse_key
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"__version__",
|
|
9
|
+
"verify_file",
|
|
10
|
+
"estimate_file",
|
|
11
|
+
"check_file",
|
|
12
|
+
"judge",
|
|
13
|
+
"Verdict",
|
|
14
|
+
"Key",
|
|
15
|
+
"estimate",
|
|
16
|
+
"key_name",
|
|
17
|
+
"all_keys",
|
|
18
|
+
"parse_key",
|
|
19
|
+
"MIN_CLARITY",
|
|
20
|
+
"SUPPORT_GAP",
|
|
21
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Compare a labelled key 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 List, Optional
|
|
7
|
+
|
|
8
|
+
from .estimate import Key, KeyId, estimate, key_name
|
|
9
|
+
from .features import load_mono
|
|
10
|
+
|
|
11
|
+
MIN_CLARITY = 0.55 # best-key correlation below this: the audio has no clear key, so the label cannot be judged
|
|
12
|
+
SUPPORT_GAP = 0.05 # the label counts as "also supported by the audio" if its score is within this of the best
|
|
13
|
+
|
|
14
|
+
OK, NEIGHBOUR, WRONG, UNSURE, ERROR = "OK", "NEIGHBOUR", "WRONG", "UNSURE", "ERROR"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Verdict:
|
|
19
|
+
file: str
|
|
20
|
+
label: str
|
|
21
|
+
status: str
|
|
22
|
+
estimate: Optional[str]
|
|
23
|
+
reason: str
|
|
24
|
+
clarity: float = 0.0
|
|
25
|
+
tuning_cents: float = 0.0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def level(self) -> str:
|
|
29
|
+
return {OK: "PASS", NEIGHBOUR: "WARN", UNSURE: "REVIEW", WRONG: "FAIL", ERROR: "ERROR"}[self.status]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def relation(label: KeyId, est: KeyId) -> Optional[str]:
|
|
33
|
+
"""How two different keys are related, if they are one of the three pairs people confuse most; else None."""
|
|
34
|
+
(lt, lm), (et, em) = label, est
|
|
35
|
+
if lm != em:
|
|
36
|
+
major_t, minor_t = (lt, et) if lm == "major" else (et, lt)
|
|
37
|
+
if (major_t - minor_t) % 12 == 3:
|
|
38
|
+
return "relative key (shares the same notes)"
|
|
39
|
+
if lt == et:
|
|
40
|
+
return "same tonic, other mode (parallel key)"
|
|
41
|
+
elif (et - lt) % 12 in (5, 7):
|
|
42
|
+
return "a fifth away (shares all but one note)"
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def judge(label: KeyId, k: Key, file: str = "", label_text: str = "",
|
|
47
|
+
min_clarity: float = MIN_CLARITY, support_gap: float = SUPPORT_GAP) -> Verdict:
|
|
48
|
+
lt = label_text or key_name(label)
|
|
49
|
+
if k.key is None:
|
|
50
|
+
return Verdict(file, lt, UNSURE, None, "too short, silent or without pitched sound: cannot measure a key", 0.0, k.tuning_cents)
|
|
51
|
+
est = key_name(k.key)
|
|
52
|
+
base = dict(clarity=k.score, tuning_cents=k.tuning_cents)
|
|
53
|
+
if k.segments and any(s != k.key and relation(k.key, s) is None for s in k.segments):
|
|
54
|
+
return Verdict(file, lt, UNSURE, est, "key changes inside the file: " + ", ".join(key_name(s) for s in k.segments), **base)
|
|
55
|
+
if k.score < min_clarity:
|
|
56
|
+
return Verdict(file, lt, UNSURE, est, f"no clear key (clarity {k.score:.2f}); label cannot be judged", **base)
|
|
57
|
+
if k.key == label:
|
|
58
|
+
return Verdict(file, lt, OK, est, "label matches the audio", **base)
|
|
59
|
+
rel = relation(label, k.key)
|
|
60
|
+
if rel:
|
|
61
|
+
return Verdict(file, lt, NEIGHBOUR, est, f"audio says {est}: {rel}; often hard to tell apart", **base)
|
|
62
|
+
if k.score_at(label) >= k.score - support_gap:
|
|
63
|
+
return Verdict(file, lt, UNSURE, est, f"audio supports both the label and {est}", **base)
|
|
64
|
+
return Verdict(file, lt, WRONG, est, f"audio says {est}, label says {key_name(label)}", **base)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_file(path, label: KeyId | str, label_text: str = "",
|
|
68
|
+
min_clarity: float = MIN_CLARITY, support_gap: float = SUPPORT_GAP) -> Verdict:
|
|
69
|
+
p = Path(path)
|
|
70
|
+
if isinstance(label, str):
|
|
71
|
+
from .labels import parse_key
|
|
72
|
+
parsed = parse_key(label)
|
|
73
|
+
if parsed is None:
|
|
74
|
+
return Verdict(p.name, label, ERROR, None, f"unrecognized key format: '{label}'")
|
|
75
|
+
raw_text = label_text or label
|
|
76
|
+
key_id = parsed
|
|
77
|
+
else:
|
|
78
|
+
key_id = label
|
|
79
|
+
raw_text = label_text or key_name(key_id)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
k = estimate(load_mono(p))
|
|
83
|
+
except Exception as e: # unreadable audio
|
|
84
|
+
return Verdict(p.name, raw_text, ERROR, None, f"could not read audio: {e}")
|
|
85
|
+
return judge(key_id, k, p.name, raw_text, min_clarity=min_clarity, support_gap=support_gap)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_file(path, label: str | KeyId,
|
|
89
|
+
min_clarity: float = MIN_CLARITY, support_gap: float = SUPPORT_GAP) -> Verdict:
|
|
90
|
+
"""Check that an audio file's key matches the given label.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> verdict = tonicfind.verify_file("song.wav", "Am")
|
|
94
|
+
>>> print(verdict.level, verdict.reason)
|
|
95
|
+
PASS label matches the audio
|
|
96
|
+
"""
|
|
97
|
+
return check_file(path, label, min_clarity=min_clarity, support_gap=support_gap)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def estimate_file(path) -> Key:
|
|
101
|
+
"""Measure the musical key of an audio file.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> key_info = tonicfind.estimate_file("song.wav")
|
|
105
|
+
>>> print(tonicfind.key_name(key_info.key), key_info.score)
|
|
106
|
+
"""
|
|
107
|
+
return estimate(load_mono(Path(path)))
|