Commit b8059f81 by PLN (Algolia)

feat(cosmicfest): two tools for a gig with no stems — separation as a lens, and a bus-only master

Both exist because CosmicFest is one stage-mic MP3, and the house pipeline
assumes twelve Ardour orbit-stems.

demucs_sections.py — per-track stems plus a global pass, PLN's ask: "to see how
both are interpreted locally and globally". One measured fact decides what that
comparison can show. htdemucs' receptive field is 7.8 s (model.segment = 39/5)
and it always splits internally, so a 63-minute file gives the model no more
musical context than a 4-minute one. The local/global difference is therefore
NOT context — it is input normalisation, since demucs normalises by the mean/std
of whatever you hand it. On this gig that is a real lever rather than a nitpick:
the set sits at -25.2 LUFS with 11.1 LU of range, so quiet sections get far less
gain under whole-set statistics than under their own.

So global mode computes the normalisation over the entire recording and then
applies the model in bounded chunks with those statistics. That isolates the one
variable that can actually differ, in about 1 GB instead of the ~12 GB a single
63-minute tensor needs (four sources x 1.33 GB of output, plus apply_model's
overlap accumulator, against 12 GB free). Chunks overlap and are crossfaded with
an equal-power ramp — the seam is two estimates of the same audio summed, so a
linear fade would dip 3 dB in the middle of it.

Written against demucs' Python API rather than its CLI, because the CLI cannot
save here at all: torchaudio 2.10 routes writes through TorchCodec, which is not
installed, so `python -m demucs` separates for 87 seconds and then dies on
ImportError. Going through apply_model and soundfile also avoids installing
anything into PLN's venv to work around it.

Validated before launching a six-hour job: 30 s slice, four stems written, all
populated, and sum-minus-original sits 27.4 dB below the signal. Vocals came
back at -50.6 dBFS RMS on an instrumental passage — correctly near-empty, which
is the kind of agreement that makes later disagreements worth trusting. Runs as
a systemd --user unit per reference_durable_background_jobs, niced with idle IO
so it can never be why the rig stutters.

master_stemless.py — MASTERING.md's chain with the per-stem half removed and one
stage added, because the measurement demanded it. Full-file analysis first:
L/R correlation 0.833 (genuinely stereo, so bass-mono is safe), sub-30 Hz is
0.07% of total energy (the HPF is free headroom), and then the finding that
reshapes the job — 300-1000 Hz carries 34.75% while 4-8 kHz has 1.75% and
8-16 kHz has 0.12%. That is a microphone in a room, not a desk feed: the PA's
treble never reaches the mic, the room absorbs what does, mid-bass piles up. The
tape is dark and boxy, not merely quiet, so the dominant move is a broadband
tilt rather than gain.

Restraint where the numbers cannot justify enthusiasm: the air shelf is +5, not
the +12 the deficit suggests, because a 320 k MP3 puts codec residue in that band
alongside cymbals and a big boost lifts hiss into the master. The demucs stems
are the lens that can tell those apart; until they answer, the shelf stays modest.

Bass-mono runs in mid/side, not by splitting and re-summing bands: a lowpass(120)
summed with a highpass(120) leaves a phase notch at the crossover, whereas
high-passing the side channel alone never filters the mid path.

Two failures worth keeping. First, the club target with the limiter placed before
any gain: the signal reaching it still peaked at -9.5 dBTP, so a -1 dBFS ceiling
never engaged, and loudnorm's linear=true — correctly refusing to breach TP —
capped the master at -11.2 LUFS against a -9 target. The gain has to come first
so the limiter has something to catch. Second, that fix alone still undershot,
because every dB the limiter absorbs is a dB the final loudnorm cannot add; the
shortfall IS the missing gain, so feeding it back converges. Bounded at three
attempts and guarded by an LRA floor, since a loudness target reached by
flattening the music is not reached — presence_is_a_precondition, applied to
dynamics.

On the 90 s trial the streaming target lands at -14.0 LUFS, peak -1.0, with
1.8 LU of range lost. The club target stops at -10.5 with the floor tripped, and
reports the miss instead of clipping its way to a number. Whether -9 is
reachable on the full set, whose LRA is 11.1 rather than the slice's 4.6, is a
question the full render answers.
parent d97dda3f
#!/usr/bin/env python3
"""demucs_sections — separate a stemless gig into per-track stems, two ways.
A set captured on one stage mic has no stems, so `feedback_mastering_eda`'s rule
("EDA on stems is a required first step; never infer a sound's role from its
name") is unsatisfiable. Demucs restores the ability to MEASURE. Read the output
as a lens, not as mix elements: re-summing separated stems gives the original
plus separation error, which stays masked for a small corrective move and turns
phasey for a real rebalance.
PLN asked for both a per-section pass and a global one, "to see how both are
interpreted locally and globally". One measured fact shapes what that comparison
can possibly show: **htdemucs' receptive field is 7.8 s** (`model.segment = 39/5`)
and it always splits internally, so the model NEVER sees musical context beyond
those 7.8 s no matter how long the file is. The local/global difference is
therefore NOT context — it is INPUT NORMALISATION. Demucs normalises by the
mean/std of whatever you hand it, so a section normalises against itself while a
global pass normalises against the whole set. On this gig that is a big lever,
not a nitpick: the set sits at -25.2 LUFS with 11.1 LU of range, so the quiet
sections get far less gain under global stats than under their own.
So `global` mode computes the normalisation over the WHOLE recording and then
applies the model in bounded chunks with those stats. That isolates the one
variable that can actually differ, and it does so in ~1 GB instead of the ~12 GB
a single 63-minute tensor would need (4 sources x 1.33 GB output, plus
apply_model's overlap accumulator, against 12 GB free). Chunks overlap and are
crossfaded so the seams the chunking introduces do not land in the audio.
Resumable by construction: an existing, non-empty stem set is skipped, because a
6-hour CPU job that cannot be restarted is a job that will be restarted from zero.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
SR = 44100
SOURCES = ("drums", "bass", "other", "vocals")
def slug(title: str) -> str:
keep = "".join(c if c.isalnum() or c in " -_" else "" for c in title)
return "_".join(keep.split()).lower()[:48]
def cut(master: Path, start: float, end: float, dest: Path) -> Path:
"""Decode one section to WAV. Demucs on the MP3 directly would re-decode the
whole file per section, and a WAV is what the analysis pass wants anyway."""
if dest.exists() and dest.stat().st_size > 1000:
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y",
"-ss", f"{start:.3f}", "-to", f"{end:.3f}", "-i", str(master),
"-ar", str(SR), "-ac", "2", "-c:a", "pcm_s24le", str(dest)],
check=True)
return dest
def _load_model(threads: int):
import torch
from demucs.pretrained import get_model
torch.set_num_threads(threads)
m = get_model("htdemucs")
m.eval()
return m
def separate(wav, model, ref_mean=None, ref_std=None):
"""Run the model. `ref_*` override the per-input normalisation stats — that
override IS the whole global mode."""
import torch
from demucs.apply import apply_model
ref = wav.mean(0)
mu = ref.mean() if ref_mean is None else ref_mean
sd = ref.std() if ref_std is None else ref_std
with torch.no_grad():
out = apply_model(model, ((wav - mu) / sd)[None],
device="cpu", shifts=0, split=True,
overlap=0.25, progress=False, num_workers=0)[0]
return out * sd + mu
def do_section(args) -> tuple[str, float, bool]:
"""One section, in its own process — torch threads do not scale to 16 on a
single job, so parallel sections beat one wide job."""
import soundfile as sf
import torch
wav_path, out_dir, threads = args
out_dir = Path(out_dir)
done = all((out_dir / f"{s}.wav").exists() and (out_dir / f"{s}.wav").stat().st_size > 1000
for s in SOURCES)
if done:
return (out_dir.name, 0.0, True)
t0 = time.time()
data, sr = sf.read(str(wav_path), dtype="float32", always_2d=True)
wav = torch.from_numpy(data.T).contiguous()
model = _load_model(threads)
out = separate(wav, model)
out_dir.mkdir(parents=True, exist_ok=True)
for name, tensor in zip(model.sources, out):
sf.write(str(out_dir / f"{name}.wav"), tensor.numpy().T, sr, subtype="PCM_24")
return (out_dir.name, time.time() - t0, False)
def global_pass(master: Path, out_dir: Path, chunk_s: float, xfade_s: float, threads: int):
"""Global normalisation, chunked application, crossfaded seams."""
import numpy as np
import soundfile as sf
import torch
out_dir.mkdir(parents=True, exist_ok=True)
if all((out_dir / f"{s}.wav").exists() and (out_dir / f"{s}.wav").stat().st_size > 1000
for s in SOURCES):
print("global: already done, skipping", flush=True)
return
# Pass 1 — normalisation stats over the WHOLE recording, streamed so the
# 63-minute file never sits in RAM in full.
print("global: pass 1/2 — normalisation stats over the full recording", flush=True)
n, s1, s2 = 0, 0.0, 0.0
with sf.SoundFile(str(master)) as f:
while (blk := f.read(1 << 20, dtype="float32", always_2d=True)).size:
mono = blk.mean(axis=1)
n += mono.size
s1 += float(mono.sum())
s2 += float((mono.astype("float64") ** 2).sum())
mu = s1 / n
sd = (s2 / n - mu * mu) ** 0.5
print(f"global: mean={mu:.6g} std={sd:.6g} over {n/SR:.1f} s", flush=True)
model = _load_model(threads)
mu_t, sd_t = torch.tensor(mu), torch.tensor(sd)
chunk, xf = int(chunk_s * SR), int(xfade_s * SR)
writers = {s: sf.SoundFile(str(out_dir / f"{s}.wav"), "w", samplerate=SR,
channels=2, subtype="PCM_24") for s in model.sources}
try:
with sf.SoundFile(str(master)) as f:
total = f.frames
tail = None # overlap region carried from the previous chunk
pos, i = 0, 0
while pos < total:
f.seek(pos)
want = chunk + (xf if pos + chunk < total else 0)
data = f.read(want, dtype="float32", always_2d=True)
if not data.size:
break
wav = torch.from_numpy(data.T).contiguous()
out = separate(wav, model, ref_mean=mu_t, ref_std=sd_t)
i += 1
print(f"global: chunk {i} @ {pos/SR/60:.1f} min "
f"({data.shape[0]/SR:.0f} s)", flush=True)
body = out[:, :, :chunk] # the part this chunk owns
nxt = out[:, :, chunk:] # the overlap, for crossfading
if tail is not None:
k = min(tail.shape[-1], body.shape[-1])
if k:
# equal-power ramp: the seam is a sum of two estimates of
# the same audio, so a linear fade would dip by 3 dB
r = torch.linspace(0, 1, k)
body[:, :, :k] = tail[:, :, :k] * torch.cos(r * 3.14159 / 2) ** 0.5 \
+ body[:, :, :k] * torch.sin(r * 3.14159 / 2) ** 0.5
for name, tensor in zip(model.sources, body):
writers[name].write(tensor.numpy().T)
tail = nxt if nxt.shape[-1] else None
pos += chunk
finally:
for w in writers.values():
w.close()
print("global: done", flush=True)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--mode", choices=("sections", "global", "both"), default="both")
ap.add_argument("--workers", type=int, default=4, help="parallel sections")
ap.add_argument("--threads", type=int, default=4, help="torch threads per section")
ap.add_argument("--chunk-s", type=float, default=300.0)
ap.add_argument("--xfade-s", type=float, default=2.0)
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
master = Path(spec["master"])
root = Path(spec["releaseRoot"])
segs = json.loads(Path(spec["segmentsRelease"]).read_text())
sect_wav = root / "sections_wav"
stems = root / "stems_demucs"
if a.mode in ("sections", "both"):
jobs = []
for s in segs:
name = f"{s['track']:02d}-{slug(s['title'])}"
w = cut(master, s["start"], s["end"], sect_wav / f"{name}.wav")
jobs.append((str(w), str(stems / "sections" / name), a.threads))
print(f"sections: {len(jobs)} jobs, {a.workers} workers x {a.threads} threads",
flush=True)
t0 = time.time()
with ProcessPoolExecutor(max_workers=a.workers) as ex:
for name, dt, skipped in ex.map(do_section, jobs):
print(f" {'skip' if skipped else 'done'} {name}"
+ ("" if skipped else f" {dt:.0f}s"), flush=True)
print(f"sections: {time.time()-t0:.0f}s total", flush=True)
if a.mode in ("global", "both"):
global_pass(master, stems / "global", a.chunk_s, a.xfade_s, min(16, os.cpu_count() or 8))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""master_stemless — master a gig captured on ONE file (no stems).
MASTERING.md's canonical chain assumes 12 Ardour orbit-stems: gain-stage each,
sum, spatialise, glue, push. A stage-mic capture has no stems, so the per-stem
half is simply unavailable and everything must happen on the bus. This is that
chain with the stem stages removed and ONE stage added, because the measurement
demanded it.
Measured on CosmicFest 2026 (ZOOM0067.MP3, full 62:59) before writing any filter:
L/R correlation 0.833 genuinely stereo — bass-mono is safe
<30 Hz 0.07% HPF is free headroom, not a compromise
300-1000 Hz 34.75% boxy midrange DOMINATES
1-4 kHz 14.76%
4-8 kHz 1.75% -17.6 presence nearly absent
8-16 kHz 0.12% -29.4 almost no air at all
I -25.2 LUFS · TP -5.1 dBFS · LRA 11.1 LU
That is the signature of a microphone in a room rather than a desk feed: the
PA's treble never reaches the mic position, the room absorbs what does, and
mid-bass builds up. So the dominant move is a broadband TILT, not gain — which
is why this file exists instead of a `--target-lufs` flag on the stem mixer.
Restraint where the measurement cannot support enthusiasm: the air shelf is +5,
not the +12 the deficit suggests. The source is a 320 kbps MP3, so the 8-16 kHz
band holds codec residue as well as cymbals, and a big boost there lifts hiss
into the master. Demucs stems (running separately) are the lens that can say how
much of that band is drums versus noise; until they do, the shelf stays modest.
`feedback_trim_threshold_ear_correction` — do not let a number talk you into a
move the ear has not sanctioned.
Bass-mono is done in MID/SIDE, not by splitting and re-summing bands. A
`lowpass(120)` summed with a `highpass(120)` puts a phase notch at the crossover;
high-passing the SIDE channel alone removes low-frequency stereo without the mid
path ever being filtered.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
# --- the chain, as data so the render can print exactly what it applied -------
HPF = 25 # measured: <30 Hz is 0.07% of total energy
LPF = 19500 # a 320k MP3 has nothing above ~20 k; strips codec junk
BOX_F, BOX_W, BOX_G = 450, 1.1, -2.5 # the 34.75% pileup at 300-1000
PRES_F, PRES_W, PRES_G = 3500, 0.9, 2.0 # articulation, gently
AIR_F, AIR_G = 6500, 5.0 # the 1.75% / 0.12% deficit, restrained
BASS_MONO_F = 120
# A loudness target reached by flattening the music is not reached. Below this
# residual range the push is doing harm, so we stop and report the miss.
LRA_FLOOR = 1.5
GLUE = "acompressor=threshold=-22dB:ratio=1.5:attack=30:release=100:makeup=1"
TARGETS = { # MASTERING.md's table; TP -1 dBTP for every platform
"streaming": dict(I=-14, TP=-1.0, LRA=11),
"club": dict(I=-9, TP=-1.0, LRA=11),
}
def tone_chain() -> str:
"""Everything before loudness. One string so it is identical in both passes."""
return (
f"highpass=f={HPF},"
f"equalizer=f={BOX_F}:t=q:w={BOX_W}:g={BOX_G},"
f"equalizer=f={PRES_F}:t=q:w={PRES_W}:g={PRES_G},"
f"highshelf=f={AIR_F}:g={AIR_G},"
f"lowpass=f={LPF}"
)
def filter_complex(loudnorm: str, pre_gain: float | None = None) -> str:
"""Tone -> M/S bass-mono -> glue -> [staged push] -> loudnorm.
`pre_gain` is what makes the club target reachable, and getting it wrong is
instructive: with the limiter placed BEFORE any gain, the signal arriving at
it still peaked at -9.5 dBTP, so a -1 dBFS ceiling never engaged, and
`linear=true` — correctly refusing to exceed TP — capped the master at
-11.2 LUFS instead of -9. The gain has to come FIRST so the limiter has
something to catch. Measured, not reasoned: that failure is why this
parameter exists.
Then MASTERING.md's staged advice, because 16 dB is too much for one stage:
a compressor takes the top off the loud passages, and the limiter only
handles what survives. One limiter doing all 16 dB pumps audibly.
"""
parts = [
f"[0:a]{tone_chain()},asplit=2[a][b]",
"[a]pan=mono|c0=0.5*c0+0.5*c1[M]",
f"[b]pan=mono|c0=0.5*c0-0.5*c1,highpass=f={BASS_MONO_F}[S]",
"[M][S]join=inputs=2:channel_layout=stereo[ms]",
# back to L/R: L = M+S, R = M-S
"[ms]pan=stereo|c0=c0+c1|c1=c0-c1[lr]",
f"[lr]{GLUE}[g]",
]
last = "g"
if pre_gain is not None:
parts.append(
f"[{last}]volume={pre_gain:.2f}dB,"
"acompressor=threshold=-14dB:ratio=2:attack=10:release=150:makeup=1,"
"alimiter=limit=0.891:attack=5:release=100:level=disabled[push]")
last = "push"
parts.append(f"[{last}]{loudnorm}[out]")
return ";".join(parts)
def measure(src: Path, pre_gain: float | None = None) -> dict:
"""loudnorm pass 1. Two-pass is not optional: pass 2 needs measured_* to
apply ONE fixed offset (linear=true). A single-pass loudnorm rides the gain
and smears transients — the exact thing -14 LUFS is chosen to preserve."""
fc = filter_complex("loudnorm=print_format=json", pre_gain)
p = subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-i", str(src),
"-filter_complex", fc, "-map", "[out]", "-f", "null", "-"],
capture_output=True, text=True)
blocks = re.findall(r"\{[^{}]*\"input_i\"[^{}]*\}", p.stderr, re.S)
if not blocks:
print(p.stderr[-2500:], file=sys.stderr)
raise SystemExit("loudnorm pass 1 produced no measurement")
return json.loads(blocks[-1])
def render(src: Path, dest: Path, target: dict, m: dict,
pre_gain: float | None = None) -> None:
ln = (f"loudnorm=I={target['I']}:TP={target['TP']}:LRA={target['LRA']}:linear=true"
f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
f":offset={m['target_offset']}")
dest.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-v", "error", "-y", "-i", str(src),
"-filter_complex", filter_complex(ln, pre_gain), "-map", "[out]",
# 24-bit: the source is lossy, but the chain's gain and EQ produce
# values that deserve the headroom, and every downstream split
# re-encodes from this file.
"-c:a", "flac", "-sample_fmt", "s32", "-compression_level", "5", str(dest)]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(r.stderr[-2500:], file=sys.stderr)
raise SystemExit(f"render failed: {dest}")
def verify(dest: Path) -> dict:
"""Re-measure the OUTPUT. feedback_verify_own_renders: the machine catches
objective errors so PLN's ears are spent on taste, not on arithmetic."""
p = subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-i", str(dest),
"-af", "ebur128=peak=true", "-f", "null", "-"],
capture_output=True, text=True)
tail = p.stderr[-1800:]
def grab(label):
m = re.search(rf"{label}:\s*(-?[\d.]+)", tail)
return float(m.group(1)) if m else None
return {"I": grab("I"), "LRA": grab("LRA"), "peak": grab("Peak")}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--variant", action="append", choices=sorted(TARGETS),
help="default: every variant in the spec")
ap.add_argument("--source", help="override the spec's source (for testing on a slice)")
ap.add_argument("--out-dir", help="override releaseRoot")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
src = Path(a.source or spec.get("source") or spec["master"])
variants = a.variant or list(spec["variants"])
root = Path(a.out_dir) if a.out_dir else Path(spec["releaseRoot"])
root.mkdir(parents=True, exist_ok=True)
print(f"source {src.name}")
print(f"tone {tone_chain()}")
print(f" bass-mono (mid/side) <{BASS_MONO_F} Hz · glue 1.5:1 @ -22 dB\n")
report = {}
# One tone-only measurement serves every variant: the chain up to the push
# is identical, so measuring it per variant would burn a full pass each.
base = measure(src)
print(f"tone-only: I={base['input_i']} TP={base['input_tp']} LRA={base['input_lra']}\n")
for v in variants:
t = TARGETS[v]
headroom = t["TP"] - float(base["input_tp"]) # what a fixed offset can give
want = t["I"] - float(base["input_i"]) # what the target asks for
staged = want > headroom # a limiter is the only way there
pre_gain = want if staged else None
dest = root / f"{v}.flac" if a.out_dir else Path(spec["variants"][v])
print(f"=== {v}: I={t['I']} TP={t['TP']} · needs {want:+.1f} dB, "
f"linear headroom {headroom:+.1f} dB -> "
+ (f"STAGED (pre-gain {pre_gain:+.1f} dB, comp + limiter)" if staged
else "linear, no limiter"))
# Converge on the target. Every dB the limiter absorbs is a dB the final
# loudnorm cannot add without breaching TP, so the first pre-gain
# undershoots: the 90 s trial reached -10.5 against a -9 target. Feeding
# the residual back in converges, because the shortfall IS the gain the
# limiter ate. Bounded at 3 attempts, and guarded by a dynamics floor —
# `presence_is_a_precondition`: a target hit by flattening the music is
# not a target hit. If the floor trips we report the miss and keep the
# better-sounding render rather than clipping our way to a number.
attempts, got, m, best = [], None, base, None
for i in range(3):
m = measure(src, pre_gain) if staged else base
if staged:
print(f" attempt {i+1}: pre-gain {pre_gain:+.1f} dB -> "
f"I={m['input_i']} TP={m['input_tp']} LRA={m['input_lra']}")
render(src, dest, t, m, pre_gain)
got = verify(dest)
lra_out = got["LRA"] if got["LRA"] is not None else 0.0
hit = got["I"] is not None and abs(got["I"] - t["I"]) <= 0.5
attempts.append({"pre_gain_db": pre_gain, "out": dict(got)})
if best is None or (got["I"] is not None and best["I"] is not None
and abs(got["I"] - t["I"]) < abs(best["I"] - t["I"])):
best = dict(got)
if hit:
break
if lra_out < LRA_FLOOR:
print(f" stop: LRA {lra_out} is below the {LRA_FLOOR} LU floor — "
f"{t['I']} LUFS costs more than it is worth on this source")
break
if not staged or got["I"] is None:
break
pre_gain += (t["I"] - got["I"]) # the shortfall is the missing gain
ok = (got["I"] is not None and abs(got["I"] - t["I"]) <= 0.5
and got["peak"] is not None and got["peak"] <= t["TP"] + 0.3)
squash = (float(base["input_lra"]) - got["LRA"]) if got["LRA"] is not None else None
print(f" out: I={got['I']} LRA={got['LRA']} peak={got['peak']} "
f"{'✓' if ok else '✗ MISSED TARGET'}"
+ (f" · dynamics lost {squash:.1f} LU" if squash and squash > 1 else ""))
print(f" {dest}")
report[v] = {"target": t, "measured_tone": base, "measured_in": m,
"measured_out": got, "in_spec": ok, "staged": staged,
"pre_gain_db": pre_gain, "lra_lost": squash,
"attempts": attempts, "path": str(dest)}
(root / "master_report.json").write_text(json.dumps(report, indent=1) + "\n")
print(f"\n✓ {root/'master_report.json'}")
return 0 if all(r["in_spec"] for r in report.values()) else 1
if __name__ == "__main__":
sys.exit(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