Commit b40c7fb5 by PLN (Algolia)

docs+feat(cosmicfest): log 038, the stem EDA, and a finisher that does not need my session

Log 038 — "A record from one microphone" — is the night CosmicFest went from
"educated guess game", PLN's phrase, to a pipeline. It carries the measurements
that reshaped the job and a correction to my own previous commit.

eda_stems.py is the mastering EDA a stemless gig could not otherwise have, and it
exists to answer three specific questions rather than to print statistics.

Is the 8-16 kHz deficit cymbals or codec noise? The master measured 0.12% of
total energy up there and the air shelf was deliberately held to +5 dB instead of
the +12 the deficit suggests, because a 320 kbps MP3 puts hiss in that band
alongside hats. A broadband boost cannot tell the two apart; a per-stem view can.
If the HF lives in `drums` it is cymbals and the shelf can open, and if it is
spread evenly across all four it is noise and the shelf stays shut.

Where is the kick? project_floor_problem measured the kick audible in only 59% of
OPAL-26, from real orbit stems. This asks the same question of a gig that has
none, via 40-120 Hz activity over time, thresholded relative to each track's own
p95 rather than an absolute dBFS — a fixed threshold on a quiet room-coloured
source measures the recording level, not the kick. Reported per track so a floor
that drops out gets named instead of averaged away.

And how much does local-versus-global separation actually differ? It compares
each section's stem against the same span cut out of the global stem:
correlation says whether the model made the same decision, the RMS delta says
whether normalisation changed the level. That comparison is the reason the
chunked writer had to be length-exact.

Presence is gated before any stem is compared to another, and demucs' own source
names are treated as hypotheses — feedback_label_and_lens applies to a
separator's labels as much as to a sample's filename.

finish_cosmic.sh + cosmic-finish.service exist because of a structural mistake I
nearly shipped. The master and demucs runs are systemd units so they outlive the
session, but every step AFTER them — split, verify, EDA — was sitting in my
session. PLN said "your autonomous now going to bed tell me results tomo", so a
teardown would have left him with rendered masters and nothing else. The unit is
ordered After= both producers, so systemd does the waiting instead of a polling
loop, and it leaves FINISH_SUMMARY.txt on disk that stands on its own. Wants=
rather than Requires=, deliberately: a club-target miss or a demucs failure must
not cancel the split of a perfectly good streaming master.

The log also corrects commit 37eb401f, which claimed the chunked demucs path was
about twice as fast. It is not, and the claim came from a broken metric: I counted
directories under stems_demucs/sections/, but a directory appears the moment
chunked_separate opens its writers, so the count measures sections STARTED, not
finished. libsndfile buffers on top of that, so three of four in-flight sections
sat at 44 bytes — a bare WAV header — after nine minutes of genuine work.
Measured properly by mtime and flushed bytes, a worker runs at about 4.3x
realtime under four-way contention plus the master render. Chunking fixed the
OOM kill; it did not demonstrably make anything faster.
parent 6ae2b820
# 038 — A record from one microphone
**2026-09-01.** PLN reframed the release: not one record but four deliverables —
{OPAL-26, CosmicFest-26} × {continuous club mix, per-track album split}. Then he
named the constraint himself: *"i ack cosmicset is a raw record from stage with
effects, we can only do our best, 80/20 likely"*, and later, flatly, *"we have
neither set action logs nor the stems.. we can interpolate from the code, but its
gonna be a educated guess game."*
This log is the night that turned that guess game into a pipeline.
## Where the two sets actually stood
Measured from the filesystem, not recalled from the board:
| | OPAL-26 | COSMICFEST-26 |
|---|---|---|
| source | 12 stems + master, 48 k/24 | **one stage-mic MP3**, 320 k, 62:59 |
| boundaries | ✅ ear-signed 08-16 | ✅ ear pass 08-29 |
| gig spec | ✅ | 🔴 **did not exist** |
| mix / master | ✅ v4 premix | 🔴 nothing |
| album split | ✅ 14 × 2 | 🔴 none |
| club mix | ✅ 2 × nodrop | 🔴 none |
| ear-reviewed MIX | ✅ A1.7 | ⛔ nothing existed to review |
So the honest answer to "are we done with boundaries, is the mix done, have we
reviewed it" was: **boundaries done for both; mix done and reviewed for OPAL
only; CosmicFest had never had a single mastering pass.** Its ear session settled
*where the cuts are*, never *how it sounds*.
## The thing that mattered most took two minutes
`ZOOM0067.MP3` — the only recording of a 63-minute gig — existed in exactly one
place: `~/Downloads`. Not on the freebox, not in `Prod/`. It is now in
`Prod/Cosmic26_master/` and mirrored to the freebox, `sha256 7a197589…d671b`
identical on both, and the Downloads original was deleted only after both copies
verified. A move, not a hopeful `mv`.
Adjacent trap, recorded because the names invite the error: `Prod/cosmicfest/`
and `Prod/cosmicfestv0.live1.*` are the **2025** edition, files dated
2025-06-27/28. Nothing in them belongs to this release.
## The tape is much better than "stage mic with FOH effects" implies
`ebur128` over the full 62:59: **I −25.2 LUFS · true peak −5.1 dBFS · LRA
11.1 LU**. Read as three pieces of good news: the peak says it was recorded
conservatively and is **not clipped**; 11.1 LU says the desk did **not crush** it
(a mangled feed lands under 6); it is simply *quiet*.
But the spectrum is the finding that reshaped the work:
| band | share | |
|---|---|---|
| 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 |
| <30 Hz | 0.07% | the HPF is free headroom |
L/R correlation 0.833 — genuinely stereo, so bass-mono below 120 Hz is safe.
That profile is a **microphone in a room**, not a desk feed: the PA's treble
never reaches the mic position, 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*, and gain alone would have produced a louder muddy record. This
is the whole argument for measuring before reaching for a filter.
## The keystone was one file
`POSTPROD.md`: *"A new gig is a copy of `judge_specs/<gig>.json` — never a copy
of a script."* That file did not exist for CosmicFest, and **both** its
deliverables sat downstream of it. Written, and the stemless path **verified by
reading the code rather than by trying it and hoping**: `apply_boundaries` and
`render_release` touch no stem key at all — only `segments`, `master`,
`variants`, `releaseRoot`, `releaseTag`. The one tool that genuinely needs stems
is `build_judge_set`, which derives nominal segments from per-orbit activity.
`setlist_to_segments.py` fills exactly that hole, and its real job is keeping two
grades of truth apart. The setlist silently mixes them: some starts are playhead
calls PLN made while listening; others were reconstructed from his notes on
boundaries he *skipped*. Only the former enter `verified`, so `apply_boundaries`
prints `ear` or `nominal` per row and asserts every playhead call reached the
output. An inferred edge can never later be mistaken for one he heard.
**That distinction immediately corrected the board.** It had named #10 and #12 as
the soft edges, taken from the recovered tracklist's unresolved list. The ear file
supersedes it: ten starts (#2–#11) are playhead calls, #1 is the origin, and the
inferred ones are **#12 LiveCode Parade, #13 Vague de CRIME, #14 Outro: La Dub
Sirène**. #10 PunkAChien was ear-called all along — *"perfect cut at 48:27.4 into
PunkAChien!"*. Three tracks, not two, and a different three. The board also said
11 decided boundaries; there are 10.
Result: **14 tracks, 63.0 min of a 63.0 min master, zero gaps, all assertions
green.**
## Titles: what shipped, then PLN, never a slug
Resolution order is authored → OPAL-release → catalog → `NEEDS_PLN`. The
authoritative source for a shared track is what actually *shipped*: OPAL-26's
ear-signed `segments_v4.json` joined to that gig's `tracks.json` on performance
order, because neither file alone holds both the score path and the released
title. Four tracks were in no shipped release and no catalog entry, so the first
run emitted them as `NEEDS_PLN` rather than title-casing them into something
plausible. PLN then gave all four: **Rose Rouge · Mafia sans Serif · LiveCode
Parade · Outro: La Dub Sirène** (he asked for proper French accents). They live in
`judge_specs/cosmicfest-2026_titles.json` with value+source+locator+date.
That file also carries a `score` alias, because the gig slug `mafia` is
`mafia_sans_serif.tidal` — and the alias then **cross-checked itself**, yielding a
declared 160 BPM that matches OPAL's Mafia exactly. `Outro: La Dub Sirène` has no
`.tidal` at all, being a played outro rather than a composed track, which is also
why the catalog can never hold its title: the catalog is keyed by score path.
## Demucs: a lens for a gig with no stems
PLN: *"we can also use demucs on the raw recording, once we have boundaries, on
each section (does it make sense) then we have 'stems' to work with, its a good
signal imo"* — then *"demucs all 14 sections and a global take to see how both are
interpreted locally and globally"*.
It makes sense, and for a stronger reason than stated: `feedback_mastering_eda`
makes stem EDA a *required* first step, so without stems we could not follow our
own rule. Demucs restores the ability to measure. But one measured fact bounds
what the local/global 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 difference is
**input normalisation**, not context. On a set at −25.2 LUFS with 11.1 LU of
range that is still a real lever, but it is a gain-staging experiment and should
be described as one.
Three things cost real time and are worth keeping:
- **The demucs 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 dies on `ImportError`. Going through `apply_model` + `soundfile`
fixes it and installs nothing into PLN's venv.
- **The first run was OOM-killed at 8.2 GB after 2 of 14 sections.** The
arithmetic was available beforehand and I did not do it: a 532 s section is
188 MB in, 750 MB out across four sources, and `apply_model`'s overlap
accumulator wants that again — ~2 GB × 4 workers, on a machine already holding
18 GB of unrelated Gradle builds. The fix was already written in the other half
of the same file: `global_pass` chunked its work for the same reason, so both
modes now share one bounded applier and differ only in which normalisation
stats they receive. Peak fell to 5.45 GB.
- **Two chunking details are load-bearing.** Seams are joined with an
equal-power ramp, because the overlap sums two *estimates of the same audio*
and a linear fade dips ~3 dB exactly at the join. And output is length-exact —
verified at 3969000 frames on all four stems for a 90 s input, zero drift —
which matters because the EDA seeks into the global stems by absolute time.
Validation before committing six hours: 30 s slice, four stems written, all
populated, sum-minus-original **27.4 dB below** the signal, and `vocals` at
−50.6 dBFS RMS on an instrumental passage — correctly near-empty. Agreement where
it should agree is the only thing that makes later disagreement worth trusting.
### Correction
Commit `37eb401` claims the chunked version was roughly twice as fast ("four
sections in the time two took"). **That claim is wrong and came from a broken
metric.** I counted directories under `stems_demucs/sections/`, but a directory
appears the moment `chunked_separate` opens its writers — so the count measures
sections *started*, not finished. libsndfile also buffers, so file size lags
badly: three of four in-flight sections sat at 44 bytes (a bare WAV header) after
nine minutes of real work. Measured properly via mtime and flushed bytes, one
worker was running at ~4.3× realtime under four-way contention plus the master
render. Chunking fixed the OOM; it did not demonstrably speed anything up.
## Mastering a bus with no stems
`master_stemless.py` is MASTERING.md's chain with the per-stem half removed and
one stage added because the measurement demanded it: HPF 25, a −2.5 dB dip at
450 Hz for the 34.75% pileup, +2 presence at 3.5 k, a +5 air shelf, LPF 19.5 k,
bass-mono below 120, the 1.5:1 glue comp, then two-pass `loudnorm` with
`linear=true`.
Restraint on the one number that begged for more: 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. Which of the two it
is, is precisely Q1 of the stem EDA — so the shelf stays modest until the lens
answers.
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.
Two failures worth keeping, both about the loud target:
1. **Limiter before gain does nothing.** The signal reaching it still peaked at
−9.5 dBTP, so a −1 dBFS ceiling never engaged, and `linear=true` — correctly
refusing to breach TP — capped the master at −11.2 LUFS against −9. The gain
has to come first so the limiter has something to catch.
2. **One pass still undershoots**, 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. On the 90 s trial: streaming **−14.0 LUFS, peak −1.0, 1.8 LU lost**;
club stopped at −10.5 with the floor tripped, reporting the miss rather than
clipping its way to a number.
## The gate that stops the shipment, and why it is right
`build_release_plan.py` refuses without canonical www gig metadata:
*"Gig metadata is never invented here — create it there first."* CosmicFest 2026
has no page under `content/lives/2026/`, and every upload adapter reads that
plan. So **no upload path is open** until PLN supplies the set title, venue and
stage. The gig spec leaves `album` absent for the same reason.
Rather than weaken the gate, `build_gig_tracksjson.py` draws its line explicitly:
*derivable* (name, file, bpm, timecodes, style from the score's directory,
samples from `catalog_view`'s parser) is generated; *PLN-only* (gig title, venue,
stage, description, and each track's `section`) is emitted as `null` with a
`_needs_pln` list. A draft, in the scratchpad, for him to promote. Tomorrow is
four fields, not an afternoon.
The derived timecodes then validated themselves against his own ear notes: Piment
Bresilien at **0:35:46** against *"starting at 35:45.6 i hear proper only piment
start sound"*, and LiveCode Parade at **0:54:58** against the 54:59 note. 14
tracks, 63.0 min, 30 sample packs, 89–170 BPM. Seven of fourteen scores are
absent from a stale `catalog_view`, so their sample lists are empty and *reported*
rather than silently blank.
**A second reason not to rush:** SoundCloud uploads are idempotent by permalink,
so publishing the 14-track split before PLN's ears approve the tone would poison
those permalinks — a v2 re-render is silently *skipped*, not replaced. The board
already records that defect for OPAL's `take-five-drops`.
## Two lessons about running work overnight
`nohup … &` inside a Bash tool call **does not survive**, and it fails silently:
no process, an empty log, no output, nothing errored. I read "still running" off a
90-second poll and lost ~25 minutes. Everything longer than one tool call now
goes in a `systemd --user` unit — which is what my own memory already said.
And the steps *after* the long jobs were sitting in my session, which would have
left PLN with masters and nothing else. So `cosmic-finish.service` is ordered
`After=cosmic-master.service cosmic-demucs.service` and does the split, the
verify and the stem EDA unattended, writing `FINISH_SUMMARY.txt`. `Wants=` not
`Requires=`, so a club-target miss cannot cancel the split of a good streaming
master.
## Shipped
`39ca4de` board · `d97dda3` spec + converter + titles · `b8059f8` demucs +
mastering tools · `37eb401` the OOM fix · `6ae2b82` metadata drafter.
Branch `claude/release-board`.
## Still PLN's
- **The set title, venue and stage** — the only thing blocking every upload.
- **The tone**, on the streaming master: is +5 of air enough for a room this dark?
- Whether `Outro: La Dub Sirène` (103 s) is an album track or mix-only.
- Whether the club target is worth chasing below the LRA floor on this source.
- The unreviewed merge `2f947c2`, still standing on master.
#!/usr/bin/env python3
"""eda_stems — the mastering EDA a stemless gig could not have, from demucs stems.
`feedback_mastering_eda` makes EDA on stems a required first step and forbids
inferring a sound's role from its name. A single stage-mic file makes both
impossible. Demucs restores the ability to measure, so this is that measurement —
and it is a LENS, not a mix: `feedback_label_and_lens` still applies, so demucs'
source names ("vocals") are treated as hypotheses, and presence is gated before
any stem is compared to another (`presence_is_a_precondition`).
Three questions it exists to answer:
1. **Is the 8-16 kHz deficit cymbals or codec noise?** The master measured 0.12%
of total energy up there and the air shelf was held to +5 dB because a 320 k
MP3 puts hiss in that band too. If the HF lives in `drums`, it is cymbals and
the shelf can open up; if it is spread evenly across all four stems, it is
noise and the shelf should stay shut. A broadband boost cannot tell them
apart — only a per-stem view can.
2. **Where is the kick?** `project_floor_problem` measured the kick audible in
only 59% of OPAL-26. That was computed from real orbit stems. This is the same
question asked of a gig that has none, via 40-120 Hz activity over time in the
drums stem — reported as a percentage of each track, so a track whose floor
drops out is named rather than averaged away.
3. **Local vs global: how much does normalisation actually change?** htdemucs'
receptive field is 7.8 s (`model.segment = 39/5`) and it always splits
internally, so a global pass cannot give the model more musical context. The
only thing that differs is the normalisation statistics. This measures whether
that difference is audible-scale or a rounding error, per stem and per
section, by comparing each section's stem against the same span cut out of the
global stem.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
SOURCES = ("drums", "bass", "other", "vocals")
BANDS = [(20, 60), (60, 120), (120, 300), (300, 1000),
(1000, 4000), (4000, 8000), (8000, 16000)]
PRESENCE_DBFS = -60.0 # below this a stem is empty, not quiet
KICK_LO, KICK_HI = 40, 120
KICK_WIN_S = 0.25
def db(x: float) -> float:
return 20 * np.log10(max(float(x), 1e-12))
def read(path: Path):
d, sr = sf.read(str(path), dtype="float32", always_2d=True)
return d.mean(axis=1), sr # mono for analysis; roles are not stereo
def band_shares(mono: np.ndarray, sr: int) -> dict:
n = 1 << 15
acc = np.zeros(len(BANDS))
win = np.hanning(n)
for i in range(0, max(len(mono) - n, 1), n):
seg = mono[i:i + n]
if len(seg) < n:
break
S = np.abs(np.fft.rfft(seg * win)) ** 2
fr = np.fft.rfftfreq(n, 1 / sr)
for j, (lo, hi) in enumerate(BANDS):
acc[j] += float(S[(fr >= lo) & (fr < hi)].sum())
tot = acc.sum() or 1.0
return {f"{lo}-{hi}": round(100 * v / tot, 2) for (lo, hi), v in zip(BANDS, acc)}
def kick_activity(mono: np.ndarray, sr: int) -> dict:
"""Fraction of time the 40-120 Hz band is within 12 dB of its own p95.
Relative to the track, not to an absolute dBFS: these stems come from a
quiet, room-coloured source, so any fixed threshold measures the recording
level rather than the kick. `feedback_measure_the_time_axis` — an aggregate
cannot tell a fade from a sparse pattern, so this walks the axis.
"""
from numpy.fft import rfft, irfft
n = len(mono)
F = rfft(mono)
fr = np.fft.rfftfreq(n, 1 / sr)
F[(fr < KICK_LO) | (fr > KICK_HI)] = 0
band = irfft(F, n=n)
w = max(int(KICK_WIN_S * sr), 1)
trim = (len(band) // w) * w
if trim == 0:
return {"active_pct": 0.0, "windows": 0}
env = np.sqrt((band[:trim].reshape(-1, w) ** 2).mean(axis=1))
if not env.size or env.max() <= 0:
return {"active_pct": 0.0, "windows": 0}
ref = np.percentile(env, 95)
thr = ref * (10 ** (-12 / 20))
return {"active_pct": round(100 * float((env >= thr).mean()), 1),
"windows": int(env.size),
"p95_dbfs": round(db(ref), 1)}
def stem_row(path: Path) -> dict | None:
if not path.exists() or path.stat().st_size < 1000:
return None
mono, sr = read(path)
rms, pk = float(np.sqrt((mono ** 2).mean())), float(np.abs(mono).max())
row = {"rms_dbfs": round(db(rms), 1), "peak_dbfs": round(db(pk), 1),
"present": db(rms) > PRESENCE_DBFS, "dur_s": round(len(mono) / sr, 1),
"bands": band_shares(mono, sr)}
row["hf_share_4k_16k"] = round(row["bands"]["4000-8000"]
+ row["bands"]["8000-16000"], 2)
return row
def compare_global(sect: Path, glob_path: Path, offset_s: float, dur_s: float) -> dict | None:
"""Same span, two separations. Correlation says whether the model made the
same decision; the RMS delta says whether normalisation changed the level."""
if not (sect.exists() and glob_path.exists()):
return None
a, sr = read(sect)
with sf.SoundFile(str(glob_path)) as f:
start = int(offset_s * f.samplerate)
if start >= f.frames:
return None
f.seek(start)
b = f.read(int(dur_s * f.samplerate), dtype="float32", always_2d=True).mean(axis=1)
k = min(len(a), len(b))
if k < sr:
return None
a, b = a[:k], b[:k]
denom = float(np.sqrt((a ** 2).sum() * (b ** 2).sum()))
corr = float((a * b).sum() / denom) if denom > 0 else 0.0
ra, rb = np.sqrt((a ** 2).mean()), np.sqrt((b ** 2).mean())
return {"corr": round(corr, 4),
"rms_delta_db": round(db(rb) - db(ra), 2),
"compared_s": round(k / sr, 1)}
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 main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--out", help="JSON report path (default: releaseRoot/eda_stems.json)")
ap.add_argument("--no-global", action="store_true")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
root = Path(spec["releaseRoot"])
stems = root / "stems_demucs"
segs = json.loads(Path(spec["segmentsRelease"]).read_text())
out = Path(a.out) if a.out else root / "eda_stems.json"
report, missing = [], []
for s in segs:
name = f"{s['track']:02d}-{slug(s['title'])}"
d = stems / "sections" / name
row = {"track": s["track"], "title": s["title"], "start": s["start"],
"duration": s["duration"], "dir": name, "stems": {}}
for src in SOURCES:
r = stem_row(d / f"{src}.wav")
if r is None:
missing.append(f"{name}/{src}")
continue
if src == "drums":
mono, sr = read(d / f"{src}.wav")
r["kick"] = kick_activity(mono, sr)
if not a.no_global:
r["vs_global"] = compare_global(d / f"{src}.wav",
stems / "global" / f"{src}.wav",
s["start"], s["duration"])
row["stems"][src] = r
report.append(row)
# ---- printed summary: the three questions, in order --------------------
print(f"{'#':>3} {'title':<30} " + " ".join(f"{s[:6]:>7}" for s in SOURCES)
+ f" {'kick%':>6} {'HF4-16k':>8}")
print("-" * 96)
for r in report:
st = r["stems"]
cells = []
for src in SOURCES:
v = st.get(src)
cells.append(f"{v['rms_dbfs']:>7.1f}" if v else f"{'—':>7}")
kick = st.get("drums", {}).get("kick", {}).get("active_pct")
hf = st.get("drums", {}).get("hf_share_4k_16k")
print(f"{r['track']:>3} {r['title'][:30]:<30} " + " ".join(cells)
+ f" {kick if kick is not None else '—':>6} {hf if hf is not None else '—':>8}")
print("\n--- Q1 · is the 8-16 kHz deficit CYMBALS or CODEC NOISE? ---")
agg = {}
for src in SOURCES:
vals = [r["stems"][src]["bands"]["8000-16000"]
for r in report if src in r["stems"] and r["stems"][src]["present"]]
agg[src] = round(float(np.mean(vals)), 3) if vals else None
print(f" {src:<7} mean 8-16 kHz share of its own energy: {agg[src]}%")
known = {k: v for k, v in agg.items() if v is not None}
if known:
top = max(known, key=known.get)
spread = max(known.values()) / max(min(known.values()), 1e-6)
print(f" -> concentrated in '{top}' by {spread:.1f}x. "
+ ("Cymbals: the air shelf can open up."
if top == "drums" and spread >= 2
else "NOT drum-dominated — treat the HF as noise and keep the shelf shut."))
print("\n--- Q2 · kick presence per track (40-120 Hz active time) ---")
ks = [(r["track"], r["title"], r["stems"]["drums"]["kick"]["active_pct"])
for r in report if "drums" in r["stems"] and "kick" in r["stems"]["drums"]]
if ks:
print(f" mean {np.mean([k[2] for k in ks]):.1f}% · "
f"worst: " + ", ".join(f"#{t} {ti[:18]} {p}%"
for t, ti, p in sorted(ks, key=lambda x: x[2])[:3]))
if not a.no_global:
print("\n--- Q3 · local vs global separation (same span, two normalisations) ---")
for src in SOURCES:
cs = [r["stems"][src]["vs_global"] for r in report
if src in r["stems"] and r["stems"][src].get("vs_global")]
if cs:
print(f" {src:<7} corr {np.mean([c['corr'] for c in cs]):.4f} "
f"· level delta {np.mean([c['rms_delta_db'] for c in cs]):+.2f} dB "
f"(n={len(cs)})")
if missing:
print(f"\n⚠ {len(missing)} stem file(s) missing — job may still be running")
Path(out).write_text(json.dumps({"gig": spec["gig"], "sections": report,
"hf_by_stem": agg, "missing": missing},
indent=1) + "\n")
print(f"\n✓ {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# finish_cosmic — everything downstream of the masters, unattended.
#
# PLN went to bed with "your autonomous now going to bed tell me results tomo".
# The master and demucs runs are systemd units so they outlive the session, but
# the steps AFTER them were sitting in my session — which means a teardown would
# have left him with rendered masters and nothing else. So the rest is a unit
# too: it waits for the masters, splits the album, runs the stem EDA, and leaves
# a summary on disk that stands on its own.
#
# Ordered after cosmic-master.service in the unit file, so systemd does the
# waiting rather than a polling loop.
set -uo pipefail
TT=/home/pln/Work/Sound/Tidal/armada/tide-table
SPEC=$TT/judge_specs/cosmicfest-2026.json
ROOT=/home/pln/Work/Sound/Prod/Cosmic26_master
PY=/home/pln/Work/Sound/tidal-ears/.venv/bin/python
SUM=$ROOT/FINISH_SUMMARY.txt
exec > >(tee -a "$SUM") 2>&1
echo "=============================================================="
echo "finish_cosmic $(date -Is)"
echo "=============================================================="
if [ ! -f "$ROOT/Cosmic26_v1_streaming.flac" ]; then
echo "FATAL: no streaming master — the master unit did not produce one."
exit 1
fi
echo
echo "--- masters on disk ---"
for f in "$ROOT"/Cosmic26_v1_*.flac; do
[ -e "$f" ] || continue
printf '%s %s ' "$(basename "$f")" "$(du -h "$f" | cut -f1)"
ffprobe -v error -show_entries format=duration -of csv=p=0 "$f"
done
# The club variant may legitimately be absent: its loudness target can be
# unreachable on this source, and master_stemless reports the miss rather than
# clipping to a number. Split whatever actually exists.
echo
echo "--- split + verify ---"
"$PY" "$TT/render_release.py" "$SPEC" --only split
"$PY" "$TT/render_release.py" "$SPEC" --only verify
echo
echo "--- stem EDA (needs the demucs unit to have finished) ---"
if [ -d "$ROOT/stems_demucs/global" ] && [ -f "$ROOT/stems_demucs/global/drums.wav" ]; then
"$PY" "$TT/eda_stems.py" "$SPEC"
else
echo "global stems absent — running the section-only EDA"
"$PY" "$TT/eda_stems.py" "$SPEC" --no-global
fi
echo
echo "--- what is on disk now ---"
for v in streaming club; do
d="$ROOT/tracks_v1_$v"
[ -d "$d" ] && echo "$v: $(ls "$d"/*.flac 2>/dev/null | wc -l) tracks"
done
echo
echo "finish_cosmic done $(date -Is)"
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