From f00abc1957877e8fe465268296c159fdbdfd0394 Mon Sep 17 00:00:00 2001 From: Aqua Date: Tue, 10 Nov 2026 18:15:45 +0000 Subject: [PATCH] publish-stable: one script publishes the Dings and aqua-ear pages to one URL each /aqua/dings/ and /aqua/ear/ are replaced in place (staging dir renamed over the old one, so a failed or repeated run leaves a whole page); `banners` marks every older per-hash page with a pointer and deletes nothing. Six stdlib tests: idempotence, replacement, refusal of a non-page, failed publish keeps the old page, banners added once without touching content, ear index. Co-Authored-By: Claude Sonnet 5.5 --- tools/aqua-ear/README.md | 2 + tools/dings-voices/README.md | 11 ++ tools/publish-stable/publish_stable.py | 137 ++++++++++++++++++++ tools/publish-stable/test_publish_stable.py | 40 ++++++ 4 files changed, 190 insertions(+) create mode 100644 tools/publish-stable/publish_stable.py create mode 100644 tools/publish-stable/test_publish_stable.py diff --git a/tools/aqua-ear/README.md b/tools/aqua-ear/README.md index ccff787..eee85fc 100644 --- a/tools/aqua-ear/README.md +++ b/tools/aqua-ear/README.md @@ -81,3 +81,5 @@ clips, not taste. Live: everything above. Not built: per-onset spectral-flux detector for noise-on-noise, an audibility model, batch mode over a directory, `--strict` per-detector selection. None of these is needed by a current consumer. + +Examples are published to one place, https://minds.gamecult.org/aqua/ear/, with `python3 tools/publish-stable/publish_stable.py ear ` (see tools/dings-voices/README.md, Publishing). diff --git a/tools/dings-voices/README.md b/tools/dings-voices/README.md index b31cb85..a3518f8 100644 --- a/tools/dings-voices/README.md +++ b/tools/dings-voices/README.md @@ -12,3 +12,14 @@ Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void, - Not here: the rendered clips and the listening page. A staged copy, with the README of its measurements, is at `/opt/commons/handoff/aqua-dings-voices-20261110` on Yggdrasil; the neutral fallback files come from `/opt/commons/handoff/aqua-dings-ratatoskr-b3eb9d01/out/neutral`. Run: `node src/polyphony.test.mjs && node src/build.mjs raw && python src/finish.py raw out && python src/verify.py .` (`finish.py` expects `out/neutral/*.ogg` to exist for the neutral comparison). + +## Publishing (one place) + +One dev, one URL: the listening page is **https://minds.gamecult.org/aqua/dings/** (`/srv/minds-web/aqua/dings/`), always the latest version, overwritten in place. Do not publish per-hash pages any more. After building a publish dir (the `Run` line, then `gen_page.py`), run + +``` +python3 tools/publish-stable/publish_stable.py dings # idempotent; replaces the stable dir by renaming a finished staging copy +python3 tools/publish-stable/publish_stable.py banners # old per-version pages get a pointer banner; deletes nothing +``` + +The script stamps a banner and a `VERSION` file (publish dir name and AquaSynth revision) into the stable copy. The aqua-ear examples are published the same way to `/srv/minds-web/aqua/ear/` (`publish_stable.py ear `). Tests: `python3 tools/publish-stable/test_publish_stable.py`. Nobody has opened these pages in a browser; every link and audio file on them was checked with curl over https. diff --git a/tools/publish-stable/publish_stable.py b/tools/publish-stable/publish_stable.py new file mode 100644 index 0000000..7563b1a --- /dev/null +++ b/tools/publish-stable/publish_stable.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""The one script that publishes AquaSynth listening pages: one dev, one URL, overwritten in place. + + publish_stable.py dings SRC_DIR -> /srv/minds-web/aqua/dings/ (https://minds.gamecult.org/aqua/dings/) + publish_stable.py ear SRC_DIR -> /srv/minds-web/aqua/ear/ (https://minds.gamecult.org/aqua/ear/) + publish_stable.py banners -> banner on every older per-hash page, pointing at the stable pages + +SRC_DIR for `dings` is a built publish dir (index.html, out/, findings/, metrics.json; see tools/dings-voices/README.md); +for `ear` a dir of aqua-ear outputs (one subdir per run, each with report.txt/png files, as `aqua_ear.py --out` writes). +Stdlib only. Idempotent: running it again with the same input leaves the same tree; the stable dir is replaced by renaming +a fully written staging dir, so a reader never sees a half-copied page. Deletes nothing outside the stable dir it owns. +--root and --base are for tests. Nothing here listens to anything: it moves files and stamps a header. +""" +import argparse, html, os, re, shutil, subprocess, sys, tempfile + +ROOT = "/srv/minds-web/aqua" +BASE = "https://minds.gamecult.org/aqua" +STABLE = {"dings": "dings", "ear": "ear"} +MARK = "aqua-stable-banner" +BANNER_CSS = "background:#fff3c4;color:#222;border:2px solid #b58900;padding:.6rem 1rem;margin:0;font:1rem/1.4 system-ui,sans-serif" + + +def rev(): + try: + return subprocess.check_output(["git", "-C", os.path.dirname(os.path.abspath(__file__)), "rev-parse", "--short", "HEAD"], text=True).strip() + except Exception: + return "unknown" + + +def banner(inner): + return f'
{inner}
\n' + + +def put_banner(path, inner): + """Insert (or replace) the marked banner right after , else after , else at the top. Idempotent.""" + s = open(path, encoding="utf-8").read() + s = re.sub(rf'
\n?', "", s, flags=re.S) + b = banner(inner) + m = re.search(r"]*>", s, re.I) or re.search(r"", s, re.I) + s = s[:m.end()] + "\n" + b + s[m.end():] if m else b + s + open(path, "w", encoding="utf-8").write(s) + + +def swap_in(src, dest, prepare): + """Copy src to a staging dir beside dest, let prepare(stage) stamp it, then replace dest by rename.""" + parent = os.path.dirname(dest) + os.makedirs(parent, exist_ok=True) + stage = tempfile.mkdtemp(prefix=".stage-", dir=parent) + try: + shutil.copytree(src, stage, dirs_exist_ok=True) + prepare(stage) + os.chmod(stage, 0o755) + old = dest + ".old" + if os.path.exists(dest): + shutil.rmtree(old, ignore_errors=True) + os.rename(dest, old) + os.rename(stage, dest) + shutil.rmtree(old, ignore_errors=True) + except BaseException: + shutil.rmtree(stage, ignore_errors=True) + raise + + +def publish_dings(src, root, base): + if not os.path.isfile(os.path.join(src, "index.html")) or not os.path.isdir(os.path.join(src, "out", "senders")): + sys.exit(f"{src} is not a built dings publish dir (needs index.html and out/senders)") + label = os.path.basename(os.path.abspath(src)) + note = (f"The one Dings page. {base}/dings/ always shows the latest version; it is overwritten in place. " + f"Current: {html.escape(label)}, AquaSynth {rev()}. Older per-version pages are kept but are not the place to listen.") + + def prepare(stage): + put_banner(os.path.join(stage, "index.html"), note) + open(os.path.join(stage, "VERSION"), "w").write(f"{label}\nAquaSynth {rev()}\n") + swap_in(src, os.path.join(root, STABLE["dings"]), prepare) + + +def publish_ear(src, root, base): + runs = sorted(d for d in os.listdir(src) if os.path.isdir(os.path.join(src, d))) + if not runs: + sys.exit(f"{src} has no run subdirectories") + + def prepare(stage): + items = [] + for r in runs: + files = sorted(f for f in os.listdir(os.path.join(stage, r)) if os.path.isfile(os.path.join(stage, r, f))) + links = " ยท ".join(f'{html.escape(f)}' for f in files) + img = next((f for f in ("compare.png", "spectrogram.png") if f in files), None) + pic = f'

{html.escape(r)}

' if img else "" + items.append(f"

{html.escape(r)}

{pic}

{links}

") + page = ('' + "aqua-ear examples" + "

aqua-ear examples

Pictures and reports from tools/aqua-ear (spectrogram, onsets, level envelope, detector verdicts). " + "They are signal facts: the tool cannot judge timbre, annoyance or feel, and nobody has judged these by ear. " + f"Current as of AquaSynth {rev()}.

" + "".join(items) + "\n") + open(os.path.join(stage, "index.html"), "w").write(page) + put_banner(os.path.join(stage, "index.html"), f"The one aqua-ear examples page. {base}/ear/ is overwritten in place.") + swap_in(src, os.path.join(root, STABLE["ear"]), prepare) + + +def banners(root, base): + skip = set(STABLE.values()) + for d in sorted(os.listdir(root)): + p = os.path.join(root, d) + if d in skip or d.startswith(".") or not os.path.isdir(p): + continue + dings = d.startswith("dings-voices-") + inner = (f"Old per-version page. The one Dings page is {base}/dings/ (always the latest); listen there." + if dings else + f"Old per-version page, kept for the record. One-place rule: current Dings are at {base}/dings/, " + f"aqua-ear examples at {base}/ear/.") + idx = os.path.join(p, "index.html") + if not os.path.exists(idx): # a dir with no page of its own gets a minimal one, so the banner has somewhere to stand + files = sorted(os.listdir(p)) + open(idx, "w").write('' + html.escape(d) + "

" + html.escape(d) + + "

\n") + put_banner(idx, inner) + print("banner:", idx) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("what", choices=["dings", "ear", "banners"]) + ap.add_argument("src", nargs="?") + ap.add_argument("--root", default=ROOT) + ap.add_argument("--base", default=BASE) + a = ap.parse_args() + if a.what == "banners": + banners(a.root, a.base) + return + if not a.src or not os.path.isdir(a.src): + sys.exit("SRC_DIR is required and must be a directory") + {"dings": publish_dings, "ear": publish_ear}[a.what](a.src, a.root, a.base) + print(f"published {a.what}: {a.root}/{STABLE[a.what]}/ -> {a.base}/{STABLE[a.what]}/") + + +if __name__ == "__main__": + main() diff --git a/tools/publish-stable/test_publish_stable.py b/tools/publish-stable/test_publish_stable.py new file mode 100644 index 0000000..a69d74a --- /dev/null +++ b/tools/publish-stable/test_publish_stable.py @@ -0,0 +1,40 @@ +import os, subprocess, sys, tempfile, unittest +HERE = os.path.dirname(os.path.abspath(__file__)); S = os.path.join(HERE, "publish_stable.py") +def run(*a): return subprocess.run([sys.executable, S, *a], capture_output=True, text=True) +def tree(root): return sorted((os.path.relpath(os.path.join(d, f), root), open(os.path.join(d, f), "rb").read()) for d, _, fs in os.walk(root) for f in fs) +class T(unittest.TestCase): + def setUp(self): + self.t = tempfile.TemporaryDirectory(); self.root = os.path.join(self.t.name, "aqua"); os.makedirs(self.root) + self.src = os.path.join(self.t.name, "dings-voices-abc1234"); os.makedirs(self.src + "/out/senders") + open(self.src + "/index.html", "w").write("x

v

") + open(self.src + "/out/senders/a.ogg", "wb").write(b"OggS") + def tearDown(self): self.t.cleanup() + def test_publish_overwrites_in_place_and_is_idempotent(self): + r = run("dings", self.src, "--root", self.root); self.assertEqual(r.returncode, 0, r.stderr) + first = tree(self.root + "/dings"); r = run("dings", self.src, "--root", self.root); self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(first, tree(self.root + "/dings")) + page = open(self.root + "/dings/index.html").read(); self.assertEqual(page.count('

old

"), ("trial-1", "

no body tag

")): + os.makedirs(f"{self.root}/{d}"); open(f"{self.root}/{d}/index.html", "w").write(body) + os.makedirs(self.root + "/aqua-ear-examples"); open(self.root + "/aqua-ear-examples/x.png", "wb").write(b"p") + for _ in range(2): self.assertEqual(run("banners", "--root", self.root).returncode, 0) + for d in ("dings-voices-old", "trial-1", "aqua-ear-examples"): + p = open(f"{self.root}/{d}/index.html").read(); self.assertEqual(p.count('