Commit b8bfec85 by PLN (Algolia)

feat(tide-table): put the machine's labels in front of ears, using the lab that already exists

PLN: "dotn we have a webapp for listening to seams in a set? you could use it for
me to confirm your labels. reusable tooling pattern."

It does — armada/ui/bounds.html, the Boundary Lab. So this builds no second page.
make_bounds.py generates the document that page already reads, which means the
confirmation loop is the one that already stores calls per gig in localStorage and
already exports boundaries-<gig>.json.

That direction is the point. The three-lens fusion is a machine opinion: 11 of 18
segments have two lenses behind them, 4 are honestly unresolved, and the two rows
that were WRONG were caught by PLN's memory rather than by any score. An analysis
pipeline whose output lands in a report has nowhere to be corrected.

The contract was read from BoundaryLab.tsx, not assumed, and two details would
have been wrong if guessed: marks[].t is in MASTER time (the page subtracts
clipStart itself), and `url` is handed to WaveformPlayer with dur = clipEnd -
clipStart, so it must point at a CLIP of that length rather than at the whole
recording. Mark keys are colour-coded by the page, so the segments where a lens
OVERRODE the embedding get `takeover` — brand magenta, which its own comment calls
"the one to try first" — because that is exactly where an ear call is worth most.

Clips plus a mount rather than the whole file, following audio-mounts.json's own
reasoning: it maps /audio/<prefix>/ for BOTH vite dev and armada/serve.py
deliberately, since "a UI that only works under npm run dev is a demo, not a
tool". A 40s clip also loads instantly on a phone over LAN, which is where this
gets used. Peaks are normalised per clip because these are 40s windows of a room
mic whose level wanders, and a waveform you cannot see is not a waveform.

Verified rather than asserted: /bounds.html 200, the document 200 at 190 KB, a
clip 200 at 641 KB, and a Range request returns 206 with exactly 1024 bytes — so
seeking works, which is the whole point of auditioning on a phone.
parent 4e9da249
#!/usr/bin/env python3
r"""make_bounds — turn machine-labelled segments into a Boundary Lab document.
PLN: "dotn we have a webapp for listening to seams in a set? you could use it for
me to confirm your labels. reusable tooling pattern."
Yes — `armada/ui/bounds.html` (BoundaryLab). This does NOT build a second page; it
generates the document that page already reads, so the ear-confirmation loop is the
one that already exists and already exports decisions.
WHY THIS DIRECTION MATTERS
The three-lens fusion is a machine opinion. Eleven of eighteen CosmicFest
segments have two lenses behind them, four are honestly unresolved, and the two
that were WRONG were caught by PLN's memory, not by any score. So the output of
an analysis pipeline should land in front of ears, not in a report — and the
Boundary Lab already does exactly that, storing calls per gig in localStorage
and exporting `boundaries-<gig>.json`.
THE CONTRACT (read from armada/ui/src/bounds/BoundaryLab.tsx, not assumed)
The page fetches `/bounds-<gig>.json`, so the document goes in
`armada/ui/public/`. Each boundary needs:
track, title (incoming), fromTitle (outgoing), nominal (master time),
clipStart, clipEnd, url, peaks[], marks[{key,label,t}], settled,
noCandidate, note
`marks[].t` is in MASTER time — the page subtracts clipStart itself.
Mark keys are colour-coded by the page: `takeover` is brand magenta ("the one
to try first"), `ear` green, `mid`/`onset` blue, `alt` dim green.
`url` is handed to WaveformPlayer with dur = clipEnd - clipStart, so it must
point at a CLIP of that length, not at the whole recording.
WHY CLIPS AND A MOUNT, RATHER THAN THE WHOLE FILE
`audio-mounts.json` maps `/audio/<prefix>/...` to disk for BOTH vite dev and
armada/serve.py, deliberately, so the SPA's URLs are identical in both — "a UI
that only works under npm run dev is a demo, not a tool". We add one mount and
write clips into it. A 40 s clip also loads instantly on a phone over LAN,
which is where this actually gets used.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import subprocess
import sys
import numpy as np
REPO = pathlib.Path("/home/pln/Work/Sound/Tidal")
PUBLIC = REPO / "armada" / "ui" / "public"
MOUNTS = REPO / "armada" / "ui" / "audio-mounts.json"
N_PEAKS = 1400
def probe_dur(path: pathlib.Path) -> float:
return float(subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", str(path)], capture_output=True, text=True).stdout.strip())
def cut(src: pathlib.Path, dst: pathlib.Path, start: float, dur: float) -> bool:
dst.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", f"{start:.3f}", "-t", f"{dur:.3f}", "-i", str(src),
"-ac", "2", "-b:a", "128k", str(dst)],
capture_output=True, text=True)
return r.returncode == 0 and dst.exists()
def peaks_of(path: pathlib.Path, n: int = N_PEAKS) -> list[float]:
"""Abs-max envelope, n points, normalised to the clip's own peak.
Per-clip normalisation on purpose: these are 40 s windows of a room mic whose
level wanders, and a waveform you cannot see is not a waveform.
"""
import soundfile as sf # noqa: PLC0415
y, _ = sf.read(str(path), dtype="float32", always_2d=True)
y = y.mean(axis=1)
if len(y) < n:
return [0.0] * n
step = len(y) // n
env = np.abs(y[: step * n].reshape(n, step)).max(axis=1)
m = float(env.max()) or 1.0
return [round(float(v / m), 4) for v in env]
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="make_bounds", description=__doc__.splitlines()[0])
ap.add_argument("recording")
ap.add_argument("--fused", default=str(REPO / "armada/tide-table/cosmicfest26_fused.json"))
ap.add_argument("--gig", default="cosmicfest-2026")
ap.add_argument("--title", default="CosmicFest 2026 — confirm the machine's labels")
ap.add_argument("--mount", default="cosmic")
ap.add_argument("--pad", type=float, default=20.0)
ap.add_argument("--outdir", default=str(REPO / "armada/tide-table/cosmicfest_bounds"))
a = ap.parse_args(argv)
rec = pathlib.Path(a.recording)
segs = json.load(open(a.fused))
outdir = pathlib.Path(a.outdir)
dur = probe_dur(rec)
print(f"{rec.name}: {dur/60:.1f} min · {len(segs)} segments · pad {a.pad}s")
bounds = []
for i, s in enumerate(segs):
nominal = float(s["start"])
if nominal <= 1.0:
continue # the set's start is not a seam
cs = max(0.0, nominal - a.pad)
ce = min(dur, nominal + a.pad)
clip = outdir / f"{i:02d}.mp3"
if not clip.exists() and not cut(rec, clip, cs, ce - cs):
print(f" ! ffmpeg failed for {clip.name}, skipping")
continue
prev = segs[i - 1] if i else None
verdict = s.get("verdict")
marks = [{"key": "mid", "label": "seg mid",
"t": round((s["start"] + s["end"]) / 2, 2)}]
# The lens that disagreed is worth SEEING on the waveform, because that is
# exactly where an ear call is most valuable.
ev = "; ".join(s.get("evidence", []))
if "OVERRIDE" in ev:
marks.append({"key": "takeover", "label": "lens override here",
"t": round(nominal, 2)})
note = ev or ""
if s.get("kick_bpm"):
note = f"kick {s['kick_bpm']:.1f} BPM · " + note
bounds.append({
"track": i,
"title": verdict or "UNRESOLVED",
"fromTitle": (prev.get("verdict") or "UNRESOLVED") if prev else "start",
"nominal": round(nominal, 2),
"clipStart": round(cs, 2),
"clipEnd": round(ce, 2),
"url": f"/audio/{a.mount}/{clip.name}",
"peaks": peaks_of(clip),
"marks": marks,
# `settled` is the page's "already decided" flag. Nothing here is
# settled: that is the entire reason the document exists.
"settled": False,
"noCandidate": verdict is None,
"note": note[:400],
})
print(f" {i:02d} {nominal/60:5.1f}m {bounds[-1]['fromTitle'][:18]:<20}"
f" -> {bounds[-1]['title'][:20]:<22} {clip.name}")
doc = {"gig": a.gig, "title": a.title, "generated": True,
"pad": a.pad, "boundaries": bounds}
dst = PUBLIC / f"bounds-{a.gig}.json"
dst.write_text(json.dumps(doc))
print(f"\nwrote {dst.relative_to(REPO)} ({dst.stat().st_size/1024:.0f} KB, "
f"{len(bounds)} seams)")
# Register the mount, idempotently, so the same URLs work in dev and prod.
m = json.loads(MOUNTS.read_text())
if m["mounts"].get(a.mount) != str(outdir):
m["mounts"][a.mount] = str(outdir)
MOUNTS.write_text(json.dumps(m, indent=2, ensure_ascii=False) + "\n")
print(f"registered mount '{a.mount}' -> {outdir}")
else:
print(f"mount '{a.mount}' already registered")
print(f"\nAudition: python3 armada/serve.py --dir armada/ui/dist --port 8742")
print(f" then open /bounds.html?gig={a.gig}")
print("Your calls export as boundaries-%s.json from the page." % a.gig)
return 0
if __name__ == "__main__":
sys.exit(main())
...@@ -2,16 +2,16 @@ ...@@ -2,16 +2,16 @@
"_comment": [ "_comment": [
"Where /audio/<prefix>/... resolves to on disk. Read by vite.config.ts in dev", "Where /audio/<prefix>/... resolves to on disk. Read by vite.config.ts in dev",
"and by armada/serve.py in production, so the SPA's URLs are identical in both", "and by armada/serve.py in production, so the SPA's URLs are identical in both",
"\u2014 a UI that only works under `npm run dev` is a demo, not a tool.", " a UI that only works under `npm run dev` is a demo, not a tool.",
"", "",
"Masters live outside the repo (they are gigabytes). Mounting them by prefix", "Masters live outside the repo (they are gigabytes). Mounting them by prefix",
"keeps the URLs stable while the paths move between gigs, and keeps `heads`", "keeps the URLs stable while the paths move between gigs, and keeps `heads`",
"and `full` apart \u2014 `master split` names both '01 - Title.flac', so a single", "and `full` apart `master split` names both '01 - Title.flac', so a single",
"flat mount would serve the 98 MB track when the UI asked for the 16 s head.", "flat mount would serve the 98 MB track when the UI asked for the 16 s head.",
"", "",
"'samples' is the whole custom sample root, not just one pack, so the kit", "'samples' is the whole custom sample root, not just one pack, so the kit",
"auditioner works on every kit PLN owns \u2014 the 163 already there and every", "auditioner works on every kit PLN owns the 163 already there and every",
"one the Foundry cuts next \u2014 without a config change per pack." "one the Foundry cuts next without a config change per pack."
], ],
"mounts": { "mounts": {
"bounds": "/home/pln/Work/Sound/Prod/Opal26_master/bounds_v4", "bounds": "/home/pln/Work/Sound/Prod/Opal26_master/bounds_v4",
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4", "heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"joins": "/home/pln/Work/Sound/Prod/Opal26_master/joins_v4", "joins": "/home/pln/Work/Sound/Prod/Opal26_master/joins_v4",
"samples": "/home/pln/Work/Sound/Samples", "samples": "/home/pln/Work/Sound/Samples",
"": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien" "": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien",
"cosmic": "/home/pln/Work/Sound/Tidal/armada/tide-table/cosmicfest_bounds"
} }
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
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