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())
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