stemcheck 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.
- stemcheck/__init__.py +5 -0
- stemcheck/_version.py +1 -0
- stemcheck/audio.py +83 -0
- stemcheck/bleed.py +187 -0
- stemcheck/check.py +173 -0
- stemcheck/cli.py +73 -0
- stemcheck/report.py +370 -0
- stemcheck/score.py +175 -0
- stemcheck-0.1.0.dist-info/METADATA +159 -0
- stemcheck-0.1.0.dist-info/RECORD +14 -0
- stemcheck-0.1.0.dist-info/WHEEL +5 -0
- stemcheck-0.1.0.dist-info/entry_points.txt +2 -0
- stemcheck-0.1.0.dist-info/licenses/LICENSE +29 -0
- stemcheck-0.1.0.dist-info/top_level.txt +1 -0
stemcheck/__init__.py
ADDED
|
@@ -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
|
stemcheck/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
stemcheck/audio.py
ADDED
|
@@ -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)
|
stemcheck/bleed.py
ADDED
|
@@ -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}
|
stemcheck/check.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""check: sanity checks on a mixture and its separated stems. No reference stems needed."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy import signal
|
|
8
|
+
|
|
9
|
+
from .audio import Audio, StemcheckError, center_window, match_channels, resample
|
|
10
|
+
from .bleed import shared_content
|
|
11
|
+
|
|
12
|
+
# Default limits. These are judgement calls, not measured standards; change them with the options.
|
|
13
|
+
RESIDUAL_WARN_DB = -30.0 # stems leave more than this much of the mix unexplained -> WARN
|
|
14
|
+
RESIDUAL_FAIL_DB = -15.0 # ... -> FAIL
|
|
15
|
+
SILENT_DBFS = -70.0
|
|
16
|
+
CLIP_FRACTION = 0.001
|
|
17
|
+
DC_LIMIT = 0.01
|
|
18
|
+
DUP_CORR = 0.99
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Finding:
|
|
23
|
+
level: str # FAIL, WARN or INFO
|
|
24
|
+
code: str
|
|
25
|
+
message: str
|
|
26
|
+
stem: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CheckResult:
|
|
31
|
+
findings: list[Finding]
|
|
32
|
+
sum_check: dict | None
|
|
33
|
+
stems: dict[str, dict]
|
|
34
|
+
pairs: list[dict]
|
|
35
|
+
sr: int
|
|
36
|
+
seconds_analysed: float
|
|
37
|
+
|
|
38
|
+
def verdict(self) -> str:
|
|
39
|
+
if any(f.level == "FAIL" for f in self.findings):
|
|
40
|
+
return "FAIL"
|
|
41
|
+
if any(f.level == "WARN" for f in self.findings):
|
|
42
|
+
return "WARN"
|
|
43
|
+
return "PASS"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def estimate_delay(a: np.ndarray, b: np.ndarray, max_lag: int) -> int:
|
|
47
|
+
"""How many samples `a` is delayed relative to `b` (positive: a[t] looks like b[t - lag]). Mono inputs."""
|
|
48
|
+
n = min(len(a), len(b))
|
|
49
|
+
a, b = a[:n].astype(np.float64), b[:n].astype(np.float64)
|
|
50
|
+
if n < 16 or not np.any(a) or not np.any(b):
|
|
51
|
+
return 0
|
|
52
|
+
c = signal.correlate(a, b, mode="full", method="fft")
|
|
53
|
+
lags = signal.correlation_lags(n, n, mode="full")
|
|
54
|
+
keep = np.abs(lags) <= max_lag
|
|
55
|
+
return int(lags[keep][np.argmax(c[keep])])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shift(a: np.ndarray, lag: int) -> np.ndarray:
|
|
59
|
+
"""Return `a` moved earlier by `lag` samples (undoing a delay), same length, zero-padded at the end."""
|
|
60
|
+
if lag == 0:
|
|
61
|
+
return a
|
|
62
|
+
out = np.zeros_like(a)
|
|
63
|
+
if lag > 0:
|
|
64
|
+
out[:-lag] = a[lag:]
|
|
65
|
+
else:
|
|
66
|
+
out[-lag:] = a[:lag]
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dbfs(x: np.ndarray) -> float:
|
|
71
|
+
return float(10 * np.log10(max(float(np.mean(np.square(x, dtype=np.float64))), 1e-30)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_stems(mixture: Audio, stems: dict[str, Audio], *, max_seconds: float | None = 120.0,
|
|
75
|
+
residual_warn_db: float = RESIDUAL_WARN_DB, residual_fail_db: float = RESIDUAL_FAIL_DB,
|
|
76
|
+
max_shared_db: float | None = None, shared: bool = True, n_perm: int = 19) -> CheckResult:
|
|
77
|
+
"""Sanity-check separated `stems` against the `mixture` they came from."""
|
|
78
|
+
if len(stems) < 2:
|
|
79
|
+
raise StemcheckError("need at least 2 stems to check")
|
|
80
|
+
F: list[Finding] = []
|
|
81
|
+
sr, ch = mixture.sr, mixture.ch
|
|
82
|
+
M = np.nan_to_num(mixture.data, nan=0.0, posinf=0.0, neginf=0.0)
|
|
83
|
+
if not np.all(np.isfinite(mixture.data)):
|
|
84
|
+
F.append(Finding("FAIL", "non_finite", "the mixture contains NaN or infinite values"))
|
|
85
|
+
if _dbfs(M) < SILENT_DBFS:
|
|
86
|
+
raise StemcheckError("the mixture is silent, nothing to check against")
|
|
87
|
+
|
|
88
|
+
# bring every stem to the mixture's rate, channel count and length
|
|
89
|
+
matched, info = {}, {}
|
|
90
|
+
for name in sorted(stems):
|
|
91
|
+
a = stems[name]
|
|
92
|
+
data = a.data
|
|
93
|
+
d = {"sample_rate": a.sr, "channels": a.ch, "seconds": round(a.n / a.sr, 3)}
|
|
94
|
+
if not np.all(np.isfinite(data)):
|
|
95
|
+
F.append(Finding("FAIL", "non_finite", "contains NaN or infinite values", name))
|
|
96
|
+
data = np.nan_to_num(data, nan=0.0, posinf=0.0, neginf=0.0)
|
|
97
|
+
if a.sr != sr:
|
|
98
|
+
F.append(Finding("WARN", "sample_rate", f"sample rate is {a.sr} Hz, the mixture is {sr} Hz (resampled for the checks)", name))
|
|
99
|
+
data = resample(data, a.sr, sr)
|
|
100
|
+
if data.shape[1] != ch:
|
|
101
|
+
F.append(Finding("WARN", "channels", f"has {a.ch} channel(s), the mixture has {ch} (matched up for the checks)", name))
|
|
102
|
+
data = match_channels(data, ch)
|
|
103
|
+
if abs(data.shape[0] - M.shape[0]) > 2:
|
|
104
|
+
diff = data.shape[0] - M.shape[0]
|
|
105
|
+
F.append(Finding("WARN", "length", f"is {abs(diff) / sr:.2f} s {'longer' if diff > 0 else 'shorter'} than the mixture (trimmed for the checks)", name))
|
|
106
|
+
matched[name] = data
|
|
107
|
+
raw_peak = float(np.abs(a.data[np.isfinite(a.data)]).max()) if a.data.size else 0.0
|
|
108
|
+
d["rms_dbfs"] = round(_dbfs(data), 2)
|
|
109
|
+
d["peak"] = round(raw_peak, 4)
|
|
110
|
+
info[name] = d
|
|
111
|
+
if d["rms_dbfs"] < SILENT_DBFS:
|
|
112
|
+
F.append(Finding("WARN", "silent", f"is silent (RMS {d['rms_dbfs']} dBFS). That is fine for an instrument that is not in the song, a problem otherwise", name))
|
|
113
|
+
else:
|
|
114
|
+
frac = float(np.mean(np.abs(a.data[np.isfinite(a.data)]) >= 0.999)) if a.data.size else 0.0
|
|
115
|
+
if frac > CLIP_FRACTION or raw_peak > 1.0:
|
|
116
|
+
F.append(Finding("WARN", "clipping", f"is clipped or too loud ({frac:.2%} of samples at full scale, peak {raw_peak:.2f})", name))
|
|
117
|
+
dc = float(np.abs(a.data[np.isfinite(a.data)].mean(axis=0)).max())
|
|
118
|
+
info[name]["dc_offset"] = round(dc, 5)
|
|
119
|
+
if dc > DC_LIMIT:
|
|
120
|
+
F.append(Finding("WARN", "dc_offset", f"has a DC offset of {dc:.3f} (a constant shift of the waveform)", name))
|
|
121
|
+
|
|
122
|
+
n = min([M.shape[0]] + [a.shape[0] for a in matched.values()])
|
|
123
|
+
sl = center_window(n, sr, max_seconds)
|
|
124
|
+
Mw = M[:n][sl].astype(np.float64)
|
|
125
|
+
W = {k: v[:n][sl].astype(np.float64) for k, v in matched.items()}
|
|
126
|
+
S = sum(W.values())
|
|
127
|
+
|
|
128
|
+
# do the stems add up to the mixture?
|
|
129
|
+
num = float(np.sum((Mw - S) ** 2))
|
|
130
|
+
den = float(np.sum(Mw ** 2))
|
|
131
|
+
res_db = round(10 * np.log10(max(num / den, 1e-10)), 2) # below -100 dB is rounding noise
|
|
132
|
+
g = float(np.sum(Mw * S) / max(float(np.sum(S ** 2)), 1e-30)) if np.any(S) else 0.0
|
|
133
|
+
gain_db = round(20 * np.log10(g), 2) if g > 0 else None
|
|
134
|
+
lag = estimate_delay(S.mean(axis=1), Mw.mean(axis=1), int(0.1 * sr))
|
|
135
|
+
aligned_db = None
|
|
136
|
+
if lag != 0:
|
|
137
|
+
Sa = _shift(S, lag)
|
|
138
|
+
aligned_db = round(10 * np.log10(max(float(np.sum((Mw - Sa) ** 2)) / den, 1e-10)), 2)
|
|
139
|
+
sumc = {"residual_db": res_db, "best_gain_db": gain_db, "delay_samples": lag, "residual_after_alignment_db": aligned_db}
|
|
140
|
+
if res_db > residual_fail_db:
|
|
141
|
+
F.append(Finding("FAIL", "sum", f"the stems do not add up to the mixture: {res_db} dB of the mix is unexplained (limit {residual_fail_db} dB)"))
|
|
142
|
+
elif res_db > residual_warn_db:
|
|
143
|
+
F.append(Finding("WARN", "sum", f"the stems only roughly add up to the mixture: {res_db} dB unexplained (usual limit {residual_warn_db} dB)"))
|
|
144
|
+
if gain_db is not None and abs(gain_db) > 1.0:
|
|
145
|
+
F.append(Finding("WARN", "gain", f"added together the stems are about {abs(gain_db):.1f} dB {'quieter' if gain_db > 0 else 'louder'} than the mixture"))
|
|
146
|
+
if lag != 0 and aligned_db is not None and aligned_db < res_db - 3.0:
|
|
147
|
+
F.append(Finding("WARN", "delay", f"the stems look delayed by {lag} samples ({1000 * lag / sr:.1f} ms); aligned, the unexplained part drops from {res_db} to {aligned_db} dB"))
|
|
148
|
+
|
|
149
|
+
# two stems that are the same audio
|
|
150
|
+
names = sorted(W)
|
|
151
|
+
mono = {k: v.mean(axis=1) for k, v in W.items()}
|
|
152
|
+
for i, a in enumerate(names):
|
|
153
|
+
for b in names[i + 1:]:
|
|
154
|
+
if info[a]["rms_dbfs"] < SILENT_DBFS or info[b]["rms_dbfs"] < SILENT_DBFS:
|
|
155
|
+
continue
|
|
156
|
+
x, y = mono[a], mono[b]
|
|
157
|
+
c = float(np.sum(x * y) / max(np.sqrt(np.sum(x * x) * np.sum(y * y)), 1e-30))
|
|
158
|
+
if abs(c) >= DUP_CORR:
|
|
159
|
+
F.append(Finding("FAIL", "duplicate", f"{a} and {b} are almost the same audio (correlation {c:.3f})"))
|
|
160
|
+
|
|
161
|
+
# shared content between pairs (a hint, see README)
|
|
162
|
+
pairs = []
|
|
163
|
+
if shared:
|
|
164
|
+
for i, a in enumerate(names):
|
|
165
|
+
for b in names[i + 1:]:
|
|
166
|
+
if info[a]["rms_dbfs"] < SILENT_DBFS or info[b]["rms_dbfs"] < SILENT_DBFS:
|
|
167
|
+
continue
|
|
168
|
+
r = shared_content(mono[a], mono[b], sr, n_perm=n_perm)
|
|
169
|
+
r.update({"a": a, "b": b})
|
|
170
|
+
pairs.append(r)
|
|
171
|
+
if max_shared_db is not None and r["excess_db"] is not None and r["excess_db"] > max_shared_db and r["p_value"] <= 0.05:
|
|
172
|
+
F.append(Finding("WARN", "shared", f"{a} and {b} share content beyond chance ({r['excess_db']} dB, p={r['p_value']:.2f})"))
|
|
173
|
+
return CheckResult(F, sumc, info, pairs, sr, round(Mw.shape[0] / sr, 2))
|
stemcheck/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .audio import StemcheckError, find_stem_files, read_audio
|
|
10
|
+
from .check import RESIDUAL_FAIL_DB, RESIDUAL_WARN_DB, check_stems
|
|
11
|
+
from .report import (check_to_dict, check_to_text, score_to_dict, score_to_text, write_check_reports,
|
|
12
|
+
write_score_reports)
|
|
13
|
+
from .score import score_stems
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_folder(folder: str, exclude=("mixture",)):
|
|
17
|
+
return {name: read_audio(p) for name, p in find_stem_files(Path(folder), exclude).items()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None) -> int:
|
|
21
|
+
ap = argparse.ArgumentParser(prog="stemcheck", description="Check separated music stems.")
|
|
22
|
+
ap.add_argument("--version", action="version", version=f"stemcheck {__version__}")
|
|
23
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
24
|
+
|
|
25
|
+
c = sub.add_parser("check", help="sanity-check stems against the mixture (no reference stems needed)")
|
|
26
|
+
c.add_argument("mixture", help="the original mixture file")
|
|
27
|
+
c.add_argument("stems", help="folder with the separated stems (vocals.wav, drums.wav, ...)")
|
|
28
|
+
c.add_argument("--max-seconds", type=float, default=120.0, help="analyse only the middle N seconds (default 120, 0 = all)")
|
|
29
|
+
c.add_argument("--residual-warn", type=float, default=RESIDUAL_WARN_DB, help=f"WARN if unexplained mix is above this many dB (default {RESIDUAL_WARN_DB:g})")
|
|
30
|
+
c.add_argument("--residual-fail", type=float, default=RESIDUAL_FAIL_DB, help=f"FAIL if above this many dB (default {RESIDUAL_FAIL_DB:g})")
|
|
31
|
+
c.add_argument("--max-shared", type=float, help="WARN if two stems share more than this many dB of content beyond chance (off by default; it is only a hint)")
|
|
32
|
+
c.add_argument("--no-shared", action="store_true", help="skip the shared-content hint")
|
|
33
|
+
c.add_argument("--out", help="folder to write report files (json, txt)")
|
|
34
|
+
c.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
35
|
+
|
|
36
|
+
s = sub.add_parser("score", help="measure leakage in estimated stems against reference stems")
|
|
37
|
+
s.add_argument("reference", help="folder with the true stems")
|
|
38
|
+
s.add_argument("estimate", help="folder with the estimated stems (same file names)")
|
|
39
|
+
s.add_argument("--window", type=float, default=10.0, help="section length in seconds for the timeline (default 10, 0 = off)")
|
|
40
|
+
s.add_argument("--max-seconds", type=float, default=120.0, help="analyse only the middle N seconds (default 120, 0 = all)")
|
|
41
|
+
s.add_argument("--null-shifts", type=int, default=3, help="how many time-shifted re-measurements estimate the chance level (default 3, 0 = formula only, faster)")
|
|
42
|
+
s.add_argument("--max-bleed", type=float, help="FAIL if a stem's total leak is above this many dB relative to the stem (e.g. -25)")
|
|
43
|
+
s.add_argument("--min-sdr", type=float, help="FAIL if a stem's quality number is below this many dB")
|
|
44
|
+
s.add_argument("--out", help="folder to write report files (json, txt, csv)")
|
|
45
|
+
s.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
46
|
+
|
|
47
|
+
a = ap.parse_args(argv)
|
|
48
|
+
if a.cmd not in ("check", "score"):
|
|
49
|
+
ap.print_help()
|
|
50
|
+
return 2
|
|
51
|
+
try:
|
|
52
|
+
if a.cmd == "check":
|
|
53
|
+
res = check_stems(read_audio(Path(a.mixture)), _load_folder(a.stems), max_seconds=a.max_seconds or None,
|
|
54
|
+
residual_warn_db=a.residual_warn, residual_fail_db=a.residual_fail,
|
|
55
|
+
max_shared_db=a.max_shared, shared=not a.no_shared)
|
|
56
|
+
if a.out:
|
|
57
|
+
write_check_reports(res, Path(a.out))
|
|
58
|
+
print(json.dumps(check_to_dict(res), indent=2) if a.json else check_to_text(res))
|
|
59
|
+
return 1 if res.verdict() == "FAIL" else 0
|
|
60
|
+
res = score_stems(_load_folder(a.reference), _load_folder(a.estimate), window_s=a.window or None,
|
|
61
|
+
max_seconds=a.max_seconds or None, max_bleed_db=a.max_bleed, min_sdr_db=a.min_sdr,
|
|
62
|
+
null_shifts=a.null_shifts)
|
|
63
|
+
if a.out:
|
|
64
|
+
write_score_reports(res, Path(a.out))
|
|
65
|
+
print(json.dumps(score_to_dict(res), indent=2) if a.json else score_to_text(res))
|
|
66
|
+
return 1 if res.verdict() == "FAIL" else 0
|
|
67
|
+
except (StemcheckError, OSError, ValueError) as e:
|
|
68
|
+
print(f"error: {e}", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
stemcheck/report.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .check import CheckResult
|
|
9
|
+
from .score import ScoreResult
|
|
10
|
+
|
|
11
|
+
CHECK_NOTE = ("Note: a PASS means none of these checks found a problem. It does not prove the separation is good. "
|
|
12
|
+
"The 'shared content' numbers are a weak hint (see the README); leakage is only measured reliably "
|
|
13
|
+
"with reference stems (stemcheck score).")
|
|
14
|
+
SCORE_NOTE = ("How to read this: each row is one estimated stem, each column one other instrument. The number is how "
|
|
15
|
+
"loud that instrument's leak is compared with the stem's own content (more negative = cleaner). "
|
|
16
|
+
"It measures the steady, filter-like part of the leak. Fast-changing leaks show up as 'unexplained'. "
|
|
17
|
+
"For mask-based separators it is typically a little lower than the true leak (about 4 dB in our synthetic test).")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _f(x, nd=1, none="n/a"):
|
|
21
|
+
if x is None:
|
|
22
|
+
return none
|
|
23
|
+
if x <= -100:
|
|
24
|
+
return "<-100"
|
|
25
|
+
if x >= 100:
|
|
26
|
+
return ">100"
|
|
27
|
+
return f"{x:.{nd}f}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------- check
|
|
31
|
+
|
|
32
|
+
def check_to_dict(res: CheckResult) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"verdict": res.verdict(), "sample_rate": res.sr, "seconds_analysed": res.seconds_analysed,
|
|
35
|
+
"findings": [asdict(f) for f in res.findings], "sum_check": res.sum_check,
|
|
36
|
+
"stems": res.stems, "shared_content": res.pairs, "note": CHECK_NOTE,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_to_text(res: CheckResult) -> str:
|
|
41
|
+
L = [f"stemcheck verdict: {res.verdict()}",
|
|
42
|
+
f"Analysed {res.seconds_analysed:g} s at {res.sr} Hz, {len(res.stems)} stems: {', '.join(res.stems)}", ""]
|
|
43
|
+
s = res.sum_check or {}
|
|
44
|
+
L.append(f"Do the stems add up to the mixture? {_f(s.get('residual_db'))} dB of the mix is unexplained "
|
|
45
|
+
"(closer to -inf is better)")
|
|
46
|
+
if s.get("best_gain_db") is not None:
|
|
47
|
+
L.append(f" overall level of the stems versus the mixture: {s['best_gain_db']:+.2f} dB off")
|
|
48
|
+
if s.get("delay_samples"):
|
|
49
|
+
L.append(f" stems look delayed by {s['delay_samples']} samples; unexplained after aligning: {_f(s.get('residual_after_alignment_db'))} dB")
|
|
50
|
+
L.append("")
|
|
51
|
+
probs = [f for f in res.findings if f.level != "INFO"]
|
|
52
|
+
if probs:
|
|
53
|
+
L.append("Findings:")
|
|
54
|
+
for f in probs:
|
|
55
|
+
who = f"[{f.stem}] " if f.stem else ""
|
|
56
|
+
L.append(f" {f.level:4s} {who}{f.message}")
|
|
57
|
+
else:
|
|
58
|
+
L.append("No problems found.")
|
|
59
|
+
L.append("")
|
|
60
|
+
if res.pairs:
|
|
61
|
+
L.append("Shared content between stems (weak hint; 'chance' removed, dB of coherence, lower is cleaner):")
|
|
62
|
+
for p in res.pairs:
|
|
63
|
+
L.append(f" {p['a']} / {p['b']}: {_f(p['excess_db'], 1, 'none above chance')}"
|
|
64
|
+
+ (f" dB (p={p['p_value']:.2f})" if p['excess_db'] is not None else ""))
|
|
65
|
+
L.append("")
|
|
66
|
+
L.append(CHECK_NOTE)
|
|
67
|
+
return "\n".join(L)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- score
|
|
71
|
+
|
|
72
|
+
def score_to_dict(res: ScoreResult) -> dict:
|
|
73
|
+
return {"verdict": res.verdict(), "sample_rate": res.sr, "seconds_analysed": res.seconds_analysed,
|
|
74
|
+
"max_bleed_db": res.max_bleed_db, "min_sdr_db": res.min_sdr_db, "notes": res.notes,
|
|
75
|
+
"stems": [asdict(s) for s in res.stems], "note": SCORE_NOTE}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def score_to_text(res: ScoreResult) -> str:
|
|
79
|
+
v = res.verdict()
|
|
80
|
+
L = [f"stemcheck score: {v if v else 'numbers only (no limits set)'}",
|
|
81
|
+
f"Analysed {res.seconds_analysed:g} s at {res.sr} Hz.", ""]
|
|
82
|
+
for n in res.notes:
|
|
83
|
+
L.append(f"note: {n}")
|
|
84
|
+
if res.notes:
|
|
85
|
+
L.append("")
|
|
86
|
+
sources = sorted({b.source for s in res.stems for b in s.bleed})
|
|
87
|
+
head = f"{'stem':10s} {'quality':>8s} {'total leak':>10s} {'unexplained':>11s} | " + " ".join(f"{'from ' + x:>12s}" for x in sources)
|
|
88
|
+
L.append(head)
|
|
89
|
+
L.append("-" * len(head))
|
|
90
|
+
for s in res.stems:
|
|
91
|
+
row = f"{s.name:10s} {_f(s.sdr_like_db):>8s} {_f(s.total_bleed_vs_target_db):>10s} {_f(s.unexplained_vs_target_db):>11s} | "
|
|
92
|
+
cells = []
|
|
93
|
+
by = {b.source: b for b in s.bleed}
|
|
94
|
+
for x in sources:
|
|
95
|
+
b = by.get(x)
|
|
96
|
+
if b is None:
|
|
97
|
+
cells.append(f"{'-':>12s}")
|
|
98
|
+
elif b.source_silent or (b.detected and b.vs_target_db is None):
|
|
99
|
+
cells.append(f"{'n/a':>12s}")
|
|
100
|
+
elif not b.detected:
|
|
101
|
+
cells.append(f"{'<floor':>12s}")
|
|
102
|
+
else:
|
|
103
|
+
cells.append(f"{_f(b.vs_target_db):>12s}")
|
|
104
|
+
L.append(row + " ".join(cells))
|
|
105
|
+
L += ["", "quality = target compared with all leak and error, in dB (higher is better; a simple stand-in for SDR, use museval for published numbers).",
|
|
106
|
+
"total leak, unexplained, from X = dB relative to the stem's own content (more negative is cleaner). '<floor' = below what chance would show.", ""]
|
|
107
|
+
for s in res.stems:
|
|
108
|
+
worst = [b for b in s.bleed if b.detected and b.vs_target_db is not None][:2]
|
|
109
|
+
if worst:
|
|
110
|
+
L.append(f"{s.name}: biggest leaks: " + ", ".join(f"{b.source} ({b.vs_target_db:.1f} dB vs stem, {b.leak_dbfs:.1f} dBFS)" for b in worst))
|
|
111
|
+
elif any(b.vs_target_db is not None for b in s.bleed):
|
|
112
|
+
L.append(f"{s.name}: no leak above the measurement floor")
|
|
113
|
+
if s.timeline:
|
|
114
|
+
vals = [(w.total_bleed_vs_target_db, w.start_s) for w in s.timeline if w.total_bleed_vs_target_db is not None]
|
|
115
|
+
if vals:
|
|
116
|
+
db, t = max(vals)
|
|
117
|
+
L.append(f"{' ' * len(s.name)} worst {s.timeline[0].seconds:g} s section starts at {t:g} s ({db:.1f} dB)")
|
|
118
|
+
for n in s.notes:
|
|
119
|
+
L.append(f"{' ' * len(s.name)} note: {n}")
|
|
120
|
+
L += ["", SCORE_NOTE]
|
|
121
|
+
return "\n".join(L)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write(path: Path, text: str):
|
|
125
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
126
|
+
f.write(text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def write_check_reports(res: CheckResult, out: Path):
|
|
130
|
+
out = Path(out)
|
|
131
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
_write(out / "stemcheck_report.json", json.dumps(check_to_dict(res), indent=2) + "\n")
|
|
133
|
+
_write(out / "stemcheck_report.txt", check_to_text(res) + "\n")
|
|
134
|
+
_write(out / "stemcheck_report.html", check_to_html(res, out / "stemcheck_report.html"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_score_reports(res: ScoreResult, out: Path):
|
|
138
|
+
out = Path(out)
|
|
139
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
_write(out / "stemcheck_score.json", json.dumps(score_to_dict(res), indent=2) + "\n")
|
|
141
|
+
_write(out / "stemcheck_score.txt", score_to_text(res) + "\n")
|
|
142
|
+
_write(out / "stemcheck_score.html", score_to_html(res, out / "stemcheck_score.html"))
|
|
143
|
+
with open(out / "bleed_matrix.csv", "w", encoding="utf-8", newline="") as f:
|
|
144
|
+
w = csv.writer(f, lineterminator="\n")
|
|
145
|
+
w.writerow(["stem", "source", "vs_target_db", "vs_source_db", "floor_vs_source_db", "detected", "leak_dbfs", "source_silent"])
|
|
146
|
+
for s in res.stems:
|
|
147
|
+
for b in s.bleed:
|
|
148
|
+
w.writerow([s.name, b.source, b.vs_target_db, b.vs_source_db, b.floor_vs_source_db, b.detected, b.leak_dbfs, b.source_silent])
|
|
149
|
+
with open(out / "timeline.csv", "w", encoding="utf-8", newline="") as f:
|
|
150
|
+
w = csv.writer(f, lineterminator="\n")
|
|
151
|
+
w.writerow(["stem", "start_s", "seconds", "total_bleed_vs_target_db", "worst_source", "worst_vs_target_db", "sdr_like_db"])
|
|
152
|
+
for s in res.stems:
|
|
153
|
+
for t in s.timeline:
|
|
154
|
+
w.writerow([s.name, t.start_s, t.seconds, t.total_bleed_vs_target_db, t.worst_source, t.worst_vs_target_db, t.sdr_like_db])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def score_to_html(res: ScoreResult, out_path: Path | None = None) -> str:
|
|
158
|
+
verdict = res.verdict()
|
|
159
|
+
if verdict == "PASS":
|
|
160
|
+
badge_style = "background: linear-gradient(135deg, #10b981, #059669); color: white;"
|
|
161
|
+
badge_text = "PASS — SEPARATION CRITERIA MET"
|
|
162
|
+
elif verdict == "FAIL":
|
|
163
|
+
badge_style = "background: linear-gradient(135deg, #ef4444, #dc2626); color: white;"
|
|
164
|
+
badge_text = "FAIL — EXCESSIVE BLEED DETECTED"
|
|
165
|
+
else:
|
|
166
|
+
badge_style = "background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: white;"
|
|
167
|
+
badge_text = "SCORED — EVALUATION BENCHMARK"
|
|
168
|
+
|
|
169
|
+
sources = sorted({b.source for s in res.stems for b in s.bleed})
|
|
170
|
+
headers_html = "".join(f'<th style="padding:10px 14px;text-align:right;border-bottom:2px solid #334155;">from {x}</th>' for x in sources)
|
|
171
|
+
|
|
172
|
+
rows_html = []
|
|
173
|
+
worst_leaks_list = []
|
|
174
|
+
for s in res.stems:
|
|
175
|
+
by = {b.source: b for b in s.bleed}
|
|
176
|
+
cells = []
|
|
177
|
+
for x in sources:
|
|
178
|
+
b = by.get(x)
|
|
179
|
+
if b is None:
|
|
180
|
+
cells.append('<td style="padding:10px 14px;text-align:right;color:#64748b;">—</td>')
|
|
181
|
+
elif b.source_silent or (b.detected and b.vs_target_db is None):
|
|
182
|
+
cells.append('<td style="padding:10px 14px;text-align:right;color:#64748b;">n/a</td>')
|
|
183
|
+
elif not b.detected:
|
|
184
|
+
cells.append('<td style="padding:10px 14px;text-align:right;color:#10b981;font-weight:600;"><floor</td>')
|
|
185
|
+
else:
|
|
186
|
+
val = b.vs_target_db
|
|
187
|
+
color = "#ef4444" if val > -25 else ("#f59e0b" if val > -35 else "#38bdf8")
|
|
188
|
+
cells.append(f'<td style="padding:10px 14px;text-align:right;color:{color};font-weight:700;">{val:.1f} dB</td>')
|
|
189
|
+
|
|
190
|
+
worst = [b for b in s.bleed if b.detected and b.vs_target_db is not None]
|
|
191
|
+
for b in worst:
|
|
192
|
+
worst_leaks_list.append((b.vs_target_db, f"<strong>{s.name}</strong> ← from <strong>{b.source}</strong> ({b.vs_target_db:.1f} dB vs stem, {b.leak_dbfs:.1f} dBFS)"))
|
|
193
|
+
|
|
194
|
+
tl_text = "—"
|
|
195
|
+
if s.timeline:
|
|
196
|
+
vals = [(w.total_bleed_vs_target_db, w.start_s) for w in s.timeline if w.total_bleed_vs_target_db is not None]
|
|
197
|
+
if vals:
|
|
198
|
+
db_val, t_val = max(vals)
|
|
199
|
+
tl_text = f"at {t_val:.1f}s ({db_val:.1f} dB)"
|
|
200
|
+
|
|
201
|
+
sdr_val = f"{s.sdr_like_db:.1f} dB" if s.sdr_like_db is not None else "n/a"
|
|
202
|
+
leak_val = f"{s.total_bleed_vs_target_db:.1f} dB" if s.total_bleed_vs_target_db is not None else "n/a"
|
|
203
|
+
unexp_val = f"{s.unexplained_vs_target_db:.1f} dB" if s.unexplained_vs_target_db is not None else "n/a"
|
|
204
|
+
|
|
205
|
+
row = f"""
|
|
206
|
+
<tr style="border-bottom:1px solid #1e293b;">
|
|
207
|
+
<td style="padding:10px 14px;font-weight:700;color:#f8fafc;">{s.name.upper()}</td>
|
|
208
|
+
<td style="padding:10px 14px;text-align:right;color:#34d399;font-weight:600;">{sdr_val}</td>
|
|
209
|
+
<td style="padding:10px 14px;text-align:right;color:#93c5fd;font-weight:600;">{leak_val}</td>
|
|
210
|
+
<td style="padding:10px 14px;text-align:right;color:#cbd5e1;">{unexp_val}</td>
|
|
211
|
+
<td style="padding:10px 14px;text-align:right;color:#f59e0b;font-size:0.85rem;">{tl_text}</td>
|
|
212
|
+
{''.join(cells)}
|
|
213
|
+
</tr>
|
|
214
|
+
"""
|
|
215
|
+
rows_html.append(row)
|
|
216
|
+
|
|
217
|
+
worst_leaks_list.sort(key=lambda x: x[0], reverse=True)
|
|
218
|
+
worst_html = "".join(f"<li>{item[1]}</li>" for item in worst_leaks_list[:5]) or "<li>No leak above measurement floor detected.</li>"
|
|
219
|
+
|
|
220
|
+
return f"""<!DOCTYPE html>
|
|
221
|
+
<html lang="en">
|
|
222
|
+
<head>
|
|
223
|
+
<meta charset="utf-8">
|
|
224
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
225
|
+
<title>StemCheck Bleed Audit Report</title>
|
|
226
|
+
<style>
|
|
227
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #020617; color: #f1f5f9; margin: 0; padding: 30px 20px; }}
|
|
228
|
+
.container {{ max-width: 1100px; margin: 0 auto; }}
|
|
229
|
+
header {{ border-bottom: 1px solid #1e293b; padding-bottom: 20px; margin-bottom: 25px; }}
|
|
230
|
+
.badge {{ display: inline-block; padding: 6px 16px; border-radius: 9999px; font-weight: 700; font-size: 0.85rem; }}
|
|
231
|
+
.stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 20px; }}
|
|
232
|
+
.stat-card {{ background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 12px 16px; }}
|
|
233
|
+
.stat-label {{ font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; }}
|
|
234
|
+
.stat-value {{ font-size: 1.3rem; font-weight: 700; color: #38bdf8; margin-top: 2px; }}
|
|
235
|
+
table {{ width: 100%; border-collapse: collapse; background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; overflow: hidden; }}
|
|
236
|
+
</style>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div class="container">
|
|
240
|
+
<header>
|
|
241
|
+
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:15px;">
|
|
242
|
+
<div>
|
|
243
|
+
<h1 style="margin:0;font-size:1.75rem;color:#f8fafc;">StemCheck Bleed & Separation Audit</h1>
|
|
244
|
+
<p style="margin:4px 0 0 0;color:#94a3b8;font-size:0.9rem;">Decomposed Cross-Instrument Bleed Matrix & Timeline Profiler</p>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="badge" style="{badge_style}">{badge_text}</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="stats-grid">
|
|
249
|
+
<div class="stat-card">
|
|
250
|
+
<div class="stat-label">Stems Evaluated</div>
|
|
251
|
+
<div class="stat-value">{len(res.stems)}</div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="stat-card">
|
|
254
|
+
<div class="stat-label">Audio Analysed</div>
|
|
255
|
+
<div class="stat-value" style="color:#a855f7;">{res.seconds_analysed:.1f}s</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="stat-card">
|
|
258
|
+
<div class="stat-label">Sample Rate</div>
|
|
259
|
+
<div class="stat-value" style="color:#34d399;">{res.sr} Hz</div>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="stat-card">
|
|
262
|
+
<div class="stat-label">Worst Bleed Found</div>
|
|
263
|
+
<div class="stat-value" style="font-size:1.1rem;color:#f87171;">{(str(worst_leaks_list[0][0]) + ' dB') if worst_leaks_list else 'None'}</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</header>
|
|
267
|
+
|
|
268
|
+
<main>
|
|
269
|
+
<h3 style="color:#f8fafc;margin-bottom:12px;">Instrument-by-Instrument Bleed Matrix (in dB relative to stem)</h3>
|
|
270
|
+
<div style="overflow-x:auto;margin-bottom:25px;">
|
|
271
|
+
<table>
|
|
272
|
+
<thead>
|
|
273
|
+
<tr style="background:#1e293b;color:#94a3b8;font-size:0.8rem;text-transform:uppercase;">
|
|
274
|
+
<th style="padding:10px 14px;text-align:left;border-bottom:2px solid #334155;">Stem</th>
|
|
275
|
+
<th style="padding:10px 14px;text-align:right;border-bottom:2px solid #334155;">Quality (SDR)</th>
|
|
276
|
+
<th style="padding:10px 14px;text-align:right;border-bottom:2px solid #334155;">Total Leak</th>
|
|
277
|
+
<th style="padding:10px 14px;text-align:right;border-bottom:2px solid #334155;">Unexplained</th>
|
|
278
|
+
<th style="padding:10px 14px;text-align:right;border-bottom:2px solid #334155;">Worst 10s Burst</th>
|
|
279
|
+
{headers_html}
|
|
280
|
+
</tr>
|
|
281
|
+
</thead>
|
|
282
|
+
<tbody>
|
|
283
|
+
{''.join(rows_html)}
|
|
284
|
+
</tbody>
|
|
285
|
+
</table>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:16px;margin-bottom:20px;">
|
|
289
|
+
<h4 style="margin:0 0 10px 0;color:#f8fafc;">Acute Burst Leaks & Worst Timeline Sections</h4>
|
|
290
|
+
<ul style="margin:0;padding-left:20px;color:#cbd5e1;line-height:1.8;">
|
|
291
|
+
{worst_html}
|
|
292
|
+
</ul>
|
|
293
|
+
</div>
|
|
294
|
+
</main>
|
|
295
|
+
|
|
296
|
+
<footer style="margin-top:40px;border-top:1px solid #1e293b;padding-top:20px;text-align:center;font-size:0.8rem;color:#64748b;">
|
|
297
|
+
Generated by <strong>stemcheck</strong> • Developed by <strong>Boobalan Arjunan</strong> • ComposerMind Labs
|
|
298
|
+
</footer>
|
|
299
|
+
</div>
|
|
300
|
+
</body>
|
|
301
|
+
</html>
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def check_to_html(res: CheckResult, out_path: Path | None = None) -> str:
|
|
306
|
+
verdict = res.verdict()
|
|
307
|
+
if verdict == "PASS":
|
|
308
|
+
badge_style = "background: linear-gradient(135deg, #10b981, #059669); color: white;"
|
|
309
|
+
badge_text = "PASS — ALL SANITY CHECKS PASSED"
|
|
310
|
+
elif verdict == "WARN":
|
|
311
|
+
badge_style = "background: linear-gradient(135deg, #f59e0b, #d97706); color: white;"
|
|
312
|
+
badge_text = "WARN — MINOR RESIDUAL ANOMALIES"
|
|
313
|
+
else:
|
|
314
|
+
badge_style = "background: linear-gradient(135deg, #ef4444, #dc2626); color: white;"
|
|
315
|
+
badge_text = "FAIL — CRITICAL SANITY DEFECTS"
|
|
316
|
+
|
|
317
|
+
s = res.sum_check or {}
|
|
318
|
+
res_db = s.get("residual_db", "N/A")
|
|
319
|
+
res_db_str = f"{res_db:.1f} dB" if isinstance(res_db, (int, float)) else str(res_db)
|
|
320
|
+
gain_db = s.get("best_gain_db", 0.0)
|
|
321
|
+
gain_str = f"{gain_db:+.2f} dB" if isinstance(gain_db, (int, float)) else str(gain_db)
|
|
322
|
+
delay_smp = s.get("delay_samples", 0)
|
|
323
|
+
|
|
324
|
+
findings_items = "".join(f"<li><strong>[{f.stem or 'all'}]</strong> {f.level}: {f.message}</li>" for f in res.findings if f.level != "INFO") or "<li>No defects detected (clean sum, zero clipping, no silent tracks).</li>"
|
|
325
|
+
|
|
326
|
+
return f"""<!DOCTYPE html>
|
|
327
|
+
<html lang="en">
|
|
328
|
+
<head>
|
|
329
|
+
<meta charset="utf-8">
|
|
330
|
+
<title>StemCheck Sanity Audit Report</title>
|
|
331
|
+
<style>
|
|
332
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #020617; color: #f1f5f9; padding: 30px 20px; }}
|
|
333
|
+
.container {{ max-width: 900px; margin: 0 auto; }}
|
|
334
|
+
.badge {{ display: inline-block; padding: 6px 16px; border-radius: 9999px; font-weight: 700; font-size: 0.85rem; }}
|
|
335
|
+
.card {{ background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; }}
|
|
336
|
+
</style>
|
|
337
|
+
</head>
|
|
338
|
+
<body>
|
|
339
|
+
<div class="container">
|
|
340
|
+
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #1e293b;padding-bottom:16px;margin-bottom:20px;">
|
|
341
|
+
<div>
|
|
342
|
+
<h1 style="margin:0;font-size:1.6rem;">StemCheck Sanity Audit</h1>
|
|
343
|
+
<p style="margin:4px 0 0 0;color:#94a3b8;font-size:0.85rem;">Mixture Conservation & Digital Defect Check (Zero Reference Needed)</p>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="badge" style="{badge_style}">{badge_text}</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div class="card">
|
|
349
|
+
<h3 style="margin:0 0 10px 0;color:#f8fafc;font-size:1.1rem;">Mixture Conservation (Do Stems Add Up to Mix?)</h3>
|
|
350
|
+
<p style="margin:0;color:#cbd5e1;font-size:0.95rem;line-height:1.6;">
|
|
351
|
+
• <strong>Unexplained Mix Residual:</strong> <code>{res_db_str}</code> (closer to -∞ is better)<br>
|
|
352
|
+
• <strong>Overall Gain Offset:</strong> <code>{gain_str}</code><br>
|
|
353
|
+
• <strong>Detected Delay:</strong> <code>{delay_smp} samples</code>
|
|
354
|
+
</p>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div class="card">
|
|
358
|
+
<h3 style="margin:0 0 10px 0;color:#f8fafc;font-size:1.1rem;">Defect Findings & Sanity Checks</h3>
|
|
359
|
+
<ul style="margin:0;padding-left:20px;color:#cbd5e1;line-height:1.7;">
|
|
360
|
+
{findings_items}
|
|
361
|
+
</ul>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<footer style="margin-top:30px;border-top:1px solid #1e293b;padding-top:16px;text-align:center;font-size:0.8rem;color:#64748b;">
|
|
365
|
+
Generated by <strong>stemcheck</strong> • Developed by <strong>Boobalan Arjunan</strong> • ComposerMind Labs
|
|
366
|
+
</footer>
|
|
367
|
+
</div>
|
|
368
|
+
</body>
|
|
369
|
+
</html>
|
|
370
|
+
"""
|
stemcheck/score.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""score: measure leakage in estimated stems against reference stems."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .audio import Audio, StemcheckError, center_window, match_channels, resample
|
|
9
|
+
from .bleed import interference, stft
|
|
10
|
+
from .check import estimate_delay
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DETECT_FACTOR = 3.0 # a leak counts as "detected" when the measured energy is this many times the strongest chance level
|
|
14
|
+
LIMIT_DB = 100.0 # below this the numbers are rounding noise of the audio itself, so they are capped
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _db(num: float, den: float) -> float | None:
|
|
18
|
+
if num <= 1e-30 or den <= 1e-30:
|
|
19
|
+
return None
|
|
20
|
+
return round(float(min(max(10.0 * np.log10(num / den), -LIMIT_DB), LIMIT_DB)), 2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class BleedEntry:
|
|
25
|
+
source: str
|
|
26
|
+
vs_target_db: float | None # leak level compared with the stem's own (target) content
|
|
27
|
+
vs_source_db: float | None # leak level compared with the leaking instrument itself
|
|
28
|
+
floor_vs_source_db: float | None # what pure chance would show (the measurement floor)
|
|
29
|
+
detected: bool # leak is above the chance level
|
|
30
|
+
leak_dbfs: float | None # absolute level of the leak (RMS, dB full scale)
|
|
31
|
+
source_silent: bool = False # the leaking instrument is silent, so there is nothing to leak
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class WindowScore:
|
|
36
|
+
start_s: float
|
|
37
|
+
seconds: float
|
|
38
|
+
total_bleed_vs_target_db: float | None
|
|
39
|
+
worst_source: str | None
|
|
40
|
+
worst_vs_target_db: float | None
|
|
41
|
+
sdr_like_db: float | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class StemScore:
|
|
46
|
+
name: str
|
|
47
|
+
target_gain_db: float | None
|
|
48
|
+
sdr_like_db: float | None
|
|
49
|
+
total_bleed_vs_target_db: float | None
|
|
50
|
+
unexplained_vs_target_db: float | None
|
|
51
|
+
bleed: list[BleedEntry]
|
|
52
|
+
timeline: list[WindowScore]
|
|
53
|
+
delay_samples: int = 0
|
|
54
|
+
notes: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ScoreResult:
|
|
59
|
+
stems: list[StemScore]
|
|
60
|
+
sr: int
|
|
61
|
+
seconds_analysed: float
|
|
62
|
+
notes: list[str]
|
|
63
|
+
max_bleed_db: float | None = None
|
|
64
|
+
min_sdr_db: float | None = None
|
|
65
|
+
|
|
66
|
+
def verdict(self) -> str | None:
|
|
67
|
+
"""PASS or FAIL against the thresholds you set; None if you set none (numbers only)."""
|
|
68
|
+
if self.max_bleed_db is None and self.min_sdr_db is None:
|
|
69
|
+
return None
|
|
70
|
+
for s in self.stems:
|
|
71
|
+
if self.max_bleed_db is not None and s.total_bleed_vs_target_db is not None \
|
|
72
|
+
and s.total_bleed_vs_target_db > self.max_bleed_db:
|
|
73
|
+
return "FAIL"
|
|
74
|
+
if self.min_sdr_db is not None and s.sdr_like_db is not None and s.sdr_like_db < self.min_sdr_db:
|
|
75
|
+
return "FAIL"
|
|
76
|
+
return "PASS"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prepare(refs: dict[str, Audio], ests: dict[str, Audio]):
|
|
80
|
+
notes: list[str] = []
|
|
81
|
+
if len(refs) < 2:
|
|
82
|
+
raise StemcheckError("need at least 2 reference stems (leakage is measured between instruments)")
|
|
83
|
+
rates = [a.sr for a in refs.values()]
|
|
84
|
+
sr = max(set(rates), key=lambda r: (rates.count(r), r))
|
|
85
|
+
ref_arr = {k: (resample(a.data, a.sr, sr) if a.sr != sr else a.data) for k, a in refs.items()}
|
|
86
|
+
if len(set(rates)) > 1:
|
|
87
|
+
notes.append(f"reference stems had different sample rates; all resampled to {sr} Hz")
|
|
88
|
+
ch = max(a.shape[1] for a in ref_arr.values())
|
|
89
|
+
ref_arr = {k: match_channels(a, ch) for k, a in ref_arr.items()}
|
|
90
|
+
lower = {k.lower(): k for k in ref_arr}
|
|
91
|
+
est_arr, extra = {}, []
|
|
92
|
+
for name, a in ests.items():
|
|
93
|
+
key = lower.get(name.lower())
|
|
94
|
+
if key is None:
|
|
95
|
+
extra.append(name)
|
|
96
|
+
continue
|
|
97
|
+
d = resample(a.data, a.sr, sr) if a.sr != sr else a.data
|
|
98
|
+
if a.sr != sr:
|
|
99
|
+
notes.append(f"estimate {name!r} was {a.sr} Hz; resampled to {sr} Hz")
|
|
100
|
+
if d.shape[1] != ch:
|
|
101
|
+
notes.append(f"estimate {name!r} has {d.shape[1]} channel(s), references have {ch}; matched up")
|
|
102
|
+
est_arr[key] = match_channels(d, ch)
|
|
103
|
+
if extra:
|
|
104
|
+
notes.append(f"estimates with no matching reference were ignored: {', '.join(sorted(extra))}")
|
|
105
|
+
if not est_arr:
|
|
106
|
+
raise StemcheckError("no estimate matches a reference by name (file names must match, e.g. vocals.wav)")
|
|
107
|
+
n = min([a.shape[0] for a in ref_arr.values()] + [a.shape[0] for a in est_arr.values()])
|
|
108
|
+
for name, a in est_arr.items():
|
|
109
|
+
diff = a.shape[0] - ref_arr[name].shape[0]
|
|
110
|
+
if abs(diff) > 2:
|
|
111
|
+
notes.append(f"estimate {name!r} is {abs(diff) / sr:.2f} s {'longer' if diff > 0 else 'shorter'} than its reference; trimmed")
|
|
112
|
+
return ref_arr, est_arr, sr, ch, n, notes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def score_stems(refs: dict[str, Audio], ests: dict[str, Audio], *, window_s: float | None = 10.0,
|
|
116
|
+
max_seconds: float | None = 120.0, max_bleed_db: float | None = None,
|
|
117
|
+
min_sdr_db: float | None = None, null_shifts: int = 3) -> ScoreResult:
|
|
118
|
+
"""Measure leakage in each estimate against the reference stems.
|
|
119
|
+
|
|
120
|
+
refs / ests: name -> Audio. Names are matched ignoring case. Only the middle `max_seconds` are analysed.
|
|
121
|
+
"""
|
|
122
|
+
ref_arr, est_arr, sr, ch, n, notes = _prepare(refs, ests)
|
|
123
|
+
sl = center_window(n, sr, max_seconds)
|
|
124
|
+
names = sorted(ref_arr)
|
|
125
|
+
R = np.stack([ref_arr[k][:n][sl] for k in names]) # (K, n, ch)
|
|
126
|
+
nn = R.shape[1]
|
|
127
|
+
if nn < sr * 2:
|
|
128
|
+
raise StemcheckError("audio is too short to measure leakage (need at least about 2 seconds)")
|
|
129
|
+
R_stft = [stft(R[:, :, c], sr) for c in range(ch)]
|
|
130
|
+
ref_rms_dbfs = {k: float(10 * np.log10(max(np.mean(R[i] ** 2), 1e-30))) for i, k in enumerate(names)}
|
|
131
|
+
out = []
|
|
132
|
+
for name in sorted(est_arr):
|
|
133
|
+
i = names.index(name)
|
|
134
|
+
est = est_arr[name][:n][sl]
|
|
135
|
+
r = interference(R, est, i, sr, window_s, refs_stft=R_stft, n_null=null_shifts)
|
|
136
|
+
o = r["overall"]
|
|
137
|
+
tgt = o["target_energy"]
|
|
138
|
+
src_t = o["per_source"] # source index -> energies
|
|
139
|
+
total_leak = o["total_leak"]
|
|
140
|
+
entries = []
|
|
141
|
+
for j, e in src_t.items():
|
|
142
|
+
vs_src = _db(e["leak_energy"], e["src_energy"])
|
|
143
|
+
entries.append(BleedEntry(
|
|
144
|
+
source=names[j], vs_target_db=_db(e["leak_energy"], tgt), vs_source_db=vs_src,
|
|
145
|
+
floor_vs_source_db=_db(e["bias_energy"], e["src_energy"]),
|
|
146
|
+
detected=bool(e["raw_energy"] >= DETECT_FACTOR * e["chance_max_energy"] and e["leak_energy"] > 0
|
|
147
|
+
and vs_src is not None and vs_src > -LIMIT_DB + 10),
|
|
148
|
+
leak_dbfs=None if vs_src is None else round(ref_rms_dbfs[names[j]] + vs_src, 2),
|
|
149
|
+
source_silent=bool(e["src_energy"] <= 1e-30)))
|
|
150
|
+
entries.sort(key=lambda b: (b.vs_target_db is None, -(b.vs_target_db or 0)))
|
|
151
|
+
tl = []
|
|
152
|
+
for w in r["timeline"]:
|
|
153
|
+
worst, worst_db = None, None
|
|
154
|
+
for j, e in w["per_source"].items():
|
|
155
|
+
d = _db(e["leak_energy"], w["target_energy"])
|
|
156
|
+
if d is not None and (worst_db is None or d > worst_db):
|
|
157
|
+
worst, worst_db = names[j], d
|
|
158
|
+
tl.append(WindowScore(w["start_s"], w["seconds"], _db(w["total_leak"], w["target_energy"]), worst, worst_db,
|
|
159
|
+
_db(w["target_energy"], w["total_leak"] + w["unexplained"])))
|
|
160
|
+
lag = estimate_delay(est.mean(axis=1), R[i].mean(axis=1), int(0.05 * sr))
|
|
161
|
+
snotes = []
|
|
162
|
+
if abs(lag) >= 2:
|
|
163
|
+
snotes.append(f"estimate looks delayed by {lag} samples ({1000 * lag / sr:.1f} ms) relative to its reference")
|
|
164
|
+
if tgt <= 1e-30:
|
|
165
|
+
snotes.append("the reference for this stem is silent (or the estimate has none of it), so levels compared with the target are not defined")
|
|
166
|
+
out.append(StemScore(
|
|
167
|
+
name=name, target_gain_db=_db(tgt, _target_src(R_stft, i)),
|
|
168
|
+
sdr_like_db=_db(tgt, total_leak + o["unexplained"]),
|
|
169
|
+
total_bleed_vs_target_db=_db(total_leak, tgt), unexplained_vs_target_db=_db(o["unexplained"], tgt),
|
|
170
|
+
bleed=entries, timeline=tl, delay_samples=int(lag), notes=snotes))
|
|
171
|
+
return ScoreResult(out, sr, round(nn / sr, 2), notes, max_bleed_db, min_sdr_db)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _target_src(R_stft, i) -> float:
|
|
175
|
+
return float(sum((np.abs(R[i].astype(np.complex128)) ** 2).sum() for R in R_stft))
|
|
@@ -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,14 @@
|
|
|
1
|
+
stemcheck/__init__.py,sha256=Ze_ACY7sRvPG0FhwrEMbfTy-fxIHi8kmsdYv4KL48bU,311
|
|
2
|
+
stemcheck/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
stemcheck/audio.py,sha256=K4zFjT4KyEOBVPS3FfJnrcjSaFWU16uJcnoWoVEa4hY,2659
|
|
4
|
+
stemcheck/bleed.py,sha256=8qPfUHavdXIkA4nEqK9FwFxQLjkG3f6y99kEzvZX6mc,9149
|
|
5
|
+
stemcheck/check.py,sha256=v2T-9d_lTT80VknH8yIRT733QABEV2yxOVbyt3oltVs,8363
|
|
6
|
+
stemcheck/cli.py,sha256=Fy2lCBX6XUEL3nLKs9kp8ayjBWs_UMRfwXy2FcwKjWY,4366
|
|
7
|
+
stemcheck/report.py,sha256=M4NMsdmwChSpmXmk-PFTQSia4Tx97iWVmzf16Sc-HgI,18725
|
|
8
|
+
stemcheck/score.py,sha256=UNshiH66sXALQZ4kemCDxpWOahUdTX8DetMsX9J5VaQ,8174
|
|
9
|
+
stemcheck-0.1.0.dist-info/licenses/LICENSE,sha256=brmceFF8Uw_1KeQBOshibuDU12xBGtsi2Y4mzSoRlgE,1462
|
|
10
|
+
stemcheck-0.1.0.dist-info/METADATA,sha256=IaMoH1ykyQPeHTTmb2WP1t7t-H2AGswLr-eW31k7Yjc,10706
|
|
11
|
+
stemcheck-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
stemcheck-0.1.0.dist-info/entry_points.txt,sha256=_3NDtH_m0Ibfyl-0OKPul49WVKE4g3kxlzYv-XycUFo,49
|
|
13
|
+
stemcheck-0.1.0.dist-info/top_level.txt,sha256=8c-Xnwp5vlOaJW1_2BI9li2PRmiVE2d9ssfIdy1ba40,10
|
|
14
|
+
stemcheck-0.1.0.dist-info/RECORD,,
|
|
@@ -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
|
+
stemcheck
|