\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'
}/{img})
' 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("
xv
")
+ 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('