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 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 &mdash; SEPARATION CRITERIA MET"
162
+ elif verdict == "FAIL":
163
+ badge_style = "background: linear-gradient(135deg, #ef4444, #dc2626); color: white;"
164
+ badge_text = "FAIL &mdash; EXCESSIVE BLEED DETECTED"
165
+ else:
166
+ badge_style = "background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: white;"
167
+ badge_text = "SCORED &mdash; 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;">&mdash;</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;">&lt;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> &larr; from <strong>{b.source}</strong> ({b.vs_target_db:.1f} dB vs stem, {b.leak_dbfs:.1f} dBFS)"))
193
+
194
+ tl_text = "&mdash;"
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 &amp; Separation Audit</h1>
244
+ <p style="margin:4px 0 0 0;color:#94a3b8;font-size:0.9rem;">Decomposed Cross-Instrument Bleed Matrix &amp; 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 &amp; 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> &bull; Developed by <strong>Boobalan Arjunan</strong> &bull; 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 &mdash; ALL SANITY CHECKS PASSED"
310
+ elif verdict == "WARN":
311
+ badge_style = "background: linear-gradient(135deg, #f59e0b, #d97706); color: white;"
312
+ badge_text = "WARN &mdash; MINOR RESIDUAL ANOMALIES"
313
+ else:
314
+ badge_style = "background: linear-gradient(135deg, #ef4444, #dc2626); color: white;"
315
+ badge_text = "FAIL &mdash; 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 &amp; 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
+ &bull; <strong>Unexplained Mix Residual:</strong> <code>{res_db_str}</code> (closer to -&infin; is better)<br>
352
+ &bull; <strong>Overall Gain Offset:</strong> <code>{gain_str}</code><br>
353
+ &bull; <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 &amp; 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> &bull; Developed by <strong>Boobalan Arjunan</strong> &bull; 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stemcheck = stemcheck.cli:main
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boobalan Arjunan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+ This project depends on third-party libraries (numpy, scipy, soundfile,
25
+ librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
26
+ optionally openai-whisper, torch/torchaudio). Each carries its own license
27
+ (mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
28
+ license before redistributing, especially if your GTV program has its own
29
+ licensing rules.
@@ -0,0 +1 @@
1
+ stemcheck