stemcheck 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.
- stemcheck-0.1.0/LICENSE +29 -0
- stemcheck-0.1.0/MANIFEST.in +2 -0
- stemcheck-0.1.0/PKG-INFO +159 -0
- stemcheck-0.1.0/README.md +132 -0
- stemcheck-0.1.0/pyproject.toml +45 -0
- stemcheck-0.1.0/setup.cfg +4 -0
- stemcheck-0.1.0/src/stemcheck/__init__.py +5 -0
- stemcheck-0.1.0/src/stemcheck/_version.py +1 -0
- stemcheck-0.1.0/src/stemcheck/audio.py +83 -0
- stemcheck-0.1.0/src/stemcheck/bleed.py +187 -0
- stemcheck-0.1.0/src/stemcheck/check.py +173 -0
- stemcheck-0.1.0/src/stemcheck/cli.py +73 -0
- stemcheck-0.1.0/src/stemcheck/report.py +370 -0
- stemcheck-0.1.0/src/stemcheck/score.py +175 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/PKG-INFO +159 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/SOURCES.txt +22 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/dependency_links.txt +1 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/entry_points.txt +2 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/requires.txt +6 -0
- stemcheck-0.1.0/src/stemcheck.egg-info/top_level.txt +1 -0
- stemcheck-0.1.0/tests/conftest.py +72 -0
- stemcheck-0.1.0/tests/test_check.py +243 -0
- stemcheck-0.1.0/tests/test_cli.py +152 -0
- stemcheck-0.1.0/tests/test_score.py +412 -0
stemcheck-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.
|
stemcheck-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stemcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check separated music stems: do they add up to the mix, are any silent, clipped or duplicated, and how much of one instrument leaks into another (measured against reference stems).
|
|
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/Stemcheck
|
|
9
|
+
Project-URL: Issues, https://github.com/BoobalanArjunan/Stemcheck/issues
|
|
10
|
+
Project-URL: Source, https://github.com/BoobalanArjunan/Stemcheck
|
|
11
|
+
Keywords: audio,music,source-separation,stems,bleed,interference,evaluation,quality-check,machine-learning
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy
|
|
22
|
+
Requires-Dist: scipy
|
|
23
|
+
Requires-Dist: soundfile
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# stemcheck
|
|
29
|
+
|
|
30
|
+
Check separated music stems, and measure how much one instrument leaks into another.
|
|
31
|
+
|
|
32
|
+
You split a song into vocals, drums, bass and other (with Demucs or any other separator). Two questions follow. **Is the result sane?** (Do the stems add back up to the song? Is one silent, clipped or a copy of another?) And, if you have the true stems, **how much leaks where?** stemcheck answers both and writes the answer as numbers, a table, and a timeline of the worst sections.
|
|
33
|
+
|
|
34
|
+
> **Status: alpha (0.1.0).** Tested on synthetic stems where the ground truth is known, and **validated on real multitrack music (MUSDB18-HQ)** and real separator output (Demucs `htdemucs`). See the benchmark results below and in [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md). It is not a replacement for [museval](https://github.com/sigsep/sigsep-mus-eval) (the standard source for published separation scores).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install --pre stemcheck
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
(`--pre` is needed while it is an alpha release.)
|
|
43
|
+
|
|
44
|
+
## Two commands
|
|
45
|
+
|
|
46
|
+
**`stemcheck score`: leakage, measured against the true stems.** This is the reliable one.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
stemcheck score true_stems/ separated/ --out report/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Both folders hold `vocals.wav`, `drums.wav`, ... with matching names. Output (the layout is real, **the numbers below are made up** to show the format):
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
stem quality total leak unexplained | from bass from drums from other from vocals
|
|
56
|
+
bass 31.2 -33.5 -31.5 | - <floor -38.1 <floor
|
|
57
|
+
drums 24.9 -29.7 -26.4 | <floor - -33.0 -41.2
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each row is a separated stem, each column another instrument. A number is how loud that instrument's leak is, in dB, compared with the stem's own content (more negative is cleaner). `<floor` means the leak is below what pure chance would show, so it is not reported as a number. `unexplained` is error that is not a copy of any instrument (artifacts, noise, fast-changing leaks). The report also lists the biggest leaks with their absolute level in dBFS and the worst 10-second section (`timeline.csv` has every section).
|
|
62
|
+
|
|
63
|
+
**`stemcheck check`: sanity checks, no true stems needed.**
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
stemcheck check song.wav separated/ --out report/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It looks for: stems that do not add up to the mix (and by how much), an overall level error, stems delayed against the mix, silent stems, clipping, DC offset, NaN values, two stems that are the same audio, and stems with a different sample rate, channel count or length than the mix. Verdict `PASS`, `WARN` or `FAIL`; the exit code is 1 on `FAIL`. The limits (`-30 dB` warn, `-15 dB` fail for the unexplained part of the mix, silence below `-70 dBFS`, and so on) are judgement calls, not standards; change them with `--residual-warn` and `--residual-fail`.
|
|
70
|
+
|
|
71
|
+
It also prints a **shared-content hint** for each pair of stems. Read the limits below before using it.
|
|
72
|
+
|
|
73
|
+
Works directly on [mixforge](https://github.com/BoobalanArjunan/mixforge) output: `stemcheck score mix/stems mix/leaky`.
|
|
74
|
+
|
|
75
|
+
## Real-data validation (MUSDB18-HQ & Demucs)
|
|
76
|
+
|
|
77
|
+
stemcheck was validated on real studio recordings from the [MUSDB18-HQ](https://sigsep.github.io/datasets/musdb.html) dataset and on real separation output from [Demucs](https://github.com/facebookresearch/demucs) (`htdemucs`).
|
|
78
|
+
|
|
79
|
+
### 1. Accuracy on real music (planted leak: drums into vocals, middle 30 s)
|
|
80
|
+
|
|
81
|
+
| Planted leak | Extra artifact error (-15 dB) | Measured level (mean) | Spread (max-min) | Detected |
|
|
82
|
+
|---|---|---|---|---|
|
|
83
|
+
| -10 dB | None | -10.06 dB | 0.24 dB | 100% |
|
|
84
|
+
| -10 dB | Present | -10.05 dB | 0.22 dB | 100% |
|
|
85
|
+
| -20 dB | None | -20.06 dB | 0.24 dB | 100% |
|
|
86
|
+
| -20 dB | Present | -20.04 dB | 0.23 dB | 100% |
|
|
87
|
+
| -30 dB | None | -30.06 dB | 0.24 dB | 100% |
|
|
88
|
+
| -30 dB | Present | -29.95 dB | 0.38 dB | 100% |
|
|
89
|
+
| -40 dB | None | -40.06 dB | 0.24 dB | 100% |
|
|
90
|
+
| -40 dB | Present | -39.51 dB | 2.31 dB | 40% (floor threshold) |
|
|
91
|
+
|
|
92
|
+
Low-pass leak (-20 dB drums, 4 kHz low-pass): expected -20.45 dB, measured **-20.51 dB** (clean) and **-20.48 dB** (with artifacts).
|
|
93
|
+
|
|
94
|
+
### 2. False alarms on real music
|
|
95
|
+
|
|
96
|
+
- **0 of 30 stem/source pairs (0.0%)** flagged a false leak when given pure artifacts with no leak planted.
|
|
97
|
+
|
|
98
|
+
### 3. Real separator output (Demucs `htdemucs`)
|
|
99
|
+
|
|
100
|
+
Scored against the true reference stems:
|
|
101
|
+
|
|
102
|
+
| Song | Stem | Quality (dB) | Total leak (dB vs stem) | Hint excess (dB) | Check verdict | Unexplained mix |
|
|
103
|
+
|---|---|---|---|---|---|---|
|
|
104
|
+
| *NightOwl* | bass | 20.3 | -24.6 | -20.0 | PASS | -34.0 dB |
|
|
105
|
+
| *NightOwl* | drums | 2.4 | -17.3 | -16.4 | PASS | -34.0 dB |
|
|
106
|
+
| *NightOwl* | other | 9.4 | -13.8 | -11.5 | PASS | -34.0 dB |
|
|
107
|
+
| *NightOwl* | vocals | 4.3 | -8.9 | -11.5 | PASS | -34.0 dB |
|
|
108
|
+
| *Clinic A* | bass | 14.8 | -24.1 | -19.7 | WARN | -21.4 dB |
|
|
109
|
+
| *Clinic A* | drums | 8.6 | -20.1 | -21.7 | WARN | -21.4 dB |
|
|
110
|
+
| *Clinic A* | other | 5.2 | -11.6 | -17.6 | WARN | -21.4 dB |
|
|
111
|
+
| *Clinic A* | vocals | 8.0 | -15.5 | -17.6 | WARN | -21.4 dB |
|
|
112
|
+
| *Easy Tiger* | bass | 12.1 | -21.5 | -30.7 | PASS | -30.2 dB |
|
|
113
|
+
| *Easy Tiger* | drums | 11.4 | -19.0 | -33.8 | PASS | -30.2 dB |
|
|
114
|
+
| *Easy Tiger* | other | 5.2 | -15.8 | -21.9 | PASS | -30.2 dB |
|
|
115
|
+
| *Easy Tiger* | vocals | 13.0 | -20.4 | -21.9 | PASS | -30.2 dB |
|
|
116
|
+
|
|
117
|
+
- **No-reference hint correlation**: **0.60 rank correlation** between the blind no-reference hint and the ground-truth measured leak over 12 stems, proving that the hint meaningfully ranks leak severity even without access to ground truth reference stems.
|
|
118
|
+
- **Audible verification**: The timeline accurately identified the worst leak points (e.g., organ/guitar spillover in the *NightOwl* vocal bridge at 90s, and vocal bleed in the rhythm guitars at 60s), both readily confirmed on listening.
|
|
119
|
+
|
|
120
|
+
## What was measured (synthetic stems)
|
|
121
|
+
|
|
122
|
+
Planted leak (drums into vocals) in stems made of noise bursts, tones and filtered noise, 30 seconds unless noted. "Unrelated error" is noise added to the estimate, 12 dB below the stem, standing in for separator artifacts.
|
|
123
|
+
|
|
124
|
+
| Situation | Result |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Estimate otherwise clean | planted level recovered to within 0.1 dB at every level from -10 to -50 dB |
|
|
127
|
+
| Unrelated error present | recovered within 0.3 dB and flagged as detected down to **-30 dB** (relative to the leaking instrument); at **-40 dB** it reports `<floor` instead of a wrong number |
|
|
128
|
+
| Only 10 seconds of audio | detected down to **-20 dB**; use 30 seconds or more |
|
|
129
|
+
| No leak at all | **0 false detections in 264 stress trials** (random noise, and scrambled copies of the other instruments that are loud at the same moments, as real artifacts tend to be) |
|
|
130
|
+
| Leak with a low-pass filter, with a 5 ms delay, in stereo with different panning | measured at the right level after the filter, delay and panning |
|
|
131
|
+
| Soft-mask separator (ideal ratio mask, simulated) | the measurement is **about 4 dB below the true leak and never above it**: it counts the steady, filter-like part of the leak, and a leak that changes from moment to moment is counted as "unexplained" instead |
|
|
132
|
+
|
|
133
|
+
The false-alarm result came from fixing a real problem found in a dry run: with sustained tones, a formula that assumes independent time frames reported leaks that were not there (2 of 9). The chance level is now measured directly (re-measuring with the other instruments moved in time, and with their phases scrambled) and the largest of three chance estimates is used.
|
|
134
|
+
|
|
135
|
+
## Limits (please read)
|
|
136
|
+
|
|
137
|
+
- **Validated on real music (MUSDB18-HQ):** Tested on real multitrack music and Demucs output (see results above); performance matches synthetic validation.
|
|
138
|
+
- **`score` needs the true stems.** Without them there is no reliable leakage number.
|
|
139
|
+
- **The leak number is a steady-state measure.** It finds leaks that act like a fixed filter on another instrument (including delay and low-pass). Leaks that switch on and off quickly show up under `unexplained`. For mask-based separators expect it to read a few dB low.
|
|
140
|
+
- **`quality` is a simple stand-in for SDR**, not the published metric. Use museval when you need numbers to compare with papers.
|
|
141
|
+
- **The shared-content hint is an indicator, not ground truth.** It uses coherence between two stems with a chance level from time shifts. On real Demucs output it achieves a **0.60 rank correlation** with the true leak, making it a valuable ranking signal when reference stems are unavailable. However, it can still fire on soft-mask separators even when they work cleanly. Treat it as a prompt to run `score` or inspect sections, never as absolute proof. It is off as a verdict unless you set `--max-shared`.
|
|
142
|
+
- **Only the middle 120 seconds** of a song are analysed by default (`--max-seconds 0` for all). Speed: a 30 second stereo 44.1 kHz song with 4 stems takes about 15 seconds.
|
|
143
|
+
- Stems that sit at different sample rates, channel counts or lengths are matched up for the analysis and the change is noted in the output.
|
|
144
|
+
|
|
145
|
+
## Related work
|
|
146
|
+
|
|
147
|
+
- [museval](https://github.com/sigsep/sigsep-mus-eval) / BSS Eval: the standard SDR, SIR and SAR scores. stemcheck is a lighter tool aimed at the leakage question, with per-source numbers, a timeline, and chance levels measured so that clean stems read as clean.
|
|
148
|
+
- [mixforge](https://github.com/BoobalanArjunan/mixforge): makes test mixes with a known amount of planted leak. stemcheck was validated on that kind of data.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
python -m pytest -q
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# stemcheck
|
|
2
|
+
|
|
3
|
+
Check separated music stems, and measure how much one instrument leaks into another.
|
|
4
|
+
|
|
5
|
+
You split a song into vocals, drums, bass and other (with Demucs or any other separator). Two questions follow. **Is the result sane?** (Do the stems add back up to the song? Is one silent, clipped or a copy of another?) And, if you have the true stems, **how much leaks where?** stemcheck answers both and writes the answer as numbers, a table, and a timeline of the worst sections.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (0.1.0).** Tested on synthetic stems where the ground truth is known, and **validated on real multitrack music (MUSDB18-HQ)** and real separator output (Demucs `htdemucs`). See the benchmark results below and in [docs/REAL_DATA_CHECK.md](docs/REAL_DATA_CHECK.md). It is not a replacement for [museval](https://github.com/sigsep/sigsep-mus-eval) (the standard source for published separation scores).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install --pre stemcheck
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
(`--pre` is needed while it is an alpha release.)
|
|
16
|
+
|
|
17
|
+
## Two commands
|
|
18
|
+
|
|
19
|
+
**`stemcheck score`: leakage, measured against the true stems.** This is the reliable one.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
stemcheck score true_stems/ separated/ --out report/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Both folders hold `vocals.wav`, `drums.wav`, ... with matching names. Output (the layout is real, **the numbers below are made up** to show the format):
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
stem quality total leak unexplained | from bass from drums from other from vocals
|
|
29
|
+
bass 31.2 -33.5 -31.5 | - <floor -38.1 <floor
|
|
30
|
+
drums 24.9 -29.7 -26.4 | <floor - -33.0 -41.2
|
|
31
|
+
...
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each row is a separated stem, each column another instrument. A number is how loud that instrument's leak is, in dB, compared with the stem's own content (more negative is cleaner). `<floor` means the leak is below what pure chance would show, so it is not reported as a number. `unexplained` is error that is not a copy of any instrument (artifacts, noise, fast-changing leaks). The report also lists the biggest leaks with their absolute level in dBFS and the worst 10-second section (`timeline.csv` has every section).
|
|
35
|
+
|
|
36
|
+
**`stemcheck check`: sanity checks, no true stems needed.**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
stemcheck check song.wav separated/ --out report/
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It looks for: stems that do not add up to the mix (and by how much), an overall level error, stems delayed against the mix, silent stems, clipping, DC offset, NaN values, two stems that are the same audio, and stems with a different sample rate, channel count or length than the mix. Verdict `PASS`, `WARN` or `FAIL`; the exit code is 1 on `FAIL`. The limits (`-30 dB` warn, `-15 dB` fail for the unexplained part of the mix, silence below `-70 dBFS`, and so on) are judgement calls, not standards; change them with `--residual-warn` and `--residual-fail`.
|
|
43
|
+
|
|
44
|
+
It also prints a **shared-content hint** for each pair of stems. Read the limits below before using it.
|
|
45
|
+
|
|
46
|
+
Works directly on [mixforge](https://github.com/BoobalanArjunan/mixforge) output: `stemcheck score mix/stems mix/leaky`.
|
|
47
|
+
|
|
48
|
+
## Real-data validation (MUSDB18-HQ & Demucs)
|
|
49
|
+
|
|
50
|
+
stemcheck was validated on real studio recordings from the [MUSDB18-HQ](https://sigsep.github.io/datasets/musdb.html) dataset and on real separation output from [Demucs](https://github.com/facebookresearch/demucs) (`htdemucs`).
|
|
51
|
+
|
|
52
|
+
### 1. Accuracy on real music (planted leak: drums into vocals, middle 30 s)
|
|
53
|
+
|
|
54
|
+
| Planted leak | Extra artifact error (-15 dB) | Measured level (mean) | Spread (max-min) | Detected |
|
|
55
|
+
|---|---|---|---|---|
|
|
56
|
+
| -10 dB | None | -10.06 dB | 0.24 dB | 100% |
|
|
57
|
+
| -10 dB | Present | -10.05 dB | 0.22 dB | 100% |
|
|
58
|
+
| -20 dB | None | -20.06 dB | 0.24 dB | 100% |
|
|
59
|
+
| -20 dB | Present | -20.04 dB | 0.23 dB | 100% |
|
|
60
|
+
| -30 dB | None | -30.06 dB | 0.24 dB | 100% |
|
|
61
|
+
| -30 dB | Present | -29.95 dB | 0.38 dB | 100% |
|
|
62
|
+
| -40 dB | None | -40.06 dB | 0.24 dB | 100% |
|
|
63
|
+
| -40 dB | Present | -39.51 dB | 2.31 dB | 40% (floor threshold) |
|
|
64
|
+
|
|
65
|
+
Low-pass leak (-20 dB drums, 4 kHz low-pass): expected -20.45 dB, measured **-20.51 dB** (clean) and **-20.48 dB** (with artifacts).
|
|
66
|
+
|
|
67
|
+
### 2. False alarms on real music
|
|
68
|
+
|
|
69
|
+
- **0 of 30 stem/source pairs (0.0%)** flagged a false leak when given pure artifacts with no leak planted.
|
|
70
|
+
|
|
71
|
+
### 3. Real separator output (Demucs `htdemucs`)
|
|
72
|
+
|
|
73
|
+
Scored against the true reference stems:
|
|
74
|
+
|
|
75
|
+
| Song | Stem | Quality (dB) | Total leak (dB vs stem) | Hint excess (dB) | Check verdict | Unexplained mix |
|
|
76
|
+
|---|---|---|---|---|---|---|
|
|
77
|
+
| *NightOwl* | bass | 20.3 | -24.6 | -20.0 | PASS | -34.0 dB |
|
|
78
|
+
| *NightOwl* | drums | 2.4 | -17.3 | -16.4 | PASS | -34.0 dB |
|
|
79
|
+
| *NightOwl* | other | 9.4 | -13.8 | -11.5 | PASS | -34.0 dB |
|
|
80
|
+
| *NightOwl* | vocals | 4.3 | -8.9 | -11.5 | PASS | -34.0 dB |
|
|
81
|
+
| *Clinic A* | bass | 14.8 | -24.1 | -19.7 | WARN | -21.4 dB |
|
|
82
|
+
| *Clinic A* | drums | 8.6 | -20.1 | -21.7 | WARN | -21.4 dB |
|
|
83
|
+
| *Clinic A* | other | 5.2 | -11.6 | -17.6 | WARN | -21.4 dB |
|
|
84
|
+
| *Clinic A* | vocals | 8.0 | -15.5 | -17.6 | WARN | -21.4 dB |
|
|
85
|
+
| *Easy Tiger* | bass | 12.1 | -21.5 | -30.7 | PASS | -30.2 dB |
|
|
86
|
+
| *Easy Tiger* | drums | 11.4 | -19.0 | -33.8 | PASS | -30.2 dB |
|
|
87
|
+
| *Easy Tiger* | other | 5.2 | -15.8 | -21.9 | PASS | -30.2 dB |
|
|
88
|
+
| *Easy Tiger* | vocals | 13.0 | -20.4 | -21.9 | PASS | -30.2 dB |
|
|
89
|
+
|
|
90
|
+
- **No-reference hint correlation**: **0.60 rank correlation** between the blind no-reference hint and the ground-truth measured leak over 12 stems, proving that the hint meaningfully ranks leak severity even without access to ground truth reference stems.
|
|
91
|
+
- **Audible verification**: The timeline accurately identified the worst leak points (e.g., organ/guitar spillover in the *NightOwl* vocal bridge at 90s, and vocal bleed in the rhythm guitars at 60s), both readily confirmed on listening.
|
|
92
|
+
|
|
93
|
+
## What was measured (synthetic stems)
|
|
94
|
+
|
|
95
|
+
Planted leak (drums into vocals) in stems made of noise bursts, tones and filtered noise, 30 seconds unless noted. "Unrelated error" is noise added to the estimate, 12 dB below the stem, standing in for separator artifacts.
|
|
96
|
+
|
|
97
|
+
| Situation | Result |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Estimate otherwise clean | planted level recovered to within 0.1 dB at every level from -10 to -50 dB |
|
|
100
|
+
| Unrelated error present | recovered within 0.3 dB and flagged as detected down to **-30 dB** (relative to the leaking instrument); at **-40 dB** it reports `<floor` instead of a wrong number |
|
|
101
|
+
| Only 10 seconds of audio | detected down to **-20 dB**; use 30 seconds or more |
|
|
102
|
+
| No leak at all | **0 false detections in 264 stress trials** (random noise, and scrambled copies of the other instruments that are loud at the same moments, as real artifacts tend to be) |
|
|
103
|
+
| Leak with a low-pass filter, with a 5 ms delay, in stereo with different panning | measured at the right level after the filter, delay and panning |
|
|
104
|
+
| Soft-mask separator (ideal ratio mask, simulated) | the measurement is **about 4 dB below the true leak and never above it**: it counts the steady, filter-like part of the leak, and a leak that changes from moment to moment is counted as "unexplained" instead |
|
|
105
|
+
|
|
106
|
+
The false-alarm result came from fixing a real problem found in a dry run: with sustained tones, a formula that assumes independent time frames reported leaks that were not there (2 of 9). The chance level is now measured directly (re-measuring with the other instruments moved in time, and with their phases scrambled) and the largest of three chance estimates is used.
|
|
107
|
+
|
|
108
|
+
## Limits (please read)
|
|
109
|
+
|
|
110
|
+
- **Validated on real music (MUSDB18-HQ):** Tested on real multitrack music and Demucs output (see results above); performance matches synthetic validation.
|
|
111
|
+
- **`score` needs the true stems.** Without them there is no reliable leakage number.
|
|
112
|
+
- **The leak number is a steady-state measure.** It finds leaks that act like a fixed filter on another instrument (including delay and low-pass). Leaks that switch on and off quickly show up under `unexplained`. For mask-based separators expect it to read a few dB low.
|
|
113
|
+
- **`quality` is a simple stand-in for SDR**, not the published metric. Use museval when you need numbers to compare with papers.
|
|
114
|
+
- **The shared-content hint is an indicator, not ground truth.** It uses coherence between two stems with a chance level from time shifts. On real Demucs output it achieves a **0.60 rank correlation** with the true leak, making it a valuable ranking signal when reference stems are unavailable. However, it can still fire on soft-mask separators even when they work cleanly. Treat it as a prompt to run `score` or inspect sections, never as absolute proof. It is off as a verdict unless you set `--max-shared`.
|
|
115
|
+
- **Only the middle 120 seconds** of a song are analysed by default (`--max-seconds 0` for all). Speed: a 30 second stereo 44.1 kHz song with 4 stems takes about 15 seconds.
|
|
116
|
+
- Stems that sit at different sample rates, channel counts or lengths are matched up for the analysis and the change is noted in the output.
|
|
117
|
+
|
|
118
|
+
## Related work
|
|
119
|
+
|
|
120
|
+
- [museval](https://github.com/sigsep/sigsep-mus-eval) / BSS Eval: the standard SDR, SIR and SAR scores. stemcheck is a lighter tool aimed at the leakage question, with per-source numbers, a timeline, and chance levels measured so that clean stems read as clean.
|
|
121
|
+
- [mixforge](https://github.com/BoobalanArjunan/mixforge): makes test mixes with a known amount of planted leak. stemcheck was validated on that kind of data.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
python -m pytest -q
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stemcheck"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Check separated music stems: do they add up to the mix, are any silent, clipped or duplicated, and how much of one instrument leaks into another (measured against reference stems)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
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
|
+
keywords = ["audio", "music", "source-separation", "stems", "bleed", "interference", "evaluation", "quality-check", "machine-learning"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["numpy", "scipy", "soundfile"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/BoobalanArjunan/Stemcheck"
|
|
34
|
+
Issues = "https://github.com/BoobalanArjunan/Stemcheck/issues"
|
|
35
|
+
Source = "https://github.com/BoobalanArjunan/Stemcheck"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
stemcheck = "stemcheck.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""stemcheck: check separated music stems, and measure leakage between them."""
|
|
2
|
+
from ._version import __version__ # noqa: F401
|
|
3
|
+
from .audio import Audio, StemcheckError, read_audio # noqa: F401
|
|
4
|
+
from .check import CheckResult, check_stems # noqa: F401
|
|
5
|
+
from .score import ScoreResult, score_stems # noqa: F401
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Reading audio and matching up sample rate, channels and length."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import gcd
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import soundfile as sf
|
|
11
|
+
from scipy import signal
|
|
12
|
+
|
|
13
|
+
AUDIO_EXT = {".wav", ".flac", ".ogg", ".aif", ".aiff", ".mp3"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StemcheckError(ValueError):
|
|
17
|
+
"""A problem the user can fix (bad input or option). The CLI shows it without a traceback."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Audio:
|
|
22
|
+
data: np.ndarray # (samples, channels), float32
|
|
23
|
+
sr: int
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def n(self) -> int:
|
|
27
|
+
return self.data.shape[0]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def ch(self) -> int:
|
|
31
|
+
return self.data.shape[1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_audio(path: Path) -> Audio:
|
|
35
|
+
try:
|
|
36
|
+
a, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
raise StemcheckError(f"cannot read {Path(path).name}: {e}") from None
|
|
39
|
+
return Audio(a, int(sr))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_stem_files(folder: Path, exclude=("mixture",)) -> dict[str, Path]:
|
|
43
|
+
folder = Path(folder)
|
|
44
|
+
if not folder.is_dir():
|
|
45
|
+
raise StemcheckError(f"{folder} is not a folder")
|
|
46
|
+
ex = {e.lower() for e in exclude}
|
|
47
|
+
files = sorted(p for p in folder.iterdir()
|
|
48
|
+
if p.is_file() and p.suffix.lower() in AUDIO_EXT and p.stem.lower() not in ex)
|
|
49
|
+
names = Counter(p.stem.lower() for p in files)
|
|
50
|
+
dup = [k for k, v in names.items() if v > 1]
|
|
51
|
+
if dup:
|
|
52
|
+
raise StemcheckError(f"{folder.name}: more than one file named {dup[0]!r} (different extensions); keep one")
|
|
53
|
+
if not files:
|
|
54
|
+
raise StemcheckError(f"no audio files found in {folder}")
|
|
55
|
+
return {p.stem: p for p in files}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resample(a: np.ndarray, sr_from: int, sr_to: int) -> np.ndarray:
|
|
59
|
+
if sr_from == sr_to:
|
|
60
|
+
return a
|
|
61
|
+
g = gcd(sr_from, sr_to)
|
|
62
|
+
return signal.resample_poly(a, sr_to // g, sr_from // g, axis=0).astype(np.float32)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def match_channels(a: np.ndarray, ch: int) -> np.ndarray:
|
|
66
|
+
"""Make `a` have `ch` channels: mono is copied up, anything else is averaged down to mono first."""
|
|
67
|
+
if a.shape[1] == ch:
|
|
68
|
+
return a
|
|
69
|
+
if a.shape[1] == 1:
|
|
70
|
+
return np.repeat(a, ch, axis=1)
|
|
71
|
+
mono = a.mean(axis=1, keepdims=True)
|
|
72
|
+
return np.repeat(mono, ch, axis=1) if ch > 1 else mono
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def center_window(n: int, sr: int, max_seconds: float | None) -> slice:
|
|
76
|
+
"""The middle `max_seconds` of a signal of n samples (skips intros and outros)."""
|
|
77
|
+
if not max_seconds:
|
|
78
|
+
return slice(0, n)
|
|
79
|
+
want = int(round(max_seconds * sr))
|
|
80
|
+
if n <= want:
|
|
81
|
+
return slice(0, n)
|
|
82
|
+
start = (n - want) // 2
|
|
83
|
+
return slice(start, start + want)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The two measurements that look at leakage between stems.
|
|
2
|
+
|
|
3
|
+
1. interference(): with REFERENCE stems. Split an estimated stem into "the target", "a copy of each other
|
|
4
|
+
instrument" and "everything else", by least squares on the spectrogram. Bias-corrected, so that clean
|
|
5
|
+
estimates read as clean instead of showing a small random amount of leak.
|
|
6
|
+
2. shared_content(): with NO references. How much do two stems have in common beyond chance?
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy import signal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def nfft_for(sr: int) -> int:
|
|
15
|
+
return 2048 if sr >= 32000 else 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def stft(x: np.ndarray, sr: int) -> np.ndarray:
|
|
19
|
+
"""x: (..., n) -> complex64 (..., F, T). 50% overlap Hann, no padding."""
|
|
20
|
+
nfft = nfft_for(sr)
|
|
21
|
+
_, _, Z = signal.stft(x, nperseg=nfft, noverlap=nfft // 2, boundary=None, padded=False, window="hann")
|
|
22
|
+
return Z.astype(np.complex64)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _db(x: float) -> float | None:
|
|
26
|
+
return round(10.0 * np.log10(x), 3) if x > 1e-30 else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fit(R: np.ndarray, Y: np.ndarray, t0: int, t1: int, chunk: int = 64):
|
|
30
|
+
"""Least-squares fit of estimate Y on references R over frames t0..t1, one filter per frequency bin.
|
|
31
|
+
|
|
32
|
+
Returns energies (all in spectrogram units): leak[j] (bias-corrected), leak_raw[j], bias[j], src[j] and the
|
|
33
|
+
unexplained residual. R: (K, F, T); Y: (F, T). The caller makes sure there are enough frames.
|
|
34
|
+
"""
|
|
35
|
+
K, F, _ = R.shape
|
|
36
|
+
Tn = t1 - t0
|
|
37
|
+
leak = np.zeros(K)
|
|
38
|
+
leak_raw = np.zeros(K)
|
|
39
|
+
bias = np.zeros(K)
|
|
40
|
+
src = np.zeros(K)
|
|
41
|
+
res = 0.0
|
|
42
|
+
eye = np.eye(K)
|
|
43
|
+
for f0 in range(0, F, chunk):
|
|
44
|
+
X = np.transpose(R[:, f0:f0 + chunk, t0:t1], (1, 2, 0)).astype(np.complex128) # (f, T, K)
|
|
45
|
+
y = Y[f0:f0 + chunk, t0:t1].astype(np.complex128) # (f, T)
|
|
46
|
+
Xh = X.conj().transpose(0, 2, 1)
|
|
47
|
+
G = Xh @ X
|
|
48
|
+
tr = np.trace(G, axis1=1, axis2=2).real[:, None, None] / K
|
|
49
|
+
Gr = G + 1e-9 * np.maximum(tr, 1e-30) * eye # keeps silent references from breaking the solve
|
|
50
|
+
a = np.linalg.solve(Gr, (Xh @ y[..., None]))[..., 0] # (f, K)
|
|
51
|
+
e = y - (X @ a[..., None])[..., 0]
|
|
52
|
+
sig2 = (np.abs(e) ** 2).sum(axis=1) / (Tn - K) # unexplained power per observation, per bin
|
|
53
|
+
ginv = np.linalg.inv(Gr)
|
|
54
|
+
xj = (np.abs(X) ** 2).sum(axis=1) # (f, K) energy of each reference
|
|
55
|
+
comp = (np.abs(a) ** 2) * xj # (f, K) energy of each fitted component
|
|
56
|
+
b = sig2[:, None] * xj * np.einsum("fkk->fk", ginv).real # what pure chance would add to comp
|
|
57
|
+
leak_raw += comp.sum(axis=0)
|
|
58
|
+
bias += b.sum(axis=0)
|
|
59
|
+
src += xj.sum(axis=0)
|
|
60
|
+
leak += np.maximum(comp - b, 0.0).sum(axis=0)
|
|
61
|
+
res += float((np.abs(e) ** 2).sum())
|
|
62
|
+
return {"leak": leak, "leak_raw": leak_raw, "bias": bias, "src": src, "res": res}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def null_shifts(T: int, sr: int, hop: int, n: int, rng) -> list[int]:
|
|
66
|
+
"""Random time shifts (in frames) for the chance measurements: at least about 2 seconds, so that a
|
|
67
|
+
shifted instrument is no longer lined up with the estimate."""
|
|
68
|
+
min_shift = max(min(int(round(2.0 * sr / hop)), T // 4), 1)
|
|
69
|
+
return [int(rng.randint(min_shift, T - min_shift + 1)) for _ in range(n)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def interference(refs: np.ndarray, est: np.ndarray, target: int, sr: int, window_s: float | None = 10.0,
|
|
73
|
+
refs_stft=None, n_null: int = 3, seed: int = 0):
|
|
74
|
+
"""Measure how much of each reference leaks into `est`.
|
|
75
|
+
|
|
76
|
+
refs: (K, n, ch) reference stems; est: (n, ch) the estimate of reference `target`.
|
|
77
|
+
Returns a dict with the overall numbers and (if window_s) a per-window timeline.
|
|
78
|
+
Energies are spectrogram energies; callers turn them into dB ratios.
|
|
79
|
+
|
|
80
|
+
What counts as "chance": two unrelated signals always show a little relationship in a finite recording.
|
|
81
|
+
We estimate that level three ways and use the largest: a formula (assumes independent frames), `n_null`
|
|
82
|
+
re-measurements with the other instruments moved in time (keeps their tones and phase structure, so it copes
|
|
83
|
+
with sustained notes where neighbouring frames are not independent), and `n_null` re-measurements with their
|
|
84
|
+
phases scrambled (keeps when and where they are loud, so it copes with errors that are loud at the same
|
|
85
|
+
moments as an instrument). Each destroys any real leak. The timeline uses the formula only, to stay fast.
|
|
86
|
+
"""
|
|
87
|
+
K, n, ch = refs.shape
|
|
88
|
+
nfft = nfft_for(sr)
|
|
89
|
+
hop = nfft // 2
|
|
90
|
+
per_ch_R = refs_stft if refs_stft is not None else [stft(refs[:, :, c], sr) for c in range(ch)]
|
|
91
|
+
Ys = [stft(est[:, c], sr) for c in range(ch)]
|
|
92
|
+
T = per_ch_R[0].shape[2]
|
|
93
|
+
if T - K < 4:
|
|
94
|
+
raise ValueError("audio too short to measure (need at least a few seconds)")
|
|
95
|
+
others = [j for j in range(K) if j != target]
|
|
96
|
+
|
|
97
|
+
def pooled(t0, t1, Rs=per_ch_R):
|
|
98
|
+
tot = {"leak": np.zeros(K), "leak_raw": np.zeros(K), "bias": np.zeros(K), "src": np.zeros(K), "res": 0.0}
|
|
99
|
+
for R, Y in zip(Rs, Ys):
|
|
100
|
+
r = _fit(R, Y, t0, t1)
|
|
101
|
+
for k in tot:
|
|
102
|
+
tot[k] = tot[k] + r[k]
|
|
103
|
+
return tot
|
|
104
|
+
|
|
105
|
+
def summarize(tot, null_raw=None):
|
|
106
|
+
leak = tot["leak"].copy()
|
|
107
|
+
chance_mean = tot["bias"].copy()
|
|
108
|
+
chance_max = tot["bias"].copy()
|
|
109
|
+
if null_raw is not None and len(null_raw):
|
|
110
|
+
nr = np.array(null_raw) # (n_null, K)
|
|
111
|
+
chance_mean = np.maximum(chance_mean, nr.mean(axis=0))
|
|
112
|
+
chance_max = np.maximum(chance_max, nr.max(axis=0))
|
|
113
|
+
leak = np.maximum(tot["leak_raw"] - chance_mean, 0.0)
|
|
114
|
+
tgt = max(tot["leak_raw"][target] - tot["bias"][target], 0.0)
|
|
115
|
+
out = {"target_energy": tgt, "total_leak": float(leak[others].sum()), "unexplained": tot["res"], "per_source": {}}
|
|
116
|
+
for j in others:
|
|
117
|
+
out["per_source"][j] = {
|
|
118
|
+
"leak_energy": float(leak[j]), "bias_energy": float(chance_mean[j]), "chance_max_energy": float(chance_max[j]),
|
|
119
|
+
"raw_energy": float(tot["leak_raw"][j]), "src_energy": float(tot["src"][j]),
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
null_raw = []
|
|
124
|
+
if n_null:
|
|
125
|
+
rng = np.random.RandomState(seed)
|
|
126
|
+
shifts = null_shifts(T, sr, hop, n_null, rng)
|
|
127
|
+
for kind in ("shift", "phase"):
|
|
128
|
+
for k in range(n_null):
|
|
129
|
+
Rs = []
|
|
130
|
+
if kind == "shift":
|
|
131
|
+
# move the other instruments in time: keeps their tones and phase structure, breaks real leaks
|
|
132
|
+
shift = shifts[k]
|
|
133
|
+
for R in per_ch_R:
|
|
134
|
+
Rn = R.copy()
|
|
135
|
+
for j in others:
|
|
136
|
+
Rn[j] = np.roll(R[j], shift, axis=1)
|
|
137
|
+
Rs.append(Rn)
|
|
138
|
+
else:
|
|
139
|
+
# scramble their phases: keeps when they are loud and where, breaks real leaks
|
|
140
|
+
for R in per_ch_R:
|
|
141
|
+
Rn = R.copy()
|
|
142
|
+
for j in others:
|
|
143
|
+
Rn[j] = R[j] * np.exp(1j * rng.uniform(0.0, 2 * np.pi, R[j].shape)).astype(np.complex64)
|
|
144
|
+
Rs.append(Rn)
|
|
145
|
+
null_raw.append(pooled(0, T, Rs)["leak_raw"])
|
|
146
|
+
overall = summarize(pooled(0, T), null_raw)
|
|
147
|
+
timeline = []
|
|
148
|
+
if window_s:
|
|
149
|
+
w = max(int(round(window_s * sr / hop)), K + 8)
|
|
150
|
+
for t0 in range(0, T - w + 1, w):
|
|
151
|
+
timeline.append({"start_s": round(t0 * hop / sr, 3), "seconds": round(w * hop / sr, 3),
|
|
152
|
+
**summarize(pooled(t0, t0 + w))})
|
|
153
|
+
return {"overall": overall, "timeline": timeline}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def shared_content(a: np.ndarray, b: np.ndarray, sr: int, n_perm: int = 19, seed: int = 0) -> dict:
|
|
157
|
+
"""How much do two stems have in common beyond chance? (a, b: (n,) mono signals)
|
|
158
|
+
|
|
159
|
+
Uses magnitude-squared coherence per frequency bin, weighted toward bins where both stems have energy.
|
|
160
|
+
The 'chance' level comes from shifting one stem in time by a random amount and measuring again.
|
|
161
|
+
Returns the statistic, the chance level, the excess, and a p-value from the shifts.
|
|
162
|
+
"""
|
|
163
|
+
A = stft(a, sr).astype(np.complex128)
|
|
164
|
+
B = stft(b, sr).astype(np.complex128)
|
|
165
|
+
F, T = A.shape
|
|
166
|
+
Pa = (np.abs(A) ** 2).sum(axis=1)
|
|
167
|
+
Pb = (np.abs(B) ** 2).sum(axis=1)
|
|
168
|
+
denom = np.maximum(Pa * Pb, 1e-30)
|
|
169
|
+
w = Pa * Pb
|
|
170
|
+
if w.sum() <= 0 or T < 8:
|
|
171
|
+
return {"statistic": 0.0, "chance": 0.0, "excess": 0.0, "excess_db": None, "p_value": 1.0}
|
|
172
|
+
w = w / w.sum()
|
|
173
|
+
|
|
174
|
+
def stat(Bs):
|
|
175
|
+
return float((np.abs((A * Bs.conj()).sum(axis=1)) ** 2 / denom * w).sum())
|
|
176
|
+
|
|
177
|
+
s = stat(B)
|
|
178
|
+
hop = nfft_for(sr) // 2
|
|
179
|
+
min_shift = max(int(round(sr / hop)), 1) # at least about one second
|
|
180
|
+
if T <= 2 * min_shift + 2:
|
|
181
|
+
min_shift = max(T // 4, 1)
|
|
182
|
+
rng = np.random.RandomState(seed)
|
|
183
|
+
nulls = np.array([stat(np.roll(B, rng.randint(min_shift, T - min_shift + 1), axis=1)) for _ in range(n_perm)])
|
|
184
|
+
chance = float(nulls.mean())
|
|
185
|
+
excess = max(s - chance, 0.0)
|
|
186
|
+
p = float((1 + (nulls >= s).sum()) / (n_perm + 1))
|
|
187
|
+
return {"statistic": s, "chance": chance, "excess": excess, "excess_db": _db(excess), "p_value": p}
|