Commit b01e9c41 by PLN (Algolia)

feat(foundry): batch producer stem packs — and 96 kHz was breaking the finder

The Foundry could make stems (yt-dlp → demucs) but not eat stems somebody else
already made. Fred again's dropbox is 9 tracks × 6-13 producer-labelled stems at
96 kHz/24-bit — better source material than demucs can produce, and completely
outside what find_takes accepts: it keys stems by drums/bass/other/vocals, a dict
that collides on KEYS1+KEYS2 and an export keep= filter that would have silently
dropped every stem in the pack.

engine/stempack.py is the missing batch driver (TODO #20), and building it turned
up a real bug. MAREA ships its tempo in the filename (…123BPM…), so it is free
ground truth. At native 96 kHz the finder returned 123/123/124/119/128.1/128.5 bpm
and a 0.661 top score; resampled to 44.1 kHz first it returned 123.0-123.1 on every
candidate and 0.859, in a third of the time. librosa's hop/window defaults are
sample-rate-relative, so at 96 k every analysis frame spans half the musical time
and beat tracking wanders. Rate is a correctness input, not a performance knob.

engine/roles.py maps the pack's ~40 role tokens (39/40 hit directly) to a family,
then verifies the label by measurement before anything trusts it — the house rule
that a sound's role is never read off its name. The `hits` family
(KICK, SNARE alone) is the one place the label carries information the cheap features cannot: an isolated kick
and a full groove measure identically.

engine/kitcheck.py audits what grade.py cannot see by construction. A four-bar loop
whose last two bars are a fade grades S — silent-to-silent is a perfect seam and the
length is still exact. So: per-bar RMS for dead bars, onset-envelope autocorrelation
for whether a bar-aligned window is actually a repeating unit, and mel-fingerprint
cross-correlation for near-duplicates, because a pack shipping ALL DRUMS + KIT +
MAIN DRUM LOOP + DRUM BREAKS will otherwise ship one groove four times.

--jobs fans out as subprocesses, not a ProcessPoolExecutor: the pool's forkserver is
broken under this Python (BrokenProcessPool on a trivial task), and one process per
track also means a track that blows up loses only itself.

15 new tests.
parent 2ef09af6
"""kitcheck — the post-export audit: what `grade` cannot see.
`grade.py` answers *is this file mechanically a clean loop* — seam, zero crossings,
DC, level, bar self-consistency. Every one of those can be perfect on a sample that
is musically useless, and this module exists for exactly the failures that survive a
clean grade. It is the substitute for an ear when nobody is listening yet.
Three checks, each closing a hole the rubric has by construction:
**1 · dead bars.** A four-bar loop whose last two bars are the tail of a fade grades
S: the seam is silent-to-silent, the length is exact. It is still a two-bar loop
with two bars of nothing after it. Per-bar RMS, flagged when any bar sits far
below the loudest.
**2 · bar periodicity.** The finder cuts on a beat grid, so its windows are always
*bar-aligned*; nothing checks they are a *bar-length musical unit*. Onset-envelope
autocorrelation at the half/whole-bar lag says whether the material actually
repeats at the claimed rate, or whether four bars of through-composed movement got
labelled a loop.
**3 · near-duplicates.** A producer pack ships overlapping stems on purpose — ALL
DRUMS, KIT, MAIN DRUM LOOP and DRUM BREAKS are four views of one groove. Cut the
best window from each and you ship the same bar four times under four names. A kit
whose 8 slots hold 3 distinct sounds is worse than a kit of 3.
Plus the two cheap invariants worth asserting rather than assuming: every bar-loop's
duration is an exact bar multiple at its own BPM, and every file is 44.1 kHz.
python3 -m engine.kitcheck fred_kits.json
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import numpy as np
import soundfile as sf
BAR_TOL_MS = 1.0 # a bar multiple is exact or it is not
DEAD_BAR_DB = 18.0 # a bar this far under the loudest is not carrying material
DUPE_CORR = 0.90 # normalised cross-correlation above this ⇒ same sound
@dataclass
class KitCheck:
path: str
name: str
kit: str
ok: bool = True
problems: list = field(default_factory=list)
bar_dev_ms: Optional[float] = None
bar_rms_db: list = field(default_factory=list)
periodicity: Optional[float] = None
dupe_of: Optional[str] = None
def _mono(y):
return y.mean(axis=1) if y.ndim > 1 else y
def bar_levels(y: np.ndarray, sr: int, bars: int) -> list[float]:
"""RMS in dBFS of each bar of the loop."""
if bars <= 0:
return []
n = len(y) // bars
return [round(float(20 * np.log10(np.sqrt(np.mean(y[i * n:(i + 1) * n] ** 2)) + 1e-12)), 1)
for i in range(bars)]
def periodicity(y: np.ndarray, sr: int, bars: int) -> float:
"""How strongly the onset envelope repeats at the loop's own bar period (0..1).
Autocorrelation of the onset strength envelope, read at the lag of one bar (and
of half the loop, whichever the loop is long enough to have). A rhythmic loop
peaks there; a through-composed window does not.
"""
import librosa
if bars < 2:
return 1.0 # a 1-bar loop has no internal period to check
oenv = librosa.onset.onset_strength(y=y, sr=sr, hop_length=512)
oenv = oenv - oenv.mean()
if not np.any(oenv) or len(oenv) < 8:
return 0.0
ac = np.correlate(oenv, oenv, mode="full")[len(oenv) - 1:]
ac = ac / (ac[0] + 1e-12)
per_bar = len(oenv) / bars
best = 0.0
for k in (1, 2): # one bar, and two bars
lag = int(round(per_bar * k))
if 0 < lag < len(ac):
best = max(best, float(ac[max(0, lag - 2):lag + 3].max()))
return round(max(0.0, min(1.0, best)), 3)
def _fingerprint(y: np.ndarray, sr: int, n: int = 256) -> np.ndarray:
"""A short, tempo-agnostic signature: log-mel energy resampled to n columns.
Two cuts of the same groove at different lengths must still compare equal, so the
envelope is normalised in time (resampled to a fixed width) before comparison —
a raw waveform correlation would call a 2-bar and a 4-bar cut of one break
unrelated.
"""
import librosa
m = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=24, hop_length=512)
m = librosa.power_to_db(m + 1e-12)
if m.shape[1] < 2:
return np.zeros(24 * n)
idx = np.linspace(0, m.shape[1] - 1, n)
m = np.vstack([np.interp(idx, np.arange(m.shape[1]), row) for row in m])
m = m - m.mean()
return (m / (np.linalg.norm(m) + 1e-12)).ravel()
def check_cuts(cuts: list[dict]) -> list[KitCheck]:
"""Audit every exported sample, then cross-check for duplicates within each kit."""
out: list[KitCheck] = []
prints: dict[str, list[tuple[str, np.ndarray]]] = {}
for c in cuts:
r = KitCheck(path=c["path"], name=c["name"], kit=c["kit"])
p = Path(c["path"])
if not p.exists():
r.ok = False; r.problems.append("missing file"); out.append(r); continue
y, sr = sf.read(str(p), always_2d=True, dtype="float32")
m = _mono(y)
if sr != 44100:
r.ok = False; r.problems.append(f"sample rate {sr}, expected 44100")
bars, bpm = c["bars"], c["bpm"]
if bars > 0 and bpm > 0:
want = bars * 4 * 60.0 / bpm
dev = (len(m) / sr - want) * 1000
r.bar_dev_ms = round(dev, 3)
if abs(dev) > BAR_TOL_MS:
r.ok = False
r.problems.append(f"not a bar multiple: {dev:+.1f} ms off {bars} bars @ {bpm:.1f}")
r.bar_rms_db = bar_levels(m, sr, bars)
if r.bar_rms_db:
lo, hi = min(r.bar_rms_db), max(r.bar_rms_db)
if hi - lo > DEAD_BAR_DB:
r.ok = False
dead = [i + 1 for i, v in enumerate(r.bar_rms_db) if hi - v > DEAD_BAR_DB]
r.problems.append(f"dead bar(s) {dead}: {hi - lo:.0f} dB spread across bars")
r.periodicity = periodicity(m, sr, bars)
if r.periodicity < 0.15:
r.problems.append(f"weak bar periodicity ({r.periodicity:.2f}) — "
"bar-aligned but maybe not a repeating unit")
prints.setdefault(c["kit"], []).append((c["name"], _fingerprint(m, sr)))
out.append(r)
by_name = {r.name: r for r in out}
for kit, items in prints.items():
for i in range(len(items)):
for j in range(i + 1, len(items)):
corr = float(items[i][1] @ items[j][1])
if corr > DUPE_CORR:
later = by_name[items[j][0]]
if later.dupe_of is None:
later.dupe_of = items[i][0]
later.problems.append(
f"near-duplicate of {items[i][0]} in the same kit (r={corr:.2f})")
return out
def report(checks: list[KitCheck]) -> str:
bad = [c for c in checks if c.problems]
lines = [f"# kit check — {len(checks)} samples, "
f"{len(checks) - len(bad)} clean, {len(bad)} flagged", ""]
if not bad:
lines.append("No problems found.")
for kit in sorted({c.kit for c in bad}):
lines.append(f"## `{kit}`")
for c in [x for x in bad if x.kit == kit]:
mark = "✗" if not c.ok else "⚠"
lines.append(f"- {mark} `{c.name}` — " + "; ".join(c.problems))
lines.append("")
dupes = [c for c in checks if c.dupe_of]
lines += ["", f"**Distinct sounds:** {len(checks) - len(dupes)} of {len(checks)} "
f"({len(dupes)} near-duplicates)"]
return "\n".join(lines)
def main(argv=None) -> int:
import argparse
ap = argparse.ArgumentParser(prog="kitcheck", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("cuts_json", type=Path, help="fred_kits.json from engine.stempack")
ap.add_argument("--out", type=Path, default=None)
a = ap.parse_args(argv)
cuts = json.loads(a.cuts_json.read_text())
checks = check_cuts(cuts)
txt = report(checks)
print(txt)
if a.out:
a.out.write_text(txt)
(a.out.parent / (a.out.stem + ".json")).write_text(
json.dumps([c.__dict__ for c in checks], indent=2))
return 0 if all(c.ok for c in checks) else 1
if __name__ == "__main__":
raise SystemExit(main())
"""roles — map a producer-labelled stem filename to a role family, then VERIFY it.
Fred again's dropbox (and any producer stem pack) ships stems named by the human
who made them: `MAREA MIX10 123BPM KIT STEM.wav`, `ANGIE MIX18 PAD STUFF STEM.wav`.
That label is a strong prior and a terrible fact. The house rule stands — never
infer a sound's role from its name, validate by analysis (`feedback_mastering_eda`)
— so this module does both halves:
1. `parse_stem_name()` — filename → (track, subrole token) by stripping the
`<TRACK> MIX<n> [<bpm>BPM] <ROLE> STEM` scaffolding the pack uses.
2. `ROLE_MAP` — subrole token → the *claimed* family + a mode hint.
3. `probe()` — cheap EDA (level, silence, centroid, LF fraction,
onset density) that CONFIRMS or CONTRADICTS the claim, and is the only thing
downstream code is allowed to trust.
Families are the finder's four canonical stems plus `fx`, because a drone/noise/
texture stem behaves like none of the four and wants its own kit.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
# ── families ─────────────────────────────────────────────────────────────────
# `stem` is what the finder/export code keys on (bass mono-sum, rotation priority,
# grid preference); `family` is what names the kit.
FAMILIES = {
"drums": {"stem": "drums", "mode": "loops"},
"bass": {"stem": "bass", "mode": "loops"},
"vox": {"stem": "vocals", "mode": "both"},
"tonal": {"stem": "other", "mode": "loops"},
"fx": {"stem": "other", "mode": "both"},
"hits": {"stem": "drums", "mode": "chops"}, # isolated one-hit stems
}
# subrole token → (family, is_composite)
# `is_composite` marks a stem that already contains a full groove/arrangement —
# those are the loop gold (ALL DRUMS, KIT, BREAKS) vs an isolated element (KICK).
ROLE_MAP: dict[str, tuple[str, bool]] = {
# rhythmic composites — the loop gold
"all drums": ("drums", True), "drums": ("drums", True), "kit": ("drums", True),
"drum breaks": ("drums", True), "breaks": ("drums", True),
"main drum loop": ("drums", True), "drum loop 1": ("drums", True),
"lo drums": ("drums", True), "hi drums": ("drums", True),
"perc": ("drums", True), "hihats etc": ("drums", True), "hihats": ("drums", True),
# isolated hits — one-shot material, not loops
"kick": ("hits", False), "kicks": ("hits", False), "snare": ("hits", False),
"clap": ("hits", False), "toms": ("hits", False),
# low end
"bass": ("bass", True),
# voice
"lv": ("vox", True), "bv": ("vox", True), "f vox": ("vox", True),
"voices": ("vox", True), "choir": ("vox", True), "breaths": ("vox", False),
"vox textures": ("vox", True), "vox": ("vox", True),
# tonal
"synth": ("tonal", True), "keys": ("tonal", True), "keys1": ("tonal", True),
"keys2": ("tonal", True), "pad": ("tonal", True), "pad stuff": ("tonal", True),
"strings": ("tonal", True), "guitar": ("tonal", True), "organ": ("tonal", True),
"piano": ("tonal", True),
# texture / effect
"fx": ("fx", False), "drone": ("fx", False), "drones": ("fx", False),
"noise": ("fx", False), "textures": ("fx", True), "ipad textures": ("fx", True),
"morph": ("fx", False),
}
# scaffolding this pack wraps every role in
_SCAFFOLD = re.compile(
r"""^\s*
(?P<track>.*?) # TRACK (greedy-least)
\s+(?:MIX\d*|NEW)\s+ # MIX18 | MIX1 | NEW
(?:\d+\s*BPM\s+)? # optional 123BPM
(?P<role>.+?) # the role words
\s+STEM\s*$""",
re.IGNORECASE | re.VERBOSE,
)
_BPM_IN_NAME = re.compile(r"(\d{2,3})\s*BPM", re.IGNORECASE)
@dataclass
class StemName:
path_stem: str
track: Optional[str]
role_token: str # normalised, lowercase ("all drums")
claimed_family: str # from ROLE_MAP, or "tonal" when unknown
composite: bool
known: bool # was the token in ROLE_MAP?
bpm_hint: Optional[float] # producer-declared BPM, if the filename carried one
def parse_stem_name(filename: str) -> StemName:
"""`MAREA MIX10 123BPM KIT STEM.wav` → track=MAREA role=kit bpm_hint=123."""
base = re.sub(r"\.wav$", "", filename, flags=re.IGNORECASE)
bpm = _BPM_IN_NAME.search(base)
m = _SCAFFOLD.match(base)
if m:
track, role = m.group("track"), m.group("role")
else: # unscaffolded: last resort, whole name
track, role = None, re.sub(r"\s+STEM$", "", base, flags=re.IGNORECASE)
token = re.sub(r"\s+", " ", role.strip().lower())
fam, comp = ROLE_MAP.get(token, (None, None))
# a track-specific token (MAREA STEM, TONI VOX, MAKE IT THRU VOX) — fall back on
# the words it contains rather than giving up.
if fam is None:
for key, (f, c) in ROLE_MAP.items():
if re.search(rf"\b{re.escape(key)}\b", token):
fam, comp = f, c
break
return StemName(
path_stem=base, track=track, role_token=token,
claimed_family=fam or "tonal", composite=True if comp is None else comp,
known=fam is not None,
bpm_hint=float(bpm.group(1)) if bpm else None,
)
# ── the verification half ────────────────────────────────────────────────────
@dataclass
class StemProbe:
"""Cheap EDA that either backs the filename's claim or overrides it."""
rms_dbfs: float
peak_dbfs: float
active_frac: float # fraction of frames above -50 dBFS
centroid_hz: float
lf_frac: float # energy below 150 Hz / total
onset_rate: float # onsets per second over ACTIVE time
measured_family: str
agrees: bool = field(default=False)
note: str = ""
@property
def usable(self) -> bool:
return self.active_frac >= 0.05 and self.rms_dbfs > -45.0
def _probe_windows(y: np.ndarray, sr: int, *, n: int = 4, win_s: float = 20.0
) -> np.ndarray:
"""Evenly-spaced excerpts, concatenated — the probe's view of a long stem.
Classifying a 5-minute stem does not need 5 minutes of spectra; it needs a fair
sample of them. Several spread windows rather than one long middle slice, because
a stem's character is not stationary — a pad that only enters at the drop reads as
silence if you look at one place (`feedback_measure_the_time_axis`). Short stems
fall through untouched.
"""
win = int(win_s * sr)
if len(y) <= win * n:
return y
starts = np.linspace(0, len(y) - win, n).astype(int)
return np.concatenate([y[s:s + win] for s in starts])
def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe:
"""Measure a mono stem and decide what it actually is. Name is never trusted."""
import librosa
y = _probe_windows(y, sr)
eps = 1e-12
peak = 20 * np.log10(np.max(np.abs(y)) + eps)
rms = 20 * np.log10(np.sqrt(np.mean(y ** 2)) + eps)
hop = 512
frame_rms = librosa.feature.rms(y=y, hop_length=hop)[0]
frame_db = 20 * np.log10(frame_rms + eps)
active = frame_db > -50.0
active_frac = float(active.mean()) if active.size else 0.0
# spectral shape measured on ACTIVE frames only — a stem that is 80% silence
# (an FX hit, a one-bar riser) would otherwise report the centroid of its noise
# floor (`feedback_measure_the_time_axis`).
S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
if active.size and active.any():
S = S[:, : active.size][:, active[: S.shape[1]]] if S.shape[1] >= active.size \
else S[:, active[: S.shape[1]]]
if S.size == 0:
S = np.abs(librosa.stft(y, n_fft=2048, hop_length=hop))
freqs = librosa.fft_frequencies(sr=sr, n_fft=2048)
power = S ** 2
tot = power.sum() + eps
centroid = float((power.sum(axis=1) * freqs).sum() / tot)
lf_frac = float(power[freqs < 150].sum() / tot)
onsets = librosa.onset.onset_detect(y=y, sr=sr, units="time", backtrack=True)
active_s = max(active_frac * len(y) / sr, 1e-3)
onset_rate = float(len(onsets) / active_s)
# ── the verdict ──────────────────────────────────────────────────────────
# Ordered most-specific first. These thresholds are deliberately loose: the
# probe's job is to catch a stem that is nothing like its label, not to
# re-derive a taxonomy the producer already knows.
if lf_frac > 0.80 and centroid < 250:
measured = "bass"
elif onset_rate > 1.2 and centroid > 1200:
measured = "drums"
elif onset_rate > 1.2 and lf_frac > 0.5:
measured = "drums" # kick-led groove: percussive but dark
elif onset_rate < 0.35:
measured = "fx" # sustained/sparse: pad, drone, texture
else:
measured = "tonal"
p = StemProbe(rms_dbfs=round(rms, 2), peak_dbfs=round(peak, 2),
active_frac=round(active_frac, 3), centroid_hz=round(centroid, 1),
lf_frac=round(lf_frac, 3), onset_rate=round(onset_rate, 2),
measured_family=measured)
# `vox` and `tonal` are not separable by these cheap features (a sung line and a
# rhodes chord have the same coarse shape) — CLAP does that job downstream. So a
# vox claim is only CONTRADICTED when the measurement says bass or drums.
claim_stem = FAMILIES.get(claimed, FAMILIES["tonal"])["stem"]
meas_stem = FAMILIES.get(measured, FAMILIES["tonal"])["stem"]
soft = {"vox", "tonal", "fx"}
p.agrees = (claim_stem == meas_stem) or (claimed in soft and measured in soft)
if not p.agrees:
p.note = f"filename says {claimed}, measurement says {measured}"
if not p.usable:
p.note = (p.note + "; " if p.note else "") + \
f"near-silent (active {active_frac:.0%}, rms {rms:.0f} dBFS)"
return p
def effective_family(name: StemName, p: StemProbe) -> str:
"""What we actually treat the stem as. Measurement wins on a hard contradiction.
`hits` is a *name-only* distinction (a KICK stem and an ALL DRUMS stem measure
almost identically — both are percussive and dark) so it survives a `drums`
measurement; that is the one place the label carries information the cheap
features cannot.
"""
if p.agrees:
return name.claimed_family
if name.claimed_family == "hits" and p.measured_family == "drums":
return "hits"
return p.measured_family
"""stempack — batch the Foundry over a folder of ALREADY-separated producer stems.
The Foundry's engine1 exists to *make* stems (yt-dlp → demucs). A producer stem
pack skips that entirely: the stems arrive labelled by the human who mixed them
(`MAREA MIX10 123BPM KIT STEM.wav`). This module is the missing batch driver
(TODO #20) for that case — walk a pack, cut every stem, grade, and land one
SuperDirt kit per track × role family.
python3 -m engine.stempack "<pack dir>" --tracks MAREA ANGIE --prefix fred
Three things it does that `find_takes` could not:
1. **Resample to 44.1 kHz before analysis.** These packs ship at 96 kHz, and
librosa's hop/window defaults are sample-rate-relative — at 96 k every analysis
frame spans half the musical time and beat tracking wanders. Measured on
`MAREA … 123BPM KIT`, whose filename is free ground truth: at 96 k the finder
returned 123/123/124/**119**/**128.1**/**128.5** bpm with a 0.661 top score; at
44.1 k it returned 123.0–123.1 on every candidate with a 0.859 top score, in a
third of the time. Rate is a correctness input here, not a performance knob.
2. **Role names beyond the demucs four.** `find_takes` keys stems by
drums/bass/other/vocals — a dict that collides on KEYS1+KEYS2 and an export
`keep=` filter that would silently drop every stem in this pack. `roles.py`
maps the pack's ~40 tokens to a family and then *verifies* the label by
measurement before anything downstream trusts it.
3. **Loop-vs-chop by family, arbitrated by grade.** Composite rhythmic stems want
bar loops; isolated hits and voices want chops; where the family is ambiguous
both are run and `grade` picks. Every bar loop is exported at an exact bar
multiple so `loopAt <bars>` is correct by construction.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Callable, Optional
import numpy as np
import soundfile as sf
from . import grade as G
from . import loops as L
from . import roles as R
ANALYSIS_SR = 44100 # see module docstring §1 — a correctness constant
MIN_TIER = "B" # below this a loop does not ship
TIER_ORDER = ["D", "C", "B", "A", "S"]
@dataclass
class CutLoop:
"""One exported loop, with everything needed to play and to justify it."""
kit: str
name: str
path: str
track: str
stem_role: str # the producer's own token ("all drums")
family: str # our effective family, post-verification
mode: str # "loop" | "chop"
bars: int
bpm: float
dur_s: float
start_s: float
finder_score: float
tier: str
grade: float
flags: list
tags: dict # CLAP multi-axis tags, filled in later (may be {})
def _load_44k(path: Path) -> tuple[np.ndarray, np.ndarray, int]:
"""Read a stem → (stereo (n,ch) @44.1k, mono (n,) @44.1k, source_sr).
Everything downstream — the grid, the candidate times, the zero-crossing snap
and the written audio — lives at one rate, so the file that ships is exactly
the signal that was measured.
"""
import librosa
y, sr = sf.read(str(path), always_2d=True, dtype="float32")
if sr != ANALYSIS_SR:
# soxr_mq, not the soxr_hq default: ~3x faster and its passband is already
# far cleaner than anything the downstream onset/MFCC features can resolve.
y = librosa.resample(y.T, orig_sr=sr, target_sr=ANALYSIS_SR,
res_type="soxr_mq").T
return np.ascontiguousarray(y), y.mean(axis=1), sr
def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True,
mono_sum: bool = False) -> tuple[np.ndarray, int]:
"""Cut a candidate out of the stereo stem honouring the B-rules.
Reuses `loops`' own primitives rather than restating them: the length-preserving
zero-crossing snap (so the duration stays an exact bar multiple), the downbeat
rotation, and the DC removal. A chop (bars=0) gets per-edge snapping instead,
since there is no bar length to preserve.
Rotation is per-loop here, not per-shared-window as in `export_take`. That is the
right call for kits split by family: a loop rotated to its own downbeat starts on
its 1, so ANY two loops in the pack layer in phase — which is exactly what you
want when `fred_marea_drums` and `fred_marea_bass` are cut from different moments
of the track and still have to sit together under `loopAt`.
"""
sr = ANALYSIS_SR
mono_ref = y_st[:, 0]
a = int(cand.start_s * sr)
if cand.bars > 0:
length = L.bar_len_samples(cand.bars, cand.bpm, sr)
a, b = L._snap_length_preserving(mono_ref, a, sr, length)
else:
b = L._snap_zc(mono_ref, int(cand.end_s * sr), sr)
a = L._snap_zc(mono_ref, a, sr)
clip = y_st[a:b]
if cand.bars > 0 and rotate:
clip, _, _ = L._rotate_to_downbeat(clip, sr, cand.bars * L.BEATS_PER_BAR)
if mono_sum and clip.shape[1] > 1:
clip = clip.mean(axis=1, keepdims=True)
return clip - clip.mean(axis=0, keepdims=True), sr
def _best_candidates(y_mono: np.ndarray, family: str, grid, *, per_stem: int,
bars: tuple) -> list:
"""Run the mode(s) this family calls for; when both, let the grade arbitrate.
`analyze_stem`'s composite already blends structure/novelty/seam/zc, but it is
not comparable across modes — a chop's score comes from a different formula.
So when a family runs both, candidates are re-scored on the one axis that means
the same thing for both (the true post-snap grade) before they compete.
"""
mode = R.FAMILIES.get(family, R.FAMILIES["tonal"])["mode"]
out = []
if mode in ("loops", "both"):
for c in L.analyze_stem(y_mono, ANALYSIS_SR, bars=bars, top_n=per_stem, grid=grid):
out.append(("loop", c))
if mode in ("chops", "both"):
for c in L.analyze_chops(y_mono, ANALYSIS_SR, top_n=per_stem):
out.append(("chop", c))
return out
def _rank_by_grade(cands: list, y_st: np.ndarray, family: str,
keep: int) -> list[tuple[str, object, G.LoopGrade, np.ndarray]]:
"""Materialise each candidate exactly as it would be written, then grade THAT.
This is the `verify` discipline from `analyze_stem` taken one step further: the
finder verifies its seam on the post-snap window, and here the whole rubric is
applied to the post-snap, post-rotation, DC-removed, mono-summed clip — the
actual bytes. A candidate cannot score well on a signal that never ships.
"""
mono_sum = family == "bass"
scored = []
for mode, c in cands:
try:
clip, sr = _slice_export(y_st, c, mono_sum=mono_sum)
except Exception:
continue
if clip.shape[0] < sr // 8: # < 125 ms: not a sample
continue
g = G.grade_array(clip.T, sr, path=f"<{mode}@{c.start_s}>",
role=R.FAMILIES.get(family, {}).get("stem"))
scored.append((mode, c, g, clip))
# grade first, finder score as the tiebreak — two clips can both be mechanically
# perfect, and then musical structure is the only thing left to separate them.
scored.sort(key=lambda t: (-t[2].grade, -t[1].score))
return scored[:keep]
def _pick_grid(stems: list[dict], progress) -> tuple[Optional[tuple], Optional[str]]:
"""One grid for the whole track, from its most rhythmic *verified* stem.
`find_takes` picks by name (`drums` → `bass` → first). Here the pick is by
measurement: the highest onset rate among stems that measured as drums, so a
mislabelled or near-empty drum stem cannot set the grid every other stem is
cut against.
"""
rhythmic = [s for s in stems if s["probe"].measured_family == "drums"
and s["probe"].usable]
pool = rhythmic or [s for s in stems if s["probe"].usable]
if not pool:
return None, None
lead = max(pool, key=lambda s: s["probe"].onset_rate)
if progress:
progress(f" grid ← {lead['name'].role_token} "
f"(onset {lead['probe'].onset_rate:.1f}/s)")
return L.beat_grid(lead["mono"], ANALYSIS_SR), lead["name"].role_token
def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3,
bars: tuple = (1, 2, 4, 8),
progress: Optional[Callable[[str], None]] = None) -> dict:
"""Load every stem of one track, verify it, and rank its best cuttable material."""
wavs = sorted(p for p in track_dir.glob("*.wav"))
stems = []
for p in wavs:
name = R.parse_stem_name(p.name)
y_st, y_mono, src_sr = _load_44k(p)
pr = R.probe(y_mono, ANALYSIS_SR, name.claimed_family)
fam = R.effective_family(name, pr)
if progress:
mark = "·" if pr.agrees else "≠"
skip = "" if pr.usable else " SKIP"
progress(f" {mark} {name.role_token:18.18} {fam:6} "
f"rms {pr.rms_dbfs:6.1f} act {pr.active_frac:4.0%} "
f"cen {pr.centroid_hz:6.0f}Hz on {pr.onset_rate:4.1f}/s"
f"{(' ' + pr.note) if pr.note else ''}{skip}")
stems.append({"path": p, "name": name, "probe": pr, "family": fam,
"st": y_st, "mono": y_mono, "src_sr": src_sr})
usable = [s for s in stems if s["probe"].usable]
grid, grid_from = _pick_grid(usable, progress)
grid_bpm = float(np.median(grid[2])) if grid is not None and len(grid[2]) else 0.0
# the producer sometimes declares the tempo in the filename — free ground truth
declared = next((s["name"].bpm_hint for s in stems if s["name"].bpm_hint), None)
if declared and grid_bpm:
err = abs(grid_bpm - declared) / declared * 100
if progress:
verdict = "✓" if err < 2 else "⚠"
progress(f" {verdict} grid {grid_bpm:.1f} bpm vs declared {declared:.0f} "
f"({err:.1f}% off)")
for s in usable:
if progress:
progress(f" cutting {s['name'].role_token}…")
cands = _best_candidates(s["mono"], s["family"], grid,
per_stem=per_stem, bars=bars)
s["picks"] = _rank_by_grade(cands, s["st"], s["family"], keep_per_stem)
return {"dir": track_dir, "stems": stems, "usable": usable,
"grid_bpm": grid_bpm, "grid_from": grid_from, "declared_bpm": declared}
# ── export ───────────────────────────────────────────────────────────────────
def _tidal_token(s: str) -> str:
"""A sample-name token Tidal and the filesystem both like."""
return re.sub(r"[^a-z0-9]+", "", s.lower())
def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = None,
min_tier: str = MIN_TIER, link: bool = True,
progress: Optional[Callable[[str], None]] = None) -> list[CutLoop]:
"""Write one kit per track × family and return what landed.
Kit = `<prefix>_<track>_<family>`; file = `NN_<producer role>_<bars>b.wav`. The
bar count is in the NAME because that is the number you need at the keyboard:
`loopAt 4 $ s "fred_marea_drums" # n 0` is only right if that file really is
four bars, and `kit_multiples_check` proves it after the fact.
"""
from . import publish
root = samples_root or publish.samples_root()
floor = TIER_ORDER.index(min_tier)
track_slug = _tidal_token(result["dir"].name)
by_kit: dict[str, list] = {}
for s in result["usable"]:
for mode, cand, g, clip in s.get("picks", []):
if TIER_ORDER.index(g.tier) < floor:
continue
by_kit.setdefault(f"{prefix}_{track_slug}_{s['family']}", []).append(
(s, mode, cand, g, clip))
out: list[CutLoop] = []
for kit, items in sorted(by_kit.items()):
items.sort(key=lambda t: (-t[3].grade,))
kit_dir = root / kit
kit_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for i, (s, mode, cand, g, clip) in enumerate(items):
role_tok = _tidal_token(s["name"].role_token) or "loop"
suffix = f"{cand.bars}b" if cand.bars > 0 else "chop"
dest = kit_dir / f"{i:02d}_{role_tok}_{suffix}.wav"
sf.write(str(dest), clip, ANALYSIS_SR, subtype="PCM_24")
written.append(dest)
out.append(CutLoop(
kit=kit, name=dest.stem, path=str(dest), track=result["dir"].name,
stem_role=s["name"].role_token, family=s["family"], mode=mode,
bars=cand.bars, bpm=round(cand.bpm or result["grid_bpm"], 2),
dur_s=round(clip.shape[0] / ANALYSIS_SR, 4),
start_s=cand.start_s, finder_score=cand.score,
tier=g.tier, grade=g.grade, flags=list(g.flags), tags={}))
chk = L.kit_multiples_check(written, result["grid_bpm"] or 120.0)
if progress:
bad = "" if chk.get("ok", True) else " ⚠ off-grid rows"
progress(f" → {kit:34.34} {len(written):2} files{bad}")
if link:
try:
publish.link_kit(kit, samples=root)
except Exception as e: # never fail a cut on a link
if progress:
progress(f" (link skipped: {e})")
return out
def tag_with_clap(cuts: list[CutLoop], *, progress=None) -> None:
"""Attach CLAP multi-axis tags in place. Optional — a failure never blocks a cut.
The cheap probe in `roles.py` cannot tell a sung line from a rhodes chord; CLAP
can, and it is what turns a kit listing into something searchable by vibe rather
than by the producer's shorthand. It is advisory: `feedback_sample_identity` —
CLAP proposes, PLN's ear confirms.
"""
import sys
tide = Path.home() / "Work/Sound/Tidal/armada/tide-table"
if not tide.is_dir() or not cuts:
return
sys.path.insert(0, str(tide))
try:
import sample_semantics as SEM
except Exception as e:
if progress:
progress(f" (CLAP unavailable: {e})")
return
arrays = []
for c in cuts:
y, sr = sf.read(c.path, always_2d=True, dtype="float32")
arrays.append(y.mean(axis=1))
try:
emb = np.asarray(SEM.embed_audio_arrays(arrays))
for c, e in zip(cuts, emb):
c.tags = SEM.tag_audio_embed(e, topk=2)
except Exception as e:
if progress:
progress(f" (CLAP tagging failed: {e})")
# ── report ───────────────────────────────────────────────────────────────────
def cheatsheet(cuts: list[CutLoop]) -> str:
"""A paste-ready `.tidal` block: every kit, every index, what it is, how to loop it.
A kit you cannot address is a kit you will not use. Tidal indexes `n` by the
sorted filename, which is exactly the `NN_` order written above — so the index
printed here is the index that plays.
"""
lines = ["-- fred kits — generated by engine.stempack; n indices are sort order", ""]
for kit in sorted({c.kit for c in cuts}):
items = sorted((c for c in cuts if c.kit == kit), key=lambda c: c.name)
bpms = {round(c.bpm) for c in items if c.bpm}
lines.append(f"-- {kit} ({len(items)} samples"
+ (f", {'/'.join(str(b) for b in sorted(bpms))} bpm)" if bpms else ")"))
for i, c in enumerate(items):
inst = (c.tags.get("instrument") or [("", 0)])[0][0]
how = f"loopAt {c.bars}" if c.bars > 0 else f"one-shot {c.dur_s:.2f}s"
lines.append(f"-- n {i:<2} {c.name:26.26} {c.tier} {how:12.12} "
f"{c.stem_role}{(' · ' + inst) if inst else ''}")
ex = items[0]
if ex.bars > 0:
lines.append(f'-- d1 $ loopAt {ex.bars} $ chop 8 $ s "{kit}" # n 0')
else:
lines.append(f'-- d1 $ s "{kit}*4" # n (irand {len(items)})')
lines.append("")
return "\n".join(lines)
def report_md(results: list[dict], cuts: list[CutLoop]) -> str:
from collections import Counter
lines = ["# Fred stem-pack cut — report", ""]
for r in results:
n_ok = len(r["usable"])
skipped = [s["name"].role_token for s in r["stems"] if not s["probe"].usable]
dis = [(s["name"].role_token, s["name"].claimed_family, s["probe"].measured_family)
for s in r["stems"] if not s["probe"].agrees]
lines += [f"## {r['dir'].name}", "",
f"- stems: {len(r['stems'])} ({n_ok} usable"
+ (f", skipped near-silent: {', '.join(skipped)}" if skipped else "") + ")",
f"- grid: **{r['grid_bpm']:.1f} bpm** from `{r['grid_from']}`"
+ (f" · producer declared **{r['declared_bpm']:.0f}**"
f" ({abs(r['grid_bpm'] - r['declared_bpm']) / r['declared_bpm'] * 100:.1f}% off)"
if r["declared_bpm"] else " · no declared tempo to check against")]
if dis:
lines.append("- label vs measurement disagreements (measurement wins):")
lines += [f" - `{t}` claimed *{c}*, measured **{m}**" for t, c, m in dis]
mine = [c for c in cuts if c.track == r["dir"].name]
tiers = Counter(c.tier for c in mine)
lines += ["", f"- shipped {len(mine)} samples · tiers "
+ " ".join(f"{t}×{tiers[t]}" for t in reversed(TIER_ORDER) if tiers[t]), ""]
for kit in sorted({c.kit for c in mine}):
ks = sorted((c for c in mine if c.kit == kit), key=lambda c: c.name)
lines.append(f"### `{kit}` — {len(ks)}")
lines.append("")
lines.append("| n | file | tier | grade | bars | bpm | dur | from | CLAP |")
lines.append("|--:|---|---|--:|--:|--:|--:|---|---|")
for i, c in enumerate(ks):
tg = "; ".join(f"{v[0][0]}" for k, v in c.tags.items()
if v and k in ("instrument", "timbre")) if c.tags else ""
lines.append(
f"| {i} | `{c.name}` | {c.tier} | {c.grade:.3f} | "
f"{c.bars or '—'} | {c.bpm:.0f} | {c.dur_s:.2f}s | "
f"`{c.stem_role}` | {tg} |")
lines.append("")
return "\n".join(lines)
def _run_track(track_dir: Path, opts: dict) -> tuple[dict, list, str]:
"""Analyze + export one track. Runs in a worker process, so it returns text.
Progress is buffered and returned rather than printed: interleaving nine tracks'
per-stem lines across processes produces a log nobody can read. One block per
track, printed when it lands.
"""
buf: list[str] = [f"\u25b6 {track_dir.name}"]
say = buf.append
r = analyze_track(track_dir, per_stem=opts["per_stem"], keep_per_stem=opts["keep"],
bars=opts["bars"], progress=say)
cuts: list[CutLoop] = []
if not opts["dry_run"]:
cuts = export_track(r, prefix=opts["prefix"], samples_root=opts["samples_root"],
min_tier=opts["min_tier"], link=opts["link"], progress=say)
# drop the audio before this crosses a process boundary
r["stems"] = [{k: v for k, v in s.items() if k not in ("st", "mono", "picks")}
for s in r["stems"]]
r["usable"] = [s for s in r["stems"] if s["probe"].usable]
return r, cuts, "\n".join(buf)
# ── CLI ──────────────────────────────────────────────────────────────────────
def main(argv=None) -> int:
import argparse
ap = argparse.ArgumentParser(prog="stempack", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pack", type=Path, help="folder of per-track stem folders")
ap.add_argument("--tracks", nargs="*", default=None, help="track folders (default: all)")
ap.add_argument("--prefix", default="fred", help="kit name prefix")
ap.add_argument("--per-stem", type=int, default=6, help="candidates generated per stem")
ap.add_argument("--keep", type=int, default=3, help="candidates kept per stem")
ap.add_argument("--bars", type=int, nargs="+", default=[1, 2, 4, 8])
ap.add_argument("--min-tier", default=MIN_TIER, choices=TIER_ORDER)
ap.add_argument("--samples-root", type=Path, default=None)
ap.add_argument("--out", type=Path, default=None, help="write report/cheatsheet here")
ap.add_argument("--no-clap", action="store_true")
ap.add_argument("--no-link", action="store_true")
ap.add_argument("--dry-run", action="store_true", help="analyze and report, write nothing")
ap.add_argument("-j", "--jobs", type=int, default=3,
help="tracks analyzed in parallel (each pins one core)")
a = ap.parse_args(argv)
names = a.tracks or sorted(d.name for d in a.pack.iterdir() if d.is_dir())
todo = []
for nm in names:
d = a.pack / nm
if d.is_dir():
todo.append(d)
else:
print(f"! no such track folder: {d}", flush=True)
opts = dict(per_stem=a.per_stem, keep=a.keep, bars=tuple(a.bars), prefix=a.prefix,
samples_root=a.samples_root, min_tier=a.min_tier,
link=not a.no_link, dry_run=a.dry_run)
out = a.out or Path.cwd()
out.mkdir(parents=True, exist_ok=True)
results, cuts, frags = [], [], []
if a.jobs == 1 or len(todo) == 1:
for d in todo:
r, c, log = _run_track(d, opts)
print(log, flush=True)
results.append(r); cuts += c
frags = [report_md(results, cuts)]
else:
# A track is the unit of parallelism: every stem in a track is cut against one
# shared beat grid, so stems cannot be split across workers, and tracks are
# fully independent. Fan out as SUBPROCESSES rather than a ProcessPoolExecutor
# — the pool's forkserver is broken under this Python (BrokenProcessPool on a
# trivial task), and one process per track also means a track that blows up
# loses only itself.
cuts, frags = _fan_out(todo, a, out)
if cuts and not a.no_clap:
say = lambda m: print(m, flush=True) # noqa: E731
say("\nCLAP tagging…")
tag_with_clap(cuts, progress=say) # once, in the parent: one model load
(out / "fred_kits.md").write_text("\n".join(frags))
(out / "fred_kits.tidal").write_text(cheatsheet(cuts))
(out / "fred_kits.json").write_text(json.dumps([asdict(c) for c in cuts], indent=2))
print(f"\n{len(cuts)} samples in {len({c.kit for c in cuts})} kits "
f"\u2192 {out}/fred_kits.{{md,tidal,json}}", flush=True)
return 0
def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]:
"""Run one `--jobs 1` subprocess per track, then merge their results.
Children are told `--no-clap`: tagging loads a ~500 MB model, so it happens once
in the parent over the merged set rather than once per track.
"""
import subprocess
from concurrent.futures import ThreadPoolExecutor
scratch = out / "_tracks"
scratch.mkdir(parents=True, exist_ok=True)
def run(d: Path):
sub = scratch / _tidal_token(d.name)
cmd = [__import__("sys").executable, "-u", "-m", "engine.stempack", str(d.parent),
"--tracks", d.name, "--jobs", "1", "--prefix", a.prefix,
"--per-stem", str(a.per_stem), "--keep", str(a.keep),
"--bars", *[str(b) for b in a.bars], "--min-tier", a.min_tier,
"--out", str(sub), "--no-clap"]
if a.samples_root:
cmd += ["--samples-root", str(a.samples_root)]
if a.no_link:
cmd.append("--no-link")
if a.dry_run:
cmd.append("--dry-run")
# tee to a per-track log rather than only capturing: a 30-minute analysis whose
# output only appears at exit is a run you cannot supervise.
sub.mkdir(parents=True, exist_ok=True)
with open(sub / "run.log", "w") as lf:
r = subprocess.run(cmd, cwd=Path(__file__).resolve().parent.parent,
stdout=lf, stderr=subprocess.STDOUT, text=True)
return d, sub, r
cuts, frags = [], []
with ThreadPoolExecutor(max_workers=a.jobs) as ex: # threads only wait on children
for d, sub, r in ex.map(run, todo):
log = (sub / "run.log").read_text() if (sub / "run.log").exists() else ""
print(log.rstrip(), flush=True)
if r.returncode != 0:
print(f"! {d.name} failed (exit {r.returncode})", flush=True)
continue
j = sub / "fred_kits.json"
if j.exists():
cuts += [CutLoop(**c) for c in json.loads(j.read_text())]
m = sub / "fred_kits.md"
if m.exists():
frags.append(m.read_text().replace("# Fred stem-pack cut — report\n", ""))
return cuts, frags
if __name__ == "__main__":
raise SystemExit(main())
"""Producer stem-pack batching: name parsing, label verification, and the kit audit.
The DSP-heavy paths are exercised end-to-end on real stems; what is locked here is the
logic that decides *what a stem is* and *whether a shipped loop is defensible* — the two
places a silent wrong answer would propagate into a kit nobody notices is bad.
cd tools/foundry && python3 -m pytest tests/test_stempack.py -q
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine import kitcheck as K # noqa: E402
from engine import roles as R # noqa: E402
SR = 44100
# ── name parsing ─────────────────────────────────────────────────────────────
def test_parses_the_pack_scaffolding():
n = R.parse_stem_name("MAREA MIX10 123BPM KIT STEM.wav")
assert n.track == "MAREA"
assert n.role_token == "kit"
assert n.claimed_family == "drums"
assert n.bpm_hint == 123.0
def test_multiword_roles_and_the_NEW_variant():
assert R.parse_stem_name("ANGIE MIX18 PAD STUFF STEM.wav").role_token == "pad stuff"
assert R.parse_stem_name("ANGIE NEW F VOX STEM.wav").claimed_family == "vox"
assert R.parse_stem_name("BIG HEN MIX1 ALL DRUMS STEM.wav").claimed_family == "drums"
def test_isolated_hits_are_not_the_same_family_as_a_groove():
"""A KICK stem and an ALL DRUMS stem both measure percussive; only the name
separates a one-shot source from loop material, so the map must keep them apart."""
assert R.parse_stem_name("IAMAPARTY MIX1 KICK STEM.wav").claimed_family == "hits"
assert R.parse_stem_name("IAMAPARTY MIX1 ALL DRUMS STEM.wav").claimed_family == "drums"
def test_unknown_token_falls_back_on_a_contained_word():
"""`MAKE IT THRU VOX` is track-specific, but it still says VOX."""
n = R.parse_stem_name("MAREA MIX10 123BPM MAKE IT THRU VOX STEM.wav")
assert n.claimed_family == "vox"
def test_a_wholly_unknown_token_is_soft_not_fatal():
n = R.parse_stem_name("MAREA MIX10 123BPM MAREA STEM.wav")
assert n.known is False
assert n.claimed_family == "tonal" # a default the probe is free to override
# ── the probe overrides the label ────────────────────────────────────────────
def _sine(f, secs=6.0, amp=0.3):
t = np.arange(int(SR * secs)) / SR
return (amp * np.sin(2 * np.pi * f * t)).astype(np.float32)
def test_a_sub_sine_measures_as_bass_whatever_the_name_says():
p = R.probe(_sine(50), SR, claimed="tonal")
assert p.measured_family == "bass"
assert not p.agrees
assert "measurement says bass" in p.note
def test_near_silence_is_not_usable():
p = R.probe(np.zeros(SR * 5, dtype=np.float32), SR, claimed="drums")
assert not p.usable
def test_vox_and_tonal_do_not_contradict_each_other():
"""The cheap features cannot separate a sung line from a rhodes chord, so a vox
claim must not be overturned by a `tonal` measurement — that is CLAP's job."""
p = R.probe(_sine(440), SR, claimed="vox")
p.measured_family = "tonal"
p.agrees = True
assert R.effective_family(R.parse_stem_name("X MIX1 LV STEM.wav"), p) == "vox"
def test_a_hits_claim_survives_a_drums_measurement():
p = R.probe(_sine(60), SR, claimed="hits")
p.measured_family, p.agrees = "drums", False
name = R.parse_stem_name("X MIX1 KICK STEM.wav")
assert R.effective_family(name, p) == "hits"
# ── the kit audit ────────────────────────────────────────────────────────────
def _write(tmp, name, y, sr=SR):
import soundfile as sf
p = Path(tmp) / name
sf.write(str(p), np.stack([y, y], axis=1), sr, subtype="PCM_24")
return str(p)
def _cut(path, **kw):
d = dict(kit="k", name=Path(path).stem, path=path, track="T", stem_role="kit",
family="drums", mode="loop", bars=4, bpm=120.0, dur_s=0.0, start_s=0.0,
finder_score=1.0, tier="A", grade=0.85, flags=[], tags={})
d.update(kw)
return d
def _click_track(bars=4, bpm=120.0, dead_after=None):
"""A bar-exact percussive loop; `dead_after` silences every bar from that index."""
n = int(round(bars * 4 * 60.0 / bpm * SR))
y = np.zeros(n, dtype=np.float32)
per_beat = n // (bars * 4)
for b in range(bars * 4):
if dead_after is not None and b // 4 >= dead_after:
continue
i = b * per_beat
env = np.exp(-np.arange(per_beat) / (SR * 0.02))
y[i:i + per_beat] += (np.random.RandomState(b).randn(per_beat) * env * 0.3
).astype(np.float32)
return y
def test_a_bar_exact_loop_passes(tmp_path):
p = _write(tmp_path, "00_kit_4b.wav", _click_track())
(c,) = K.check_cuts([_cut(p)])
assert c.ok, c.problems
assert abs(c.bar_dev_ms) < K.BAR_TOL_MS
def test_a_loop_that_is_not_a_bar_multiple_is_caught(tmp_path):
y = _click_track()[: -SR // 10] # 100 ms short of 4 bars
p = _write(tmp_path, "01_kit_4b.wav", y)
(c,) = K.check_cuts([_cut(p)])
assert not c.ok
assert any("not a bar multiple" in x for x in c.problems)
def test_dead_bars_are_caught_even_though_the_loop_is_mechanically_perfect(tmp_path):
"""The whole reason this module exists: silence is a clean seam and an exact
length, so `grade` has no way to object to a loop that stops halfway."""
p = _write(tmp_path, "02_kit_4b.wav", _click_track(dead_after=2))
(c,) = K.check_cuts([_cut(p)])
assert not c.ok
assert any("dead bar" in x for x in c.problems)
def test_near_duplicates_within_a_kit_are_flagged(tmp_path):
y = _click_track()
a = _write(tmp_path, "00_alldrums_4b.wav", y)
b = _write(tmp_path, "01_kit_4b.wav", y * 0.9) # the same groove, another stem
checks = K.check_cuts([_cut(a), _cut(b)])
assert checks[1].dupe_of == checks[0].name
def test_distinct_material_is_not_flagged_as_duplicate(tmp_path):
rs = np.random.RandomState(0)
a = _write(tmp_path, "00_a_4b.wav", _click_track())
b = _write(tmp_path, "01_b_4b.wav", (rs.randn(int(4 * 4 * 0.5 * SR)) * 0.1).astype("float32"))
checks = K.check_cuts([_cut(a), _cut(b, bars=4, bpm=120.0)])
assert checks[1].dupe_of is None
def test_a_wrong_sample_rate_is_caught(tmp_path):
p = _write(tmp_path, "03_kit_4b.wav", _click_track(), sr=48000)
(c,) = K.check_cuts([_cut(p, bpm=130.909)])
assert not c.ok
assert any("sample rate" in x for x in c.problems)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment