Commit 0bd4a75e by PLN (Algolia)

fix(foundry): three biases that would have shipped bad kits

All three found by running the thing, not by reading it.

**Chops beat loops mechanically, every time.** grade classes anything under 0.75 s
a one-shot and scores it 0.5*level + 0.3*dc + 0.2*zc -- no seam term, no bar term,
because a one-shot is never looped. Export already removes DC and snaps zero
crossings, so a chop lands near 1.0 by construction, while a loop pays 0.35 on seam
and 0.25 on bar consistency and rarely maxes both. Measured on ME: every chop
0.993-0.999 (S), every loop 0.841-0.881 (A). Ranked head to head the vocal kit came
out as three stabs and no loops -- exactly backwards from what was asked for. Modes
now rank in separate pools with their own slots.

**Every loop got its own tempo.** analyze_stem reports a candidate's bpm as the mean
local BPM inside its own window, which is the right number for detecting drift and
the wrong one to derive a bar length from. ME's track grid read 129.2 bpm while both
shipped loops were cut at 150.5 -- so two loops from one track do not layer, and
loopAt lies. Bar length now comes from a single robust track tempo: the MODE of the
local-BPM histogram, not the mean, because a few dropped or doubled beats drag a mean
and leave a mode alone. Validated on a synthetic 120 bpm pack: 120.2, 0.2% off.

Confidence comes with it -- the share of beats within 5% of that tempo. Below 35%
there is no pulse worth cutting bars against and the track ships chops only. ME is
the case that motivated it: no drum stem at all, so the grid was derived from a
texture stem that merely measured percussive.

**Near-duplicates were shipping.** A producer pack overlaps stems on purpose; ALL
DRUMS, KIT, MAIN DRUM LOOP and DRUM BREAKS are four views of one groove and the
finder picks the same bar out of all four. Eight slots holding three sounds is a
worse kit than three. Dedup now runs at export, reusing kitcheck's mel fingerprint
and its threshold so the auditor and the exporter cannot disagree about what "the
same sound" means. Smoke test: 12 cuts -> 7, 6 of them distinct.

Also: the merged report regenerates from the merged cuts instead of concatenating
the children's fragments, which froze the tables before CLAP tagging ran and lost
every tag; and kit_multiples_check no longer runs over chops, which are sub-bar by
definition and were producing an off-grid warning that meant nothing.

101 tests green.
parent b01e9c41
...@@ -40,7 +40,7 @@ import soundfile as sf ...@@ -40,7 +40,7 @@ import soundfile as sf
BAR_TOL_MS = 1.0 # a bar multiple is exact or it is not 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 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 DUPE_CORR = 0.93 # normalised cross-correlation above this ⇒ same sound
@dataclass @dataclass
......
...@@ -88,7 +88,7 @@ def _load_44k(path: Path) -> tuple[np.ndarray, np.ndarray, int]: ...@@ -88,7 +88,7 @@ def _load_44k(path: Path) -> tuple[np.ndarray, np.ndarray, int]:
def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True, def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True,
mono_sum: bool = False) -> tuple[np.ndarray, int]: mono_sum: bool = False, bar_bpm: float = 0.0) -> tuple[np.ndarray, int]:
"""Cut a candidate out of the stereo stem honouring the B-rules. """Cut a candidate out of the stereo stem honouring the B-rules.
Reuses `loops`' own primitives rather than restating them: the length-preserving Reuses `loops`' own primitives rather than restating them: the length-preserving
...@@ -106,7 +106,8 @@ def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True, ...@@ -106,7 +106,8 @@ def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True,
mono_ref = y_st[:, 0] mono_ref = y_st[:, 0]
a = int(cand.start_s * sr) a = int(cand.start_s * sr)
if cand.bars > 0: if cand.bars > 0:
length = L.bar_len_samples(cand.bars, cand.bpm, sr) # the TRACK tempo, not this window's local mean — see `track_tempo`
length = L.bar_len_samples(cand.bars, bar_bpm or cand.bpm, sr)
a, b = L._snap_length_preserving(mono_ref, a, sr, length) a, b = L._snap_length_preserving(mono_ref, a, sr, length)
else: else:
b = L._snap_zc(mono_ref, int(cand.end_s * sr), sr) b = L._snap_zc(mono_ref, int(cand.end_s * sr), sr)
...@@ -120,50 +121,95 @@ def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True, ...@@ -120,50 +121,95 @@ def _slice_export(y_st: np.ndarray, cand, *, rotate: bool = True,
def _best_candidates(y_mono: np.ndarray, family: str, grid, *, per_stem: int, def _best_candidates(y_mono: np.ndarray, family: str, grid, *, per_stem: int,
bars: tuple) -> list: bars: tuple) -> dict[str, list]:
"""Run the mode(s) this family calls for; when both, let the grade arbitrate. """Run the mode(s) this family calls for, keeping the modes SEPARATE.
`analyze_stem`'s composite already blends structure/novelty/seam/zc, but it is They must not compete on grade, because the rubric scores them on different weights
not comparable across modes — a chop's score comes from a different formula. and the comparison is rigged. `grade` classes anything under 0.75 s as a one-shot and
So when a family runs both, candidates are re-scored on the one axis that means scores it `0.5·level + 0.3·dc + 0.2·zc` — no seam term, no bar term, since a one-shot
the same thing for both (the true post-snap grade) before they compete. is never looped. Export already removes DC and snaps to zero crossings, so almost
every chop lands near 1.0, while a loop pays 0.35 on seam and 0.25 on bar consistency
and rarely maxes both.
Measured on ME: every chop graded 0.993–0.999 (S), every loop 0.841–0.881 (A). Ranked
head to head, chops take every slot on a "both" family and the vocal kits come out as
stabs with no loops in them — precisely backwards. So each mode gets its own slots.
""" """
mode = R.FAMILIES.get(family, R.FAMILIES["tonal"])["mode"] mode = R.FAMILIES.get(family, R.FAMILIES["tonal"])["mode"]
out = [] out: dict[str, list] = {}
if mode in ("loops", "both"): if mode in ("loops", "both"):
for c in L.analyze_stem(y_mono, ANALYSIS_SR, bars=bars, top_n=per_stem, grid=grid): out["loop"] = L.analyze_stem(y_mono, ANALYSIS_SR, bars=bars, top_n=per_stem,
out.append(("loop", c)) grid=grid)
if mode in ("chops", "both"): if mode in ("chops", "both"):
for c in L.analyze_chops(y_mono, ANALYSIS_SR, top_n=per_stem): out["chop"] = L.analyze_chops(y_mono, ANALYSIS_SR, top_n=per_stem)
out.append(("chop", c))
return out return out
def _rank_by_grade(cands: list, y_st: np.ndarray, family: str, def _rank_by_grade(by_mode: dict[str, list], y_st: np.ndarray, family: str,
keep: int) -> list[tuple[str, object, G.LoopGrade, np.ndarray]]: keep: int, *, bar_bpm: float = 0.0) -> list:
"""Materialise each candidate exactly as it would be written, then grade THAT. """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 This is `analyze_stem`'s `verify` discipline taken one step further: the finder
finder verifies its seam on the post-snap window, and here the whole rubric is re-scores its seam on the post-snap window, and here the whole rubric is applied to
applied to the post-snap, post-rotation, DC-removed, mono-summed clip — the the post-snap, post-rotation, DC-removed, mono-summed clip — the actual bytes. A
actual bytes. A candidate cannot score well on a signal that never ships. candidate cannot score well on a signal that never ships.
Ranking happens WITHIN a mode (see `_best_candidates`); the modes are then
concatenated so both survive into the kit.
""" """
mono_sum = family == "bass" mono_sum = family == "bass"
scored = [] picked = []
for mode, c in cands: for mode, cands in by_mode.items():
try: scored = []
clip, sr = _slice_export(y_st, c, mono_sum=mono_sum) for c in cands:
except Exception: try:
continue clip, sr = _slice_export(y_st, c, mono_sum=mono_sum,
if clip.shape[0] < sr // 8: # < 125 ms: not a sample bar_bpm=bar_bpm)
continue except Exception:
g = G.grade_array(clip.T, sr, path=f"<{mode}@{c.start_s}>", continue
role=R.FAMILIES.get(family, {}).get("stem")) if clip.shape[0] < sr // 8: # < 125 ms: not a sample
scored.append((mode, c, g, clip)) continue
# grade first, finder score as the tiebreak — two clips can both be mechanically g = G.grade_array(clip.T, sr, path=f"<{mode}@{c.start_s}>",
# perfect, and then musical structure is the only thing left to separate them. role=R.FAMILIES.get(family, {}).get("stem"))
scored.sort(key=lambda t: (-t[2].grade, -t[1].score)) scored.append((mode, c, g, clip))
return scored[:keep] # 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 x: (-x[2].grade, -x[1].score))
# a chop is cheap and a loop is the point: chops get half the slots, minimum one.
picked += scored[: keep if mode == "loop" else max(1, keep // 2)]
return picked
def track_tempo(grid) -> tuple[float, float]:
"""One tempo for the whole track, plus how much to trust it. (bpm, confidence).
`analyze_stem` reports each candidate's bpm as the MEAN of the local per-beat BPM
inside its own window, which is the right number for detecting drift but the wrong
one to derive a bar length from: every loop then gets its own idiosyncratic tempo
and two loops from the same track no longer layer. Measured on ME, the track grid
read 129.2 bpm while the two shipped loops were cut at 150.5.
So the bar length comes from a single robust track tempo — the mode of the local-BPM
histogram, not the mean, because a handful of dropped or doubled beats drag a mean
and leave a mode alone. Confidence is the share of beats sitting within ±5 % of it,
which is what tells us whether there is a pulse here at all.
"""
if grid is None or len(grid[2]) < 8:
return 0.0, 0.0
bpm = np.asarray(grid[2], dtype=float)
bpm = bpm[(bpm > 50) & (bpm < 220)]
if bpm.size < 8:
return 0.0, 0.0
hist, edges = np.histogram(bpm, bins=np.arange(50, 221, 1.0))
centre = edges[int(np.argmax(hist))] + 0.5
near = np.abs(bpm - centre) <= centre * 0.05
if near.sum() >= 4: # refine on the beats that agree
centre = float(np.median(bpm[near]))
near = np.abs(bpm - centre) <= centre * 0.05
return round(float(centre), 2), round(float(near.mean()), 3)
TEMPO_TRUST = 0.35 # below this share of on-tempo beats there is no usable grid
def _pick_grid(stems: list[dict], progress) -> tuple[Optional[tuple], Optional[str]]: def _pick_grid(stems: list[dict], progress) -> tuple[Optional[tuple], Optional[str]]:
...@@ -209,7 +255,11 @@ def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3, ...@@ -209,7 +255,11 @@ def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3,
usable = [s for s in stems if s["probe"].usable] usable = [s for s in stems if s["probe"].usable]
grid, grid_from = _pick_grid(usable, progress) 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 grid_bpm, tempo_conf = track_tempo(grid)
bar_ok = tempo_conf >= TEMPO_TRUST
if progress:
progress(f" tempo {grid_bpm:.1f} bpm, confidence {tempo_conf:.0%}"
+ ("" if bar_ok else " ⇒ NO usable pulse: chops only, no bar-loops"))
# the producer sometimes declares the tempo in the filename — free ground truth # 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) declared = next((s["name"].bpm_hint for s in stems if s["name"].bpm_hint), None)
...@@ -225,10 +275,14 @@ def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3, ...@@ -225,10 +275,14 @@ def analyze_track(track_dir: Path, *, per_stem: int = 6, keep_per_stem: int = 3,
progress(f" cutting {s['name'].role_token}…") progress(f" cutting {s['name'].role_token}…")
cands = _best_candidates(s["mono"], s["family"], grid, cands = _best_candidates(s["mono"], s["family"], grid,
per_stem=per_stem, bars=bars) per_stem=per_stem, bars=bars)
s["picks"] = _rank_by_grade(cands, s["st"], s["family"], keep_per_stem) if not bar_ok:
cands.pop("loop", None) # a bar-loop off a tempo we do not believe
s["picks"] = _rank_by_grade(cands, s["st"], s["family"], keep_per_stem,
bar_bpm=grid_bpm if bar_ok else 0.0)
return {"dir": track_dir, "stems": stems, "usable": usable, return {"dir": track_dir, "stems": stems, "usable": usable,
"grid_bpm": grid_bpm, "grid_from": grid_from, "declared_bpm": declared} "grid_bpm": grid_bpm, "tempo_conf": tempo_conf, "bar_ok": bar_ok,
"grid_from": grid_from, "declared_bpm": declared}
# ── export ─────────────────────────────────────────────────────────────────── # ── export ───────────────────────────────────────────────────────────────────
...@@ -262,6 +316,7 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No ...@@ -262,6 +316,7 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No
out: list[CutLoop] = [] out: list[CutLoop] = []
for kit, items in sorted(by_kit.items()): for kit, items in sorted(by_kit.items()):
items.sort(key=lambda t: (-t[3].grade,)) items.sort(key=lambda t: (-t[3].grade,))
items = _drop_duplicates(items)
kit_dir = root / kit kit_dir = root / kit
kit_dir.mkdir(parents=True, exist_ok=True) kit_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = [] written: list[Path] = []
...@@ -274,11 +329,15 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No ...@@ -274,11 +329,15 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No
out.append(CutLoop( out.append(CutLoop(
kit=kit, name=dest.stem, path=str(dest), track=result["dir"].name, kit=kit, name=dest.stem, path=str(dest), track=result["dir"].name,
stem_role=s["name"].role_token, family=s["family"], mode=mode, stem_role=s["name"].role_token, family=s["family"], mode=mode,
bars=cand.bars, bpm=round(cand.bpm or result["grid_bpm"], 2), bars=cand.bars,
bpm=round(result["grid_bpm"] or cand.bpm, 2),
dur_s=round(clip.shape[0] / ANALYSIS_SR, 4), dur_s=round(clip.shape[0] / ANALYSIS_SR, 4),
start_s=cand.start_s, finder_score=cand.score, start_s=cand.start_s, finder_score=cand.score,
tier=g.tier, grade=g.grade, flags=list(g.flags), tags={})) tier=g.tier, grade=g.grade, flags=list(g.flags), tags={}))
chk = L.kit_multiples_check(written, result["grid_bpm"] or 120.0) # only bar-loops have a bar multiple to be off; a chop is sub-bar by definition,
# and checking it produces a warning that means nothing.
bar_files = [w for w, it in zip(written, items) if it[2].bars > 0]
chk = L.kit_multiples_check(bar_files, result["grid_bpm"] or 120.0)
if progress: if progress:
bad = "" if chk.get("ok", True) else " ⚠ off-grid rows" bad = "" if chk.get("ok", True) else " ⚠ off-grid rows"
progress(f" → {kit:34.34} {len(written):2} files{bad}") progress(f" → {kit:34.34} {len(written):2} files{bad}")
...@@ -291,6 +350,27 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No ...@@ -291,6 +350,27 @@ def export_track(result: dict, *, prefix: str, samples_root: Optional[Path] = No
return out return out
def _drop_duplicates(items: list, thresh: float | None = None) -> list:
"""Remove near-identical cuts from a kit, keeping the best-graded of each group.
A producer pack overlaps its stems on purpose — ALL DRUMS, KIT, MAIN DRUM LOOP and
DRUM BREAKS are four views of one groove, and the finder will happily pick the same
bar out of all four. Eight slots holding three distinct sounds is a worse kit than
three. `kitcheck` reports duplicates after the fact; this stops them being written,
reusing the same tempo-agnostic mel fingerprint so the two agree by construction.
"""
from .kitcheck import DUPE_CORR, _fingerprint
thresh = DUPE_CORR if thresh is None else thresh # one definition of "same sound"
kept, prints = [], []
for it in items: # already sorted best-first
clip = it[4]
fp = _fingerprint(clip.mean(axis=1) if clip.ndim > 1 else clip, ANALYSIS_SR)
if any(float(fp @ q) > thresh for q in prints):
continue
kept.append(it); prints.append(fp)
return kept
def tag_with_clap(cuts: list[CutLoop], *, progress=None) -> None: def tag_with_clap(cuts: list[CutLoop], *, progress=None) -> None:
"""Attach CLAP multi-axis tags in place. Optional — a failure never blocks a cut. """Attach CLAP multi-axis tags in place. Optional — a failure never blocks a cut.
...@@ -351,25 +431,44 @@ def cheatsheet(cuts: list[CutLoop]) -> str: ...@@ -351,25 +431,44 @@ def cheatsheet(cuts: list[CutLoop]) -> str:
return "\n".join(lines) return "\n".join(lines)
def track_summary(r: dict) -> dict:
"""The JSON-safe half of an analysis, so a child process can hand it to the parent.
The parent regenerates the whole report after CLAP tagging; concatenating the
children's own report fragments would freeze the tables before the tags exist.
"""
return {
"track": r["dir"].name,
"n_stems": len(r["stems"]),
"usable": [s["name"].role_token for s in r["stems"] if s["probe"].usable],
"skipped": [s["name"].role_token for s in r["stems"] if not s["probe"].usable],
"disagreements": [(s["name"].role_token, s["name"].claimed_family,
s["probe"].measured_family)
for s in r["stems"] if not s["probe"].agrees],
"grid_bpm": r["grid_bpm"], "grid_from": r["grid_from"],
"tempo_conf": r.get("tempo_conf", 0.0), "bar_ok": r.get("bar_ok", True),
"declared_bpm": r["declared_bpm"],
}
def report_md(results: list[dict], cuts: list[CutLoop]) -> str: def report_md(results: list[dict], cuts: list[CutLoop]) -> str:
from collections import Counter from collections import Counter
lines = ["# Fred stem-pack cut — report", ""] lines = ["# Fred stem-pack cut — report", ""]
for r in results: for r in results:
n_ok = len(r["usable"]) lines += [f"## {r['track']}", "",
skipped = [s["name"].role_token for s in r["stems"] if not s["probe"].usable] f"- stems: {r['n_stems']} ({len(r['usable'])} usable"
dis = [(s["name"].role_token, s["name"].claimed_family, s["probe"].measured_family) + (f", skipped near-silent: {', '.join(r['skipped'])}" if r["skipped"] else "") + ")",
for s in r["stems"] if not s["probe"].agrees] f"- grid: **{r['grid_bpm']:.1f} bpm** from `{r['grid_from']}` "
lines += [f"## {r['dir'].name}", "", f"· confidence {r['tempo_conf']:.0%}"
f"- stems: {len(r['stems'])} ({n_ok} usable" + ("" if r["bar_ok"] else " · **no usable pulse — chops only**")
+ (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" · producer declared **{r['declared_bpm']:.0f}**"
f" ({abs(r['grid_bpm'] - r['declared_bpm']) / r['declared_bpm'] * 100:.1f}% off)" 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 r["declared_bpm"] else " · no declared tempo to check against")]
if dis: if r["disagreements"]:
lines.append("- label vs measurement disagreements (measurement wins):") lines.append("- label vs measurement disagreements (measurement wins):")
lines += [f" - `{t}` claimed *{c}*, measured **{m}**" for t, c, m in dis] lines += [f" - `{tok}` claimed *{c}*, measured **{m}**"
mine = [c for c in cuts if c.track == r["dir"].name] for tok, c, m in r["disagreements"]]
mine = [c for c in cuts if c.track == r["track"]]
tiers = Counter(c.tier for c in mine) tiers = Counter(c.tier for c in mine)
lines += ["", f"- shipped {len(mine)} samples · tiers " lines += ["", f"- shipped {len(mine)} samples · tiers "
+ " ".join(f"{t}×{tiers[t]}" for t in reversed(TIER_ORDER) if tiers[t]), ""] + " ".join(f"{t}×{tiers[t]}" for t in reversed(TIER_ORDER) if tiers[t]), ""]
...@@ -448,13 +547,13 @@ def main(argv=None) -> int: ...@@ -448,13 +547,13 @@ def main(argv=None) -> int:
out = a.out or Path.cwd() out = a.out or Path.cwd()
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
results, cuts, frags = [], [], [] results, cuts = [], []
if a.jobs == 1 or len(todo) == 1: if a.jobs == 1 or len(todo) == 1:
for d in todo: for d in todo:
r, c, log = _run_track(d, opts) r, c, log = _run_track(d, opts)
print(log, flush=True) print(log, flush=True)
results.append(r); cuts += c results.append(track_summary(r)); cuts += c
frags = [report_md(results, cuts)] (out / "tracks.json").write_text(json.dumps(results, indent=2))
else: else:
# A track is the unit of parallelism: every stem in a track is cut against one # 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 # shared beat grid, so stems cannot be split across workers, and tracks are
...@@ -462,14 +561,14 @@ def main(argv=None) -> int: ...@@ -462,14 +561,14 @@ def main(argv=None) -> int:
# — the pool's forkserver is broken under this Python (BrokenProcessPool on a # — 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 # trivial task), and one process per track also means a track that blows up
# loses only itself. # loses only itself.
cuts, frags = _fan_out(todo, a, out) results, cuts = _fan_out(todo, a, out)
if cuts and not a.no_clap: if cuts and not a.no_clap:
say = lambda m: print(m, flush=True) # noqa: E731 say = lambda m: print(m, flush=True) # noqa: E731
say("\nCLAP tagging…") say("\nCLAP tagging…")
tag_with_clap(cuts, progress=say) # once, in the parent: one model load 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.md").write_text(report_md(results, cuts))
(out / "fred_kits.tidal").write_text(cheatsheet(cuts)) (out / "fred_kits.tidal").write_text(cheatsheet(cuts))
(out / "fred_kits.json").write_text(json.dumps([asdict(c) for c in cuts], indent=2)) (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 " print(f"\n{len(cuts)} samples in {len({c.kit for c in cuts})} kits "
...@@ -477,7 +576,7 @@ def main(argv=None) -> int: ...@@ -477,7 +576,7 @@ def main(argv=None) -> int:
return 0 return 0
def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]: def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list]:
"""Run one `--jobs 1` subprocess per track, then merge their results. """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 Children are told `--no-clap`: tagging loads a ~500 MB model, so it happens once
...@@ -509,7 +608,7 @@ def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]: ...@@ -509,7 +608,7 @@ def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]:
stdout=lf, stderr=subprocess.STDOUT, text=True) stdout=lf, stderr=subprocess.STDOUT, text=True)
return d, sub, r return d, sub, r
cuts, frags = [], [] results, cuts = [], []
with ThreadPoolExecutor(max_workers=a.jobs) as ex: # threads only wait on children with ThreadPoolExecutor(max_workers=a.jobs) as ex: # threads only wait on children
for d, sub, r in ex.map(run, todo): for d, sub, r in ex.map(run, todo):
log = (sub / "run.log").read_text() if (sub / "run.log").exists() else "" log = (sub / "run.log").read_text() if (sub / "run.log").exists() else ""
...@@ -520,10 +619,10 @@ def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]: ...@@ -520,10 +619,10 @@ def _fan_out(todo: list[Path], a, out: Path) -> tuple[list, list[str]]:
j = sub / "fred_kits.json" j = sub / "fred_kits.json"
if j.exists(): if j.exists():
cuts += [CutLoop(**c) for c in json.loads(j.read_text())] cuts += [CutLoop(**c) for c in json.loads(j.read_text())]
m = sub / "fred_kits.md" s = sub / "tracks.json"
if m.exists(): if s.exists():
frags.append(m.read_text().replace("# Fred stem-pack cut — report\n", "")) results += json.loads(s.read_text())
return cuts, frags return results, cuts
if __name__ == "__main__": if __name__ == "__main__":
......
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