From 797d67fc7c3d2bf268a887bff814ab84402369ab Mon Sep 17 00:00:00 2001 From: Aqua Date: Tue, 10 Nov 2026 18:32:34 +0000 Subject: [PATCH 1/2] sound-guard: one command that checks every shipped Dings and Earcon clip with aqua-ear Renders (or takes) the 32 sender and 4 neutral Dings clips and the 24 Earcon Workshop clips and asserts, per clip: one onset per intended note on the full and high bands, no click/step/DC, no clipping, true peak under a ceiling, settled tails, loudness within a band of the median. Thresholds live in thresholds.json with a reason each; aqua-ear's own boundaries are pinned. The four neutral fallbacks, 4 to 14 dB louder than the sender clips, are recorded as expiring known failures. Tests mutate generated clips (v3 knock, clipped tail, click, step, cut, DC, outlier, echo, late note, ring-over) and require the intended assertion to fail. Co-Authored-By: Claude Sonnet 5.5 --- tools/dings-voices/README.md | 2 + tools/sound-guard/README.md | 66 ++++++ tools/sound-guard/guard.py | 366 ++++++++++++++++++++++++++++++ tools/sound-guard/test_guard.py | 216 ++++++++++++++++++ tools/sound-guard/thresholds.json | 187 +++++++++++++++ 5 files changed, 837 insertions(+) create mode 100644 tools/sound-guard/README.md create mode 100644 tools/sound-guard/guard.py create mode 100644 tools/sound-guard/test_guard.py create mode 100644 tools/sound-guard/thresholds.json diff --git a/tools/dings-voices/README.md b/tools/dings-voices/README.md index a3518f8..0a71887 100644 --- a/tools/dings-voices/README.md +++ b/tools/dings-voices/README.md @@ -11,6 +11,8 @@ Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void, - v4: Void has no tick (it was a second hit 95 ms into every note) and a note something follows ends by the next onset; `polyphony.test.mjs` fails if a Void clip has more onsets than notes. Nibu's voice is proposal 3 (the 8 kHz 5-bit sine) for all four events; proposals 1 and 2 stay as alternates under `out/proposals/`, not in the manifest. - 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`. +Sound regression guard over every clip this generator and the Earcon Workshop ship (onsets per note, clicks, clipping, true peak, tails, loudness): `python3 tools/sound-guard/guard.py` (see `../sound-guard/README.md`). + 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) diff --git a/tools/sound-guard/README.md b/tools/sound-guard/README.md new file mode 100644 index 0000000..86f3ad7 --- /dev/null +++ b/tools/sound-guard/README.md @@ -0,0 +1,66 @@ +# sound-guard + +A regression check for the sound itself. One command renders (or is given) every shipped Dings clip (8 sender voices x 4 events, plus the 4 neutral fallbacks) +and every Earcon Workshop clip (4 identities x 6 events), runs `../aqua-ear` over each, and **exits 1 if any assertion fails**. It exists so a double hit, +a click, clipping, a cut tail or a clip that is suddenly louder than its siblings cannot come back unnoticed (the v3 Void knock, 95 ms into every note, +was a second hit that nothing measured until Meta heard it). + +**It measures signal facts. It cannot judge annoyance, timbre or feel.** A clip can pass every check here and still sound bad; a clip can fail one and +sound fine. Nothing in this tool has heard anything. + +## Run + +``` +python3 tools/sound-guard/guard.py # renders both families into a temp dir (about 10 s), checks 60 clips +python3 tools/sound-guard/guard.py --dings DIR --earcon DIR # check existing output (DIR = finish.py's `out`, and a dir of cli.mjs packs, one subdir per identity) +python3 -m unittest -v test_guard # in tools/sound-guard: the guard's own tests, ~3 s +``` + +`python3` must have numpy, scipy and soundfile (libsndfile with Vorbis), as for aqua-ear; `node` renders and reads the intended note times. This sandbox: +`/var/lib/mind-aqua-self/ogg-venv/bin/python`. Self-render needs the neutral fallback clips (`finish.py` wants `out/neutral/*.ogg`): default +`config.neutral_source` in `thresholds.json`, or `--neutral DIR`. Exit 0 = pass, 1 = failed assertion or stale known failure, 2 = tool error (render failed, +aqua-ear's thresholds drifted from the pins, node missing). + +## What is asserted, per clip + +| # | assertion | how | +|---|---|---| +| 1 | one onset per intended note | Intended times: `RHYTHM` in `dings-voices/src/voices.mjs`; `EVENTS` x tempo in `earcon-workshop/web/earcon.js` (both machine readable, read through node). Each note owns the window [t - 20, t + 20] ms. Full band: each window has an onset, the loudest starts within 8 ms of t, an onset before it is a pre-echo only if it is 10 dB or more under it, an onset after it in the window is a double hit, any onset outside every window is stray. High band (>2 kHz): no onset outside any window. Kinds: `missing timing double stray stray_hf` | +| 2 | no click, step or DC | aqua-ear `click_step_dc`: discontinuities on the clip with 20 ms of silence in front (so a non-zero first sample counts), DC on the clip | +| 3 | no clipped samples | aqua-ear `clipping_true_peak` (overs, plateaus at >= 0.99), plus a plateau detector relative to the clip's own peak (WAV only) | +| 4 | true peak under a ceiling | measured on the decoded file; Dings -8 dBTP, Earcon and neutral -2.5 dBTP | +| 5 | tail settled | `abrupt_end` clear on every clip; for the voices built to end by the next onset (Kiko, Libby, Void, Nibu) `overlapping_tails` clear at each following note. Other voices and all earcons ring over the next note by design and are not asserted | +| 6 | loudness spread | LAE (A-weighted exposure, the quantity `finish.py` equalises) of every clip within +-0.5 dB of the median of the 32 sender clips | + +Not asserted: Earcon loudness (the events differ on purpose), anything about the 12 non-shipped Nibu proposal clips. + +## Thresholds: `thresholds.json` + +Every number the guard compares against is in that one file, each with a `why`. `guard.py` has no magic numbers (a test parses its source to prove it) and +`test_guard.py` fails if a threshold has no reason or the code reads a name the file lacks. aqua-ear's own detector boundaries stay in `aqua_ear.py`, so +that file owns them; the guard **pins** the ones it relies on (`aqua_ear_pins`) and refuses to run (exit 2) if they drift, so loosening aqua-ear cannot +loosen the guard silently. Change a number by editing its value and its `why` together. + +Voice policy (also in the file): `swell_voices` (Eureka, Heimdall: slow attack by design, so the full-band count is not asserted), `settle_before_next_voices`, +and `neutral_events` (the neutral clips are Earcon Workshop warm-bell renders; their note times are inferred from the audio). + +## Known failures + +`known_failures` in `thresholds.json` lists clips that fail today, each with the assertion, the measured number, a slack and a reason. The guard prints them on every +run. If one stops failing the run fails (`STALE`: remove the entry); if its number moves by more than the slack the run fails until the entry is re-justified. Today: +the four neutral fallback clips are 4.3 to 13.7 dB louder than the sender clips (assertion 6). Everything else passes. + +## What the guard cannot see (measured, not guessed) + +- Two hits of the same timbre inside one rising attack, or a flam under about 25 ms whose second hit adds less than 4 dB: the onset detector needs a 4 dB rise. + Depends on phase: a copy of a Void note 15 ms later was caught (10 ms was not); the same copy 10 to 20 ms later was missed on Kiko and Weksa. A spectral-flux detector (listed as not built in aqua-ear) would close it. +- The v3 knock: caught down to 20 dB under its v3 level on every Void motif tried; at 22 dB under only on the loudest clip. Below about -76 dBFS in the high band the + legitimate clips (Aqua's glide, Heimdall's swell) have their own rises, so the floor cannot go lower without false alarms. +- Eureka and Heimdall full-band onsets (swell voices): only high-band strays are asserted for them. +- Flat-topped limiting below 0.98 of the clip's peak on a decoded Ogg (Vorbis rounds the plateau off); a WAV shows it. +- Anything under -80 dBFS in the high band, and everything about how the sound feels. + +aqua-ear gaps the guard works around, found while building it: it misses an onset that is already at full level at sample 0 (so the guard pads 20 ms of silence); it counts +the envelope ripple of low harmonic tones as onsets (so the guard peak-holds the envelope 10 ms; Heimdall gave 50 to 90 onsets a clip); and its `double_hit`/`overlapping_tails` +verdicts fire on 13 of the 32 sender clips (Vorbis pre-echo 12 to 15 ms ahead of an attack, Nibu's 8 kHz grain, ring-over voices), which are not defects. The guard does its own note-aware +check on top of aqua-ear's onset table. These could move into aqua-ear; they were not changed there. diff --git a/tools/sound-guard/guard.py b/tools/sound-guard/guard.py new file mode 100644 index 0000000..0e93044 --- /dev/null +++ b/tools/sound-guard/guard.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""sound-guard: one command that renders (or is given) every shipped Dings clip and every Earcon Workshop clip and checks each one with +tools/aqua-ear. Exit 0 = every assertion holds (known failures aside), 1 = something failed or a known failure went stale, 2 = tool error. +It measures signal facts. It cannot judge annoyance, timbre or feel. See README.md. Every number it compares against is in thresholds.json.""" +import argparse, json, os, shutil, subprocess, sys, tempfile +sys.dont_write_bytecode = True # the guard imports from sibling tools; it must not leave __pycache__ in their trees +from pathlib import Path + +HERE = Path(__file__).resolve().parent +TOOLS = HERE.parent +sys.path.insert(0, str(TOOLS / "aqua-ear")) +sys.path.insert(0, str(TOOLS / "dings-voices" / "src")) +import numpy as np +import scipy.signal as ss +from scipy.ndimage import maximum_filter1d +import aqua_ear as ae +from metriclib import lae + +MS_PER_S = 1000 +SHIPPED_RATE = 48000 # Hz: every shipped clip (finish.py asserts it too) +NODE_TIMEOUT_S = 60 +RENDER_TIMEOUT_S = 240 +STDERR_TAIL = 800 +HF_ORDER = 4 # Butterworth order of aqua-ear's high-band channel (aqua_ear.analyze); repeated here because the guard holds the envelope before detecting +SILENT_PEAK = 1e-6 # a clip whose peak is below this is silent: no onsets to look for +SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"] +MOTIFS = ["mention", "dm", "message", "invite"] + + +class GuardError(Exception): + """Tool error (exit 2): the guard could not do its job, which is different from a clip failing.""" + + +def load_thresholds(path=None): + d = json.loads(Path(path or HERE / "thresholds.json").read_text()) + return d + + +def tv(th, name): + return th["thresholds"][name]["value"] + + +def pol(th, name): + return th["voice_policy"][name]["value"] + + +def check_pins(th): + """aqua-ear owns its detector boundaries; the guard pins the ones it relies on so a loosening there cannot loosen the guard silently.""" + bad = [f"{k}: aqua_ear.TH={ae.TH.get(k)!r}, pinned {p['value']!r}" for k, p in th["aqua_ear_pins"].items() if ae.TH.get(k) != p["value"]] + if bad: + raise GuardError("aqua-ear thresholds differ from tools/sound-guard/thresholds.json aqua_ear_pins (re-justify the pin or revert aqua-ear): " + "; ".join(bad)) + + +# ---- one failure -------------------------------------------------------------------------------------------------------- +class Fail: + def __init__(self, assertion, kind, value, msg): + self.assertion, self.kind, self.value, self.msg = assertion, kind, float(value), msg + + def __repr__(self): + return f"{self.assertion}/{self.kind}={self.value:g}: {self.msg}" + + +# ---- measurement ---------------------------------------------------------------------------------------------------------- +def hold(env, n): + """Peak-hold over the last n ms of a 1 ms envelope: rising edges are untouched, valleys shorter than n are filled.""" + return maximum_filter1d(env, size=n, origin=(n - 1) // 2) + + +def onset_rows(x, sr, th): + """Onset rows (aqua-ear's detector and table) on the held full-band and held high-band envelopes of x preceded by pad_ms of silence. + Times are in PADDED ms. Returns (rows, hf_rows, raw_env_full).""" + pad, n = tv(th, "pad_ms"), tv(th, "hold_ms") + xp = np.concatenate([np.zeros(int(sr * pad / MS_PER_S)), x]) + peak = float(np.max(np.abs(x))) if len(x) else 0.0 + if peak <= SILENT_PEAK: + return [], [], ae.envelope_1ms(xp, sr) + raw = ae.envelope_1ms(xp, sr) + h = hold(raw, n) + rows = ae.onset_table(h, ae.detect_onsets(h, float(h.max())), "full") + hf_rows = [] + sos = ss.butter(HF_ORDER, ae.TH["hf_cut_hz"], "highpass", fs=sr, output="sos") + eh = hold(ae.envelope_1ms(ss.sosfiltfilt(sos, xp), sr), n) + floor = tv(th, "hf_floor_dbfs") + if eh.max() > floor: + hf_rows = ae.onset_table(eh, ae.detect_onsets(eh, float(eh.max()), floor), "hf") + return rows, hf_rows, raw + + +def check_onsets(rows, hf_rows, notes_ms, swell, th): + """Assertion 1. Each intended note t owns the window [t - pre, t + post]. Full band (not for swell voices): every window has an onset; + its loudest onset is the note and starts within main_timing_ms of t; an onset before it is a precursor (pre-echo) only if precursor_db under it; + any onset after it in the window is a double hit; any onset outside every window is stray. High band: only strays outside every window.""" + pad, pre, post = tv(th, "pad_ms"), tv(th, "window_pre_ms"), tv(th, "window_post_ms") + wins = [(t + pad - pre, t + pad + post, t + pad) for t in notes_ms] + inside = lambda ms: any(lo <= ms <= hi for lo, hi, _ in wins) + out, mains = [], [] + rel = lambda ms: ms - pad + for lo, hi, t in wins: + w = [r for r in rows if lo <= r["ms"] <= hi] + if not w: + if not swell: + out.append(Fail("onsets", "missing", 1, f"no full-band onset for the note at {rel(t):g} ms (window {rel(lo):g}..{rel(hi):g} ms)")) + continue + main = max(w, key=lambda r: r["peak_dbfs"]) + mains.append(main) + if swell: + continue + if abs(main["ms"] - t) > tv(th, "main_timing_ms"): + out.append(Fail("onsets", "timing", abs(main["ms"] - t), f"note at {rel(t):g} ms: loudest onset at {rel(main['ms'])} ms, {main['ms'] - t:+g} ms off (limit {tv(th, 'main_timing_ms'):g})")) + for r in w: + if r is main: + continue + if r["ms"] > main["ms"]: + out.append(Fail("onsets", "double", 1, f"second full-band onset at {rel(r['ms'])} ms, {r['ms'] - main['ms']} ms after the note at {rel(main['ms'])} ms ({r['peak_dbfs'] - main['peak_dbfs']:+.1f} dB re it)")) + elif main["peak_dbfs"] - r["peak_dbfs"] < tv(th, "precursor_db"): + out.append(Fail("onsets", "double", 1, f"onset at {rel(r['ms'])} ms before the note at {rel(main['ms'])} ms is only {main['peak_dbfs'] - r['peak_dbfs']:.1f} dB under it (precursor limit {tv(th, 'precursor_db'):g} dB)")) + if not swell: + for r in rows: + if not inside(r["ms"]): + out.append(Fail("onsets", "stray", 1, f"full-band onset at {rel(r['ms'])} ms ({r['peak_dbfs']} dBFS) is not within a note window")) + for r in hf_rows: + if not inside(r["ms"]): + out.append(Fail("onsets", "stray_hf", 1, f"high-band onset at {rel(r['ms'])} ms ({r['peak_dbfs']} dBFS in the >2 kHz band) is not within a note window")) + return out, mains + + +def flat_top_runs(x, th): + peak = float(np.max(np.abs(x))) + hi = np.abs(x) >= tv(th, "flat_top_rel") * peak + eq = np.concatenate([[False], np.abs(np.diff(x)) < tv(th, "flat_top_eps")]) + ok = hi & eq + runs = run = 0 + for i in range(len(x)): + run = run + 1 if ok[i] else 0 + if run == ae.TH["clip_run"] - 1: + runs += 1 + return runs + + +def check_clip(x, sr, notes_ms, policy, th): + """All per-clip assertions. policy: dict(ceiling_dbtp, swell, settle). Returns (failures, facts).""" + f, facts = [], {} + rows, hf_rows, raw = onset_rows(x, sr, th) + of, mains = check_onsets(rows, hf_rows, notes_ms, policy["swell"], th) + f += of + facts["onsets"] = [r["ms"] - tv(th, "pad_ms") for r in rows] + facts["onsets_hf"] = [r["ms"] - tv(th, "pad_ms") for r in hf_rows] + # 2. click / step / DC: aqua-ear's detector. Steps on the padded clip (so a non-zero first sample counts), DC on the clip itself. + xp = np.concatenate([np.zeros(int(sr * tv(th, "pad_ms") / MS_PER_S)), x]) + steps = ae.det_click_dc(xp, sr)["evidence"]["discontinuities"] + dc = ae.det_click_dc(x, sr)["evidence"]["dc"] + for s in steps: + f.append(Fail("click_step_dc", s["kind"], s["jump"], f"{s['kind']} at {s['ms'] - tv(th, 'pad_ms'):g} ms, jump {s['jump']}")) + if abs(dc) >= ae.TH["dc_abs"]: + f.append(Fail("click_step_dc", "dc", abs(dc), f"DC offset {dc:+.5f} (limit {ae.TH['dc_abs']})")) + # 3 and 4. clipping and true peak + c = ae.det_clip(x, sr) + if c["fired"]: + e = c["evidence"] + f.append(Fail("clipping", "clip", e["over_samples"] + e["plateau_runs"], f"{e['over_samples']} sample(s) over 1.0, {e['plateau_runs']} plateau run(s) at >= {ae.TH['clip_level']}, true peak {e['true_peak_dbtp']:.2f} dBTP")) + runs = flat_top_runs(x, th) + if runs: + f.append(Fail("clipping", "flat_top", runs, f"{runs} run(s) of {ae.TH['clip_run']}+ equal samples at >= {tv(th, 'flat_top_rel')} of the clip's own peak")) + tp = c["evidence"]["true_peak_dbtp"] + facts["true_peak_dbtp"] = round(tp, 2) + if tp > policy["ceiling_dbtp"]: + f.append(Fail("true_peak", "ceiling", tp, f"true peak {tp:.2f} dBTP over the {policy['ceiling_dbtp']:g} dBTP ceiling")) + # 5. tail settled at the end; tail settled before the next note for voices built that way + peak = float(np.max(np.abs(x))) + end = ae.det_end(x, sr, peak) + if end["fired"]: + f.append(Fail("tail_end", "abrupt_end", end["evidence"]["drop_db"], end["verdict"].split(" [")[0])) + if policy["settle"] and len(mains) > 1: + ov = ae.det_overlap(raw, mains) + if ov["fired"]: + h = [e for e in ov["evidence"] if e["tail_db_re_prev_peak"] > ae.TH["overlap_tail_db"]] + f.append(Fail("tail_overlap", "overlap", max(e["tail_db_re_prev_peak"] for e in h), ov["verdict"].split(" [")[0])) + return f, facts + + +def loudness_failures(lae_by_clip, th, neutral=()): + """Assertion 6. Median over the sender clips; every clip (neutral too) within loudness_dev_db of it.""" + sender = [v for k, v in lae_by_clip.items() if k not in neutral] + med = float(np.median(sender)) + band = tv(th, "loudness_dev_db") + out = {} + for k, v in lae_by_clip.items(): + if abs(v - med) > band: + out[k] = Fail("loudness", "outlier", abs(v - med), f"LAE {v:.2f} dB is {v - med:+.2f} dB from the sender median {med:.2f} dB (band +-{band:g})") + return out, med + + +# ---- sources: where the clips and the intended note times come from ------------------------------------------------------------ +def node_json(js): + try: + r = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, timeout=NODE_TIMEOUT_S, check=True) + except (OSError, subprocess.SubprocessError) as e: + raise GuardError(f"node is needed for the intended note times (RHYTHM in voices.mjs, EVENTS in earcon.js): {e}") + return json.loads(r.stdout) + + +def intended(): + js = (f'import {{RHYTHM}} from "{(TOOLS / "dings-voices/src/voices.mjs").as_posix()}";' + f'import {{EVENTS, IDENTITIES}} from "{(TOOLS / "earcon-workshop/web/earcon.js").as_posix()}";' + 'console.log(JSON.stringify({dings:Object.fromEntries(Object.entries(RHYTHM).map(([k,v])=>[k,v.map(n=>n[0])])),' + 'earcon:Object.fromEntries(Object.entries(EVENTS).map(([k,v])=>[k,v.notes.map(n=>n[1])])),identities:Object.keys(IDENTITIES)}))') + return node_json(js) + + +def read(path): + x, sr, ch = ae.load(str(path)) + return x, sr + + +def dings_clips(d, th, want): + """Yield (id, path, family, voice, notes_ms). d is the generator's out dir (manifest.json, senders/, neutral/).""" + d = Path(d) + mf = d / "manifest.json" + if not mf.exists(): + raise GuardError(f"{mf} missing: --dings takes the out dir finish.py writes") + man = json.loads(mf.read_text()) + out = [] + for s in SENDERS: + for m in MOTIFS: + rel = man.get("senders", {}).get(s, {}).get(m) + out.append((f"dings/{s}/{m}", d / rel if rel else None, "dings", s, want["dings"][m])) + nev = pol(th, "neutral_events") + for m in MOTIFS: + rel = man.get("neutral", {}).get(m) + out.append((f"dings/neutral/{m}", d / rel if rel else None, "neutral", "neutral", want["earcon"][nev[m]])) + return out + + +def earcon_clips(d, want): + """d holds one pack per identity (//pack.json + wavs, as cli.mjs writes), or is itself one pack.""" + d = Path(d) + packs = {p.parent.name: p.parent for p in d.glob("*/pack.json")} or ({d.name: d} if (d / "pack.json").exists() else {}) + out = [] + for ident in want["identities"]: + pk = packs.get(ident) + pack = json.loads((pk / "pack.json").read_text()) if pk else None + tempo = pack["params"]["tempo"] if pack else 1 + for ev, times in want["earcon"].items(): + file = pack["events"].get(ev, {}).get("file") if pack else None + out.append((f"earcon/{ident}/{ev}", pk / file if file else None, "earcon", ident, [t * tempo for t in times])) + return out + + +def render_all(tmp, neutral_dir): + tmp = Path(tmp) + dv = TOOLS / "dings-voices" / "src" + steps = [(["node", str(dv / "build.mjs"), str(tmp / "dings" / "raw")], None)] + (tmp / "dings" / "out").mkdir(parents=True) + nd = Path(neutral_dir) + if not nd.is_dir(): + raise GuardError(f"neutral fallback clips not found at {nd} (finish.py needs out/neutral/*.ogg); pass --neutral DIR") + shutil.copytree(nd, tmp / "dings" / "out" / "neutral") + steps.append(([sys.executable, str(dv / "finish.py"), str(tmp / "dings" / "raw"), str(tmp / "dings" / "out")], None)) + for ident in intended()["identities"]: + steps.append((["node", str(TOOLS / "earcon-workshop" / "cli.mjs"), ident, str(tmp / "earcon" / ident)], None)) + for cmd, _ in steps: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=RENDER_TIMEOUT_S) + if r.returncode: + raise GuardError(f"render step failed: {' '.join(cmd[:2])}\n{r.stderr[-STDERR_TAIL:]}") + return tmp / "dings" / "out", tmp / "earcon" + + +# ---- run ----------------------------------------------------------------------------------------------------------------------- +def policy_for(family, voice, th): + if family == "dings": + return dict(ceiling_dbtp=tv(th, "ceiling_dings_dbtp"), swell=voice in pol(th, "swell_voices"), settle=voice in pol(th, "settle_before_next_voices")) + return dict(ceiling_dbtp=tv(th, "ceiling_earcon_dbtp"), swell=False, settle=False) # neutral and earcon + + +def run(clips, th): + """clips: [(id, path, family, voice, notes_ms)]. Returns {id: dict(fails=[Fail], facts)} with loudness folded in.""" + res, laes = {}, {} + for cid, path, fam, voice, notes in clips: + if path is None or not Path(path).exists(): + res[cid] = dict(fails=[Fail("present", "missing", 1, f"clip file missing ({path})")], facts={}) + continue + x, sr = read(path) + if sr != SHIPPED_RATE: + res[cid] = dict(fails=[Fail("present", "sample_rate", sr, f"sample rate {sr}, expected {SHIPPED_RATE}")], facts={}) + continue + f, facts = check_clip(x, sr, notes, policy_for(fam, voice, th), th) + facts["duration_ms"] = round(len(x) * MS_PER_S / sr, 1) + res[cid] = dict(fails=f, facts=facts) + if fam in ("dings", "neutral"): + laes[cid] = lae(x) + facts["lae_db"] = round(laes[cid], 2) + if laes: + lf, med = loudness_failures(laes, th, neutral={k for k in laes if k.startswith("dings/neutral/")}) + for k, fl in lf.items(): + res[k]["fails"].append(fl) + return res + + +def apply_known(res, th): + """Returns (failing, known_lines, stale_lines). A known failure is a (clip, assertion) with the measured value and slack recorded in thresholds.json; + it is printed on every run. If it stops failing it is stale (reported, and the run fails until the entry is removed); if its value moves by more than + slack the run fails until the entry is re-justified.""" + known = {(k["clip"], k["assertion"]): k for k in th.get("known_failures", [])} + failing, known_lines, stale, seen = {}, [], [], set() + for cid, r in res.items(): + for fl in r["fails"]: + k = known.get((cid, fl.assertion)) + if k is None: + failing.setdefault(cid, []).append(fl) + continue + seen.add((cid, fl.assertion)) + if abs(fl.value - k["value"]) > k["slack"]: + failing.setdefault(cid, []).append(Fail(fl.assertion, "known_moved", fl.value, f"known failure moved: recorded {k['value']} +-{k['slack']}, now {fl.value:g} ({fl.msg})")) + else: + known_lines.append(f"KNOWN {cid} [{fl.assertion}/{fl.kind}] {fl.msg} -- {k['reason']}") + for key, k in known.items(): + if key not in seen: + stale.append(f"STALE known failure {key[0]} [{key[1]}] no longer fails: remove it from thresholds.json known_failures") + return failing, known_lines, stale + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Check every shipped Dings clip and every Earcon Workshop clip with aqua-ear. Exit 1 on any failed assertion. With no directory it renders both itself into a temp dir.") + ap.add_argument("--dings", help="out dir of tools/dings-voices (manifest.json, senders/, neutral/)") + ap.add_argument("--earcon", help="dir with one cli.mjs pack per identity (//pack.json)") + ap.add_argument("--neutral", help="neutral fallback dir used when rendering Dings (default: thresholds.json config.neutral_source)") + ap.add_argument("--thresholds", help="thresholds file (default: next to guard.py)") + ap.add_argument("--keep", action="store_true", help="keep the temp render dir and print it") + a = ap.parse_args(argv) + try: + th = load_thresholds(a.thresholds) + check_pins(th) + want = intended() + tmp = None + dd, ed = a.dings, a.earcon + if not dd or not ed: + tmp = tempfile.mkdtemp(prefix="sound-guard-") + print(f"rendering into {tmp} ...", flush=True) + rd, re_ = render_all(tmp, a.neutral or th["config"]["neutral_source"]["value"]) + dd, ed = dd or rd, ed or re_ + clips = dings_clips(dd, th, want) + earcon_clips(ed, want) + res = run(clips, th) + except GuardError as e: + print(f"sound-guard: error: {e}", file=sys.stderr) + return 2 + failing, known_lines, stale = apply_known(res, th) + for cid, r in res.items(): + tag = "FAIL " if cid in failing else ("known" if any(l.startswith(f"KNOWN {cid} ") for l in known_lines) else "ok ") + print(f"{tag} {cid:34s} peak {r['facts'].get('true_peak_dbtp', '?')} dBTP onsets {r['facts'].get('onsets', [])}") + for cid, fl in failing.items(): + for x in fl: + print(f"FAILED {cid} [{x.assertion}/{x.kind}] {x.msg}") + for l in known_lines + stale: + print(l) + n = len(res) + print(f"\n{n} clips, {len(failing)} failing, {len(known_lines)} known failure(s), {len(stale)} stale known failure(s)." + f" This guard measures signal facts; it cannot judge annoyance, timbre or feel.") + if a.keep and tmp: + print(f"kept {tmp}") + elif tmp: + shutil.rmtree(tmp, ignore_errors=True) + return 1 if failing or stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sound-guard/test_guard.py b/tools/sound-guard/test_guard.py new file mode 100644 index 0000000..401f0ad --- /dev/null +++ b/tools/sound-guard/test_guard.py @@ -0,0 +1,216 @@ +"""Tests for sound-guard: python3 -m unittest -v test_guard (needs numpy, scipy, soundfile with Vorbis, and node for the generator). +Clips are rendered by the generator into a temp dir and mutated in memory; nothing binary is committed. Each mutant must fail the assertion named.""" +import ast, io, json, os, shutil, subprocess, sys, tempfile, unittest +from pathlib import Path +import numpy as np +import scipy.signal as ss +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import guard as g + +TH = g.load_thresholds() +SR = 48000 +WANT = g.intended() if shutil.which("node") else None +BASE_PEAK = 10 ** (-9 / 20) # finish.py places the loudest clip at -9 dBTP +SHIPPED_LAE = -31.86 # finish.py's target exposure for the current sender set (metrics.json targetLaeDb) + + +def ms(t): return int(round(t * SR / 1000)) + + +class Fixture(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which("node"): + raise unittest.SkipTest("node missing: the generator cannot render") + cls.tmp = tempfile.mkdtemp(prefix="sound-guard-test-") + subprocess.run(["node", str(g.TOOLS / "dings-voices/src/build.mjs"), cls.tmp], check=True, capture_output=True) + cls.raw = {(s, m): sf.read(os.path.join(cls.tmp, f"{s}-{m}.wav"))[0] for s in g.SENDERS for m in g.MOTIFS} + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def clip(self, s, m, peak=None): + """A raw clip at the level finish.py gives it (every clip at one A-weighted exposure), or at an explicit peak.""" + x = self.raw[(s, m)].astype(np.float64) + return x * peak / np.max(np.abs(x)) if peak else x * 10 ** ((SHIPPED_LAE - g.lae(x)) / 20) + + @staticmethod + def ogg(x): + b = io.BytesIO(); sf.write(b, x.astype(np.float32), SR, format="OGG", subtype="VORBIS"); b.seek(0) + return sf.read(b, dtype="float64")[0] + + def check(self, x, voice, motif, family="dings"): + notes = WANT["dings"][motif] if family == "dings" else WANT["earcon"][motif] + return g.check_clip(x, SR, notes, g.policy_for(family, voice, TH), TH)[0] + + def kinds(self, fails): return sorted({(f.assertion, f.kind) for f in fails}) + + def assertFails(self, fails, assertion, kind=None): + got = self.kinds(fails) + self.assertTrue(any(a == assertion and (kind is None or k == kind) for a, k in got), f"expected {assertion}/{kind}, got {got}") + + +class Baselines(Fixture): + def test_unmutated_clips_pass(self): + for s, m in [("void", "dm"), ("void", "invite"), ("void", "mention"), ("libby", "dm"), ("kiko", "invite"), ("weksa", "message"), ("nibu", "invite"), ("heimdall", "dm")]: + for x in (self.clip(s, m), self.ogg(self.clip(s, m))): + self.assertEqual(self.check(x, s, m), [], f"{s}/{m}") + + +def knock(n, at_ms, rel_db, tone_peak, seed=7): + """The v3 Void 'soft knock': noise through a broad band-pass around 1.8 kHz, 4 ms attack, 6 ms decay, 24 ms long (voices.mjs v3 noiseBurst). + v3 level: 36.6 dB under the tone's peak (measured by differencing the v3 and v4 renders).""" + r = np.random.default_rng(seed).uniform(-1, 1, ms(24)) + y = ss.sosfilt(ss.butter(2, [1000, 2800], "bandpass", fs=SR, output="sos"), r) + k = np.arange(len(y)); y *= np.minimum(1, (k + 1) / ms(4)) * np.exp(-k / SR / 0.006) + y *= tone_peak * 10 ** ((-36.6 + rel_db) / 20) / np.max(np.abs(y)) + out = np.zeros(n); i = ms(at_ms); out[i:i + len(y)] = y[:n - i] + return out + + +class Mutants(Fixture): + def test_a_v3_knock_at_three_levels(self): + for m in ("dm", "mention"): + base = self.clip("void", m) + at = 95 if m == "dm" else 190 + for rel in (0, -12, -20): # -22 is caught only on the loudest clip: see README + x = self.ogg(base + knock(len(base), at, rel, np.max(np.abs(base)))) + with self.subTest(motif=m, rel_db=rel): + self.assertFails(self.check(x, "void", m), "onsets", "stray_hf") + + def test_b_clipped_tail(self): + base = self.clip("void", "dm") + x = base.copy(); i = ms(300) + x[i:] = np.clip(x[i:] * 40, -1, 1) # last stretch driven into full scale + self.assertFails(self.check(x, "void", "dm"), "clipping", "clip") # exact plateaus in a WAV + self.assertFails(self.check(self.ogg(x), "void", "dm"), "true_peak") # after Vorbis the plateau is overs / true peak, not equal samples + self.assertFails(self.check(self.ogg(x), "void", "dm"), "clipping", "clip") + + def test_b2_limited_plateau_below_full_scale(self): + x = np.clip(self.clip("void", "mention"), -0.6 * BASE_PEAK, 0.6 * BASE_PEAK) # a limiter flat-top, nowhere near 0.99 + self.assertFails(self.check(x, "void", "mention"), "clipping", "flat_top") + + def test_c_click_and_step(self): + base = self.clip("void", "dm") + click = base.copy(); click[ms(150)] += 0.2 + self.assertFails(self.check(click, "void", "dm"), "click_step_dc") + self.assertFails(self.check(self.ogg(click), "void", "dm"), "click_step_dc") + step = base.copy(); step[ms(150):] += 0.03 + self.assertFails(self.check(step, "void", "dm"), "click_step_dc") + self.assertFails(self.check(self.ogg(step), "void", "dm"), "click_step_dc") + + def test_d_abrupt_cut(self): + base = self.clip("void", "mention") + cut = base[:ms(150)] + self.assertFails(self.check(cut, "void", "mention"), "tail_end", "abrupt_end") + self.assertFails(self.check(self.ogg(cut), "void", "mention"), "tail_end", "abrupt_end") + + def test_e_dc_offset(self): + base = self.clip("void", "dm") + 0.01 + self.assertFails(self.check(base, "void", "dm"), "click_step_dc", "dc") + self.assertFails(self.check(self.ogg(base), "void", "dm"), "click_step_dc", "dc") + + def test_f_loudness_outlier(self): + laes = {f"dings/{s}/{m}": g.lae(self.raw[(s, m)]) for s in g.SENDERS for m in g.MOTIFS} + target = float(np.median(list(laes.values()))) + eq = {} # finish.py equalisation, without the encode + for k, v in laes.items(): eq[k] = target + self.assertEqual(g.loudness_failures(eq, TH)[0], {}) + for db, expect in ((3.0, True), (1.0, True), (0.3, False)): + m = dict(eq); m["dings/void/dm"] = target + db + got = g.loudness_failures(m, TH)[0] + self.assertEqual(list(got), ["dings/void/dm"] if expect else [], f"+{db} dB") + m = dict(eq); m["dings/void/dm"] = target - 3.0 # and a quiet outlier + self.assertEqual(list(g.loudness_failures(m, TH)[0]), ["dings/void/dm"]) + + def test_g_second_hit_in_and_out_of_window(self): + base = self.clip("void", "mention") + self.assertEqual(self.check(self.ogg(base), "void", "mention"), []) + def echo(shift): + x = np.concatenate([base, np.zeros(ms(shift))]); x[ms(shift):] += base + return self.ogg(x) + self.assertFails(self.check(echo(15), "void", "mention"), "onsets", "double") # a flam inside the note window + self.assertFails(self.check(echo(45), "void", "mention"), "onsets", "stray") # a second hit outside it + + def test_h_missing_note_and_late_note(self): + base = self.clip("libby", "dm") + gone = base.copy(); gone[ms(200):] = 0 + self.assertFails(self.check(self.ogg(gone), "libby", "dm"), "onsets", "missing") + late = np.concatenate([base[:ms(200)], np.zeros(ms(12)), base[ms(200):]]) + self.assertFails(self.check(self.ogg(late), "libby", "dm"), "onsets", "timing") + + def test_i_tail_rings_over_next_note(self): + a = self.clip("libby", "mention") + x = a.copy(); x[ms(240):] += a[:len(a) - ms(240)] + self.assertFails(self.check(self.ogg(x), "libby", "dm"), "tail_overlap") + # a ring-over voice (Aqua) is allowed to do it + self.assertNotIn("tail_overlap", [f.assertion for f in self.check(self.ogg(x), "aqua", "dm")]) + + def test_j_true_peak_ceiling(self): + hot = self.clip("void", "mention", peak=10 ** (-3 / 20)) + self.assertFails(self.check(hot, "void", "mention"), "true_peak") + + def test_k_earcon_ceiling_is_its_own(self): + x = self.clip("void", "mention", peak=10 ** (-1.5 / 20)) # over -2.5 dBTP, under the Dings -1 convention + f = g.check_clip(x, SR, [0], g.policy_for("earcon", "warm-bell", TH), TH)[0] + self.assertFails(f, "true_peak") + ok = g.check_clip(self.clip("void", "mention", peak=10 ** (-3.2 / 20)), SR, [0], g.policy_for("earcon", "warm-bell", TH), TH)[0] + self.assertNotIn("true_peak", [i.assertion for i in ok]) + + +class Contract(unittest.TestCase): + def leaves(self, d, path=()): + if isinstance(d, dict) and "value" in d: + yield path, d + elif isinstance(d, dict): + for k, v in d.items(): + if isinstance(v, dict): yield from self.leaves(v, path + (k,)) + + def test_every_threshold_has_a_reason(self): + found = list(self.leaves(TH["thresholds"])) + list(self.leaves(TH["voice_policy"])) + list(self.leaves(TH["aqua_ear_pins"])) + list(self.leaves(TH["config"])) + self.assertGreaterEqual(len(found), 20) + for path, leaf in found: + self.assertTrue(isinstance(leaf.get("why"), str) and len(leaf["why"].split()) >= 8, f"{path} needs a real `why`") + + def test_every_threshold_the_code_reads_is_in_the_file(self): + src = (Path(g.__file__)).read_text() + names = set(__import__("re").findall(r'\b(?:tv|pol)\(th, "([a-z_]+)"\)', src)) + have = set(TH["thresholds"]) | set(TH["voice_policy"]) + self.assertEqual(names - have, set()) + self.assertEqual({n for n in TH["thresholds"]} - names, set(), "a threshold nothing reads") + + def test_aqua_ear_pins_hold_and_a_moved_pin_is_refused(self): + g.check_pins(TH) + bad = json.loads(json.dumps(TH)); bad["aqua_ear_pins"]["dc_abs"]["value"] = 0.05 + with self.assertRaises(g.GuardError): g.check_pins(bad) + + def test_known_failures_are_complete_and_stale_ones_are_loud(self): + for k in TH["known_failures"]: + self.assertTrue({"clip", "assertion", "value", "slack", "reason", "measured"} <= set(k), k) + self.assertGreaterEqual(len(k["reason"].split()), 8) + fake = {"known_failures": [{"clip": "c", "assertion": "loudness", "value": 5.0, "slack": 0.1, "reason": "r", "measured": "m"}]} + passing = {"c": dict(fails=[], facts={})} + failing, known, stale = g.apply_known(passing, fake) + self.assertEqual((failing, known, len(stale)), ({}, [], 1)) # started passing: reported + moved = {"c": dict(fails=[g.Fail("loudness", "outlier", 9.0, "x")], facts={})} + self.assertIn("c", g.apply_known(moved, fake)[0]) # got worse: fails until re-justified + same = {"c": dict(fails=[g.Fail("loudness", "outlier", 5.05, "x")], facts={})} + self.assertEqual(g.apply_known(same, fake)[0], {}) # still as recorded: printed, not failing + + def test_guard_source_has_no_magic_numbers(self): + """Numeric literals other than 0, 1, 2 may only be module constants (UPPER_CASE = ...). Everything else is read from thresholds.json.""" + tree = ast.parse(Path(g.__file__).read_text()) + allowed = set() + for n in tree.body: + if isinstance(n, ast.Assign) and all(isinstance(t, ast.Name) and t.id.isupper() for t in n.targets): + allowed |= {id(c) for c in ast.walk(n.value)} + bad = [(c.value, c.lineno) for c in ast.walk(tree) if isinstance(c, ast.Constant) and isinstance(c.value, (int, float)) and not isinstance(c.value, bool) + and c.value not in (0, 1, 2) and id(c) not in allowed] + self.assertEqual(bad, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/sound-guard/thresholds.json b/tools/sound-guard/thresholds.json new file mode 100644 index 0000000..47fb0c8 --- /dev/null +++ b/tools/sound-guard/thresholds.json @@ -0,0 +1,187 @@ +{ + "format": "aquasynth.sound-guard-thresholds.v1", + "about": "Every number the guard compares against lives in this file and nowhere else (guard.py has none). Each entry has a `why`. test_guard.py fails if one lacks it. Detector boundaries that belong to tools/aqua-ear are pinned, not copied: see aqua_ear_pins.", + "thresholds": { + "pad_ms": { + "value": 20, + "why": "Digital silence prepended before onset analysis. aqua-ear cannot see an onset that is already at full level at sample 0 (Kiko, Void, Aqua, Weksa start inside the first millisecond: their first note was missing from aqua-ear's own onset list). The pad also makes a clip that starts on a non-zero sample a step the click detector sees. Padding is not used for DC or the end checks." + }, + "hold_ms": { + "value": 10, + "why": "Peak-hold applied to aqua-ear's 1 ms envelope before the onset detector (rising edges are untouched, only valleys are filled). A low harmonic tone's envelope ripples at its fundamental and aqua-ear reads each ripple as an onset (Heimdall's 196 Hz horn gave 50 to 90 'onsets' per clip; Earcon Workshop warm-bell 20 to 75). 10 ms is one period of 100 Hz, below every fundamental in the shipped sets (lowest: Heimdall 196 Hz, Earcon fail -4 semitones from 262 Hz)." + }, + "window_pre_ms": { + "value": 20, + "why": "An onset up to this many ms before an intended note start belongs to that note. Measured on the decoded shipped clips: Vorbis pre-echo puts rises 12 to 15 ms ahead of an attack (Aqua message 215 ms for a note at 230, 15 ms early; Aqua dm 228 for 240, 12 early) at -14 to -30 dB re the attack; there is nothing else in a clip that early." + }, + "window_post_ms": { + "value": 20, + "why": "An onset up to this many ms after an intended note start belongs to that note. Measured: first-hit attacks land 0 to 5 ms late and high-band onsets from the attack (Aqua glide, Nibu 8 kHz grain, Void 9 ms attack) land up to +14 ms (Aqua invite 214 for 200). Must stay well below 95 ms, where the v3 Void knock sat, and below the 70 ms spacing of the Earcon message pair (pre 20 + post 20 = 40 ms, no windows overlap)." + }, + "main_timing_ms": { + "value": 8, + "why": "The loudest onset in a note's window must start within this many ms of the intended time. Measured worst offset of a main onset over all strict clips is 5 ms (Void dm second note, 235 for 240); Earcon first notes are 1 to 3 ms early. 8 leaves 3 ms margin; a note 10 ms off is a timing bug." + }, + "precursor_db": { + "value": 10, + "why": "An onset that starts before the main onset of its note (pre-echo, a rise lead-in) is not a second hit if its peak is at least this far under the main onset's peak. Measured pre-echo is 13.7 to 27 dB under (Aqua invite 195 ms: 13.7 dB under). aqua-ear's own 'comparable' limit is 20 dB, which would call Vorbis pre-echo a double hit; 10 dB is lower than any measured pre-echo and still lets any equal-level second hit fail. An onset AFTER the main onset inside the window has no such allowance and always fails on the full-band channel." + }, + "hf_floor_dbfs": { + "value": -80.0, + "why": "High-band (above 2 kHz) onset floor, absolute; aqua-ear uses -75. The detector needs a 4 dB rise above its trough, which is clamped at this floor, so a high-band onset is only seen when it peaks above floor + 4 dB = -76 dBFS. The v3 Void knock measured -53 dBFS in this band at a -9 dBFS clip. Measured on synthetic copies of the v4 Void clips: caught down to a knock 20 dB below the v3 level on all three motifs tried, 22 dB below on the loudest only. -80 is as low as the shipped set allows: at -84 Aqua mention (hf -79 dBFS at 30 ms, glide leakage) and Heimdall (hf rises at -76 under its swell) show strays, so a lower floor would make the guard red on clips nobody can hear differ." + }, + "ceiling_dings_dbtp": { + "value": -8.0, + "why": "True-peak ceiling for the 32 sender clips, measured on the decoded Ogg. finish.py equalises exposure and places the loudest clip at -9.0 dBTP (-8.94 measured after the encode), so no clip should exceed that; -8.0 leaves 1 dB for encoder variation and still fails a spiky clip (a knock or a click raises peak at constant exposure). The delivery convention of -1 dBTP (verify.py) would let a clip 7 dB hotter than designed through." + }, + "ceiling_earcon_dbtp": { + "value": -2.5, + "why": "True-peak ceiling for Earcon Workshop clips and the neutral fallbacks (which are Earcon Workshop warm-bell renders). The default render sets level 0.7 = -3.1 dBFS; measured true peak is -3.09 to -2.66 dBTP over all 24 earcons and 4 neutral clips (the neutral invite is -2.66). -2.5 keeps 0.16 dB margin over the worst, and stays 1.5 dB inside the -1 dBTP delivery convention." + }, + "flat_top_rel": { + "value": 0.98, + "why": "Flat-top (limiter or hard clip) detector for exact sample plateaus: a run of aqua-ear's clip_run equal samples at or above this fraction of the clip's OWN peak. aqua-ear only calls plateaus at >= 0.99 of full scale clipped, and a -9 dBFS clip clipped at -9 dBFS slips past it. Only WAV can show this (a Vorbis decode smooths plateaus away), so on decoded Ogg this check is blind: see README." + }, + "flat_top_eps": { + "value": 1e-06, + "why": "Two samples are 'equal' for the flat-top detector when they differ by less than this (aqua-ear's own equality test in clipping_true_peak)." + }, + "loudness_dev_db": { + "value": 0.5, + "why": "Every sender clip's A-weighted exposure (LAE, metriclib.lae on the decoded file, the quantity finish.py equalises) must lie within this many dB of the median of the 32 shipped sender clips. Measured after the Vorbis encode: spread 0.36 dB, largest deviation from the median 0.33 dB (Aqua invite). 0.5 dB is also the limit tools/dings-voices/src/verify.py already enforces on spread, and is under the ~1 dB a listener notices as a level difference between two tones. Neutral fallbacks are held to the same band against the same median (see known_failures)." + } + }, + "voice_policy": { + "swell_voices": { + "value": [ + "eureka", + "heimdall" + ], + "why": "Voices whose attack is a slow swell by design (voices.mjs: Eureka 70 ms raised-cosine attack, Heimdall a swell over 45% of a 300 ms note). aqua-ear's rise detector turns a swell into a staircase of 'onsets' (Heimdall: 5 to 7 onsets inside one swell) and sees no onset at all when a legato note changes pitch under a held tone (Heimdall invite shows only note 1; Eureka invite shows notes 1 and 2, the second 21 ms late). For these voices the full-band onset count is NOT asserted; the high band still is (a knock or tick is plain there and these voices have no energy above 2 kHz)." + }, + "settle_before_next_voices": { + "value": [ + "kiko", + "libby", + "void", + "nibu" + ], + "why": "Voices whose notes are built to end by the next onset (voices.mjs v4: Kiko and Libby 'a note something follows is shortened to settle before the next one lands'; Void ends by the next onset; Nibu heldLen). For these the previous tail must be below aqua-ear's overlapping_tails limit at each following note. Aqua, Eureka, Heimdall and Weksa notes ring over the next note by design (their length is the motif cap, not the gap) and Earcon Workshop identities decay 0.25 to 0.55 s over note gaps of 70 to 450 ms by design, so overlap is not asserted for them." + }, + "neutral_events": { + "value": { + "mention": "success", + "dm": "waiting", + "message": "message", + "invite": "done" + }, + "why": "The neutral fallback clips carry no machine-readable intent. They are the Earcon Workshop warm-bell renders: durations (770, 1450, 455, 1170 ms) and onset lists equal warm-bell success, waiting, message and done. Note times for the onset check come from that mapping. This is inferred from the audio, not recorded anywhere." + } + }, + "aqua_ear_pins": { + "dc_abs": { + "value": 0.005, + "why": "DC limit in aqua-ear's click_step_dc (-46 dBFS). The guard uses aqua-ear's detector; pinned so a loosening in aqua_ear.py fails the guard instead of silently loosening it." + }, + "step_abs": { + "value": 0.02, + "why": "Minimum second difference for a click or step in aqua-ear's click_step_dc." + }, + "step_ratio": { + "value": 25.0, + "why": "Discontinuity must be this many times its local mean second difference." + }, + "clip_run": { + "value": 3, + "why": "Equal samples at the ceiling that make a clipped plateau." + }, + "clip_level": { + "value": 0.99, + "why": "A plateau counts as clipping at or above this fraction of full scale." + }, + "tp_limit_dbtp": { + "value": 0.0, + "why": "aqua-ear flags a true peak above 0 dBTP (an inter-sample over); the guard's own ceilings are tighter." + }, + "end_decay_db": { + "value": 6.0, + "why": "abrupt_end: the last 3 ms must have fallen this far since 9 ms earlier or it counts as cut." + }, + "end_tail_db": { + "value": -40.0, + "why": "abrupt_end: last 3 ms louder than this re the file peak counts as a cut candidate." + }, + "end_abs_dbfs": { + "value": -70.0, + "why": "abrupt_end: and the last 3 ms must also be louder than this absolute level, so digital silence at the end is never a cut." + }, + "overlap_gap_ms": { + "value": 80.0, + "why": "overlapping_tails: onsets at least this far apart are separate notes." + }, + "overlap_tail_db": { + "value": -40.0, + "why": "overlapping_tails: the previous note's tail must be below this re its peak at the next onset." + }, + "onset_rise_db": { + "value": 4.0, + "why": "Onset detector: rise above the recent trough that counts as an onset." + }, + "trough_window_ms": { + "value": 20, + "why": "Onset detector: how far back the trough is looked for." + }, + "floor_dbfs": { + "value": -60.0, + "why": "Onset detector: absolute floor of the full-band channel." + }, + "rel_floor_db": { + "value": -45.0, + "why": "Onset detector: floor relative to the clip's (held) peak, so a quiet tail wobble far under the note is not an onset." + }, + "hf_cut_hz": { + "value": 2000.0, + "why": "High-band channel cut-off; the v3 Void knock was visible only above this." + } + }, + "config": { + "neutral_source": { + "value": "/opt/commons/handoff/aqua-dings-ratatoskr-b3eb9d01/out/neutral", + "why": "Not a threshold: where the neutral fallback clips come from when the guard renders the Dings itself. finish.py needs out/neutral/*.ogg and the generator does not make them; this is the staged copy tools/dings-voices/README.md names. Override with --neutral." + } + }, + "known_failures": [ + { + "clip": "dings/neutral/mention", + "assertion": "loudness", + "value": 11.74, + "slack": 0.05, + "measured": "LAE +11.74 dB from the sender median (-31.80 dB LAE), band +-0.5", + "reason": "the fallback clips are (inferred from durations and onsets, see voice_policy.neutral_events) Earcon Workshop warm-bell renders, shipped unmodified: finish.py measures it ('measured as shipped, not modified') and never loudness-matches it, so it sits above the equalised sender clips. Whether to equalise it is the owner's call, not the guard's." + }, + { + "clip": "dings/neutral/dm", + "assertion": "loudness", + "value": 7.12, + "slack": 0.05, + "measured": "LAE +7.12 dB from the sender median (-31.80 dB LAE), band +-0.5", + "reason": "the fallback clips are (inferred from durations and onsets, see voice_policy.neutral_events) Earcon Workshop warm-bell renders, shipped unmodified: finish.py measures it ('measured as shipped, not modified') and never loudness-matches it, so it sits above the equalised sender clips. Whether to equalise it is the owner's call, not the guard's." + }, + { + "clip": "dings/neutral/message", + "assertion": "loudness", + "value": 4.29, + "slack": 0.05, + "measured": "LAE +4.29 dB from the sender median (-31.80 dB LAE), band +-0.5", + "reason": "the fallback clips are (inferred from durations and onsets, see voice_policy.neutral_events) Earcon Workshop warm-bell renders, shipped unmodified: finish.py measures it ('measured as shipped, not modified') and never loudness-matches it, so it sits above the equalised sender clips. Whether to equalise it is the owner's call, not the guard's." + }, + { + "clip": "dings/neutral/invite", + "assertion": "loudness", + "value": 13.73, + "slack": 0.05, + "measured": "LAE +13.73 dB from the sender median (-31.80 dB LAE), band +-0.5", + "reason": "the fallback clips are (inferred from durations and onsets, see voice_policy.neutral_events) Earcon Workshop warm-bell renders, shipped unmodified: finish.py measures it ('measured as shipped, not modified') and never loudness-matches it, so it sits above the equalised sender clips. Whether to equalise it is the owner's call, not the guard's." + } + ] +} -- 2.47.3 From a7fc0b91b3a1f35cc7e5a3dbbf7080892c1188e0 Mon Sep 17 00:00:00 2001 From: Aqua Date: Tue, 10 Nov 2026 18:32:50 +0000 Subject: [PATCH 2/2] sound-guard: the rendering subprocess must not leave __pycache__ in dings-voices Co-Authored-By: Claude Sonnet 5.5 --- tools/sound-guard/guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/sound-guard/guard.py b/tools/sound-guard/guard.py index 0e93044..78f7755 100644 --- a/tools/sound-guard/guard.py +++ b/tools/sound-guard/guard.py @@ -259,7 +259,7 @@ def render_all(tmp, neutral_dir): for ident in intended()["identities"]: steps.append((["node", str(TOOLS / "earcon-workshop" / "cli.mjs"), ident, str(tmp / "earcon" / ident)], None)) for cmd, _ in steps: - r = subprocess.run(cmd, capture_output=True, text=True, timeout=RENDER_TIMEOUT_S) + r = subprocess.run(cmd, capture_output=True, text=True, timeout=RENDER_TIMEOUT_S, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) if r.returncode: raise GuardError(f"render step failed: {' '.join(cmd[:2])}\n{r.stderr[-STDERR_TAIL:]}") return tmp / "dings" / "out", tmp / "earcon" -- 2.47.3