diff --git a/tools/dings-voices/README.md b/tools/dings-voices/README.md index f8eaf0e..c7bcbca 100644 --- a/tools/dings-voices/README.md +++ b/tools/dings-voices/README.md @@ -9,11 +9,11 @@ Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void, - `src/gen_page.py ` - writes `index.html` (no autoplay) from `metrics.json`, `voices.json` and the `inspect_clips.py` output under `findings/`. `src/verify.py [dir]` - checks the files against the limits and manifest schema. `src/inspect_clips.py ` - spectrogram + 1 ms envelope PNGs and onset lists for every Void clip, multi-note checks for Nibu. `src/polyphony.test.mjs` - a clip is the sum of per-note renders and notes do not depend on each other. - v3: `renderNote` renders each note in its own buffer with its own noise stream; Libby/Kiko notes that something follows end by the next onset and the last rings; Nibu has three no-glide proposals (`NIBU_PROPOSALS`, encoded under `out/proposals/`); Kiko is struck glass; Void's tick is a soft knock. - 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`. +- 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. 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). +Run: `node src/polyphony.test.mjs && node src/build.mjs raw && python src/finish.py raw out && python src/verify.py .` (`build.mjs` also renders the four neutral fallbacks, Earcon Workshop `warm-bell` at default sliders with mention=warning, dm=waiting, message=message, invite=done, and `finish.py` equalises them to the same A-weighted exposure as the senders; `out/manifest.json` lists them under `neutral`). ## Publishing (one place) diff --git a/tools/dings-voices/src/build.mjs b/tools/dings-voices/src/build.mjs index 89613cd..e8d461a 100644 --- a/tools/dings-voices/src/build.mjs +++ b/tools/dings-voices/src/build.mjs @@ -2,10 +2,15 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { SENDERS, MOTIFS, renderClip, VOICES, RHYTHM, NIBU_PROPOSALS } from "./voices.mjs"; -import { SR } from "../../earcon-workshop/web/earcon.js"; +import { SR, renderFamily } from "../../earcon-workshop/web/earcon.js"; const out = process.argv[2]; mkdirSync(out, { recursive: true }); const wav32 = (x) => { const b = Buffer.alloc(44 + x.length * 4); b.write("RIFF", 0); b.writeUInt32LE(36 + x.length * 4, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(3, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(32, 34); b.write("data", 36); b.writeUInt32LE(x.length * 4, 40); x.forEach((v, i) => b.writeFloatLE(v, 44 + i * 4)); return b; }; const NAMES = [...SENDERS, ...Object.keys(NIBU_PROPOSALS)]; for (const s of NAMES) for (const m of MOTIFS) writeFileSync(join(out, `${s}-${m}.wav`), wav32(renderClip(s, m))); +// Neutral fallback: the Earcon Workshop warm-bell identity at default sliders (renderFamily peak-normalises the whole family), one event per motif. +// The mapping is provisional and was chosen for distinctness only (it matches the Ratatoskr handoff b3eb9d01); finish.py equalises these to the sender median. +export const NEUTRAL_EVENT = { mention: "warning", dm: "waiting", message: "message", invite: "done" }; +const fam = renderFamily("warm-bell"); +for (const m of MOTIFS) writeFileSync(join(out, `neutral-${m}.wav`), wav32(fam.items.find((it) => it.event === NEUTRAL_EVENT[m]).samples)); writeFileSync(join(out, "voices.json"), JSON.stringify({ voices: Object.fromEntries([...SENDERS.map((s) => [s, VOICES[s]]), ...Object.entries(NIBU_PROPOSALS)].map(([s, v]) => [s, { object: v.object, family: v.family, caption: v.caption }])), rhythm: RHYTHM }, null, 1)); -console.log(`wrote ${NAMES.length * MOTIFS.length} raw clips to ${out}`); +console.log(`wrote ${NAMES.length * MOTIFS.length} raw clips and ${MOTIFS.length} neutral clips to ${out}`); diff --git a/tools/dings-voices/src/finish.py b/tools/dings-voices/src/finish.py index d90bf3d..c08ba2d 100644 --- a/tools/dings-voices/src/finish.py +++ b/tools/dings-voices/src/finish.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""finish.py : loudness-match, encode ogg (Vorbis, 48 kHz mono), decode back, measure, write manifest + metrics. +"""finish.py : loudness-match, encode ogg (Vorbis, 48 kHz mono; senders and the neutral fallbacks), decode back, measure, write manifest + metrics. Needs numpy and soundfile (libsndfile has Vorbis); no oggenc/ffmpeg on the host. Metrics are not verdicts; nothing here was heard.""" import sys, os, json, itertools import numpy as np, soundfile as sf @@ -42,11 +42,17 @@ for p in PROPOSALS: sf.write(path, y.astype(np.float32), SR, format="OGG", subtype="VORBIS"); z, _ = sf.read(path); dec[(p, m)] = z; cen, flat = spectral(z) prop_rows.append(dict(sender=p, motif=m, file=f"proposals/{p}/{m}.ogg", bytes=os.path.getsize(path), durationMs=round(1000 * len(z) / SR, 1), laeDb=round(lae(z), 2), lafmaxDb=round(lafmax(z), 2), samplePeakDb=round(20 * np.log10(np.max(np.abs(z))), 2), truePeakDbTP=round(true_peak_db(z), 2), onsetRiseMs=round(onset_rise_ms(z), 2) if m == "mention" else None, onsetMs=round(onset_time_ms(z), 1), **end_metrics(z), centroidHz=round(cen), flatnessDb=round(10 * np.log10(flat), 1))) -# neutral fallback, measured as shipped (not modified) +# neutral fallback: warm-bell renders (build.mjs), equalised to the same A-weighted exposure as the senders. The sender target is not +# touched by them (a bell peaking higher than the voices must not make every sender quieter), so they must fit under the peak limit at it. neutral = [] for m in MOTIFS: - z, sr3 = sf.read(os.path.join(out, "neutral", f"{m}.ogg")); z = z if z.ndim == 1 else z[:, 0] - neutral.append(dict(motif=m, durationMs=round(1000 * len(z) / sr3, 1), laeDb=round(lae(z), 2), truePeakDbTP=round(true_peak_db(z), 2), onsetRiseMs=round(onset_rise_ms(z), 2) if m == "mention" else None, centroidHz=round(spectral(z)[0]), flatnessDb=round(10 * np.log10(spectral(z)[1]), 1))) + x, sr = sf.read(os.path.join(raw, f"neutral-{m}.wav")); assert sr == SR and x.ndim == 1 + y = x * 10 ** ((target - lae(x)) / 20) + d = os.path.join(out, "neutral"); os.makedirs(d, exist_ok=True); path = os.path.join(d, f"{m}.ogg") + sf.write(path, y.astype(np.float32), SR, format="OGG", subtype="VORBIS"); z, sr3 = sf.read(path); assert sr3 == SR and z.ndim == 1 + assert true_peak_db(z) < PEAK_LIMIT_DBTP, f"neutral {m} peaks over the limit at the sender target" + cen, flat = spectral(z) + neutral.append(dict(motif=m, file=f"neutral/{m}.ogg", bytes=os.path.getsize(path), durationMs=round(1000 * len(z) / sr3, 1), laeDb=round(lae(z), 2), truePeakDbTP=round(true_peak_db(z), 2), onsetRiseMs=round(onset_rise_ms(z), 2) if m == "mention" else None, centroidHz=round(cen), flatnessDb=round(10 * np.log10(flat), 1))) # pair distinctness, per motif, over decoded clips pairs = [] for m in MOTIFS: diff --git a/tools/dings-voices/src/gen_page.py b/tools/dings-voices/src/gen_page.py index 8671c1f..cd258af 100644 --- a/tools/dings-voices/src/gen_page.py +++ b/tools/dings-voices/src/gen_page.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""gen_page.py : writes /index.html (static, no autoplay, no script needed beyond the loudness-match checkbox) from /metrics.json, +"""gen_page.py : writes /index.html (static, no autoplay, one-clip-at-a-time script only) from /metrics.json, voices.json, findings/onsets-{before,after}.json and findings/nibu-after.json (inspect_clips.py output). /out holds the v4 files (senders/, proposals/, neutral/) and out/v3/senders/{void,nibu} the v3 files they are heard beside; /findings holds the PNGs.""" import json, html, os, sys @@ -10,9 +10,8 @@ MOTIFS = ["mention", "dm", "message", "invite"] RHY = {"mention": "one note", "dm": "two slower notes, rising", "message": "two quick notes, rising", "invite": "a long pause, then a short one: three notes"} clip = {(r["sender"], r["motif"]): r for r in M["clips"] + M["proposals"]}; neu = {n["motif"]: n for n in M["neutral"]}; S = M["summary"] e = html.escape -def aud(src, label, role="voice", match=None): - d = f' data-match="{match:.4f}"' if match else "" - return f'' +def aud(src, label, role="voice"): + return f'' def ms(s, m): return f'
{clip[(s, m)]["durationMs"]:.0f} ms
' def table(cols, caption): # cols: [(header, fn(m) -> cell html)] head = "".join(f'{e(h)}' for h, _ in cols) @@ -82,15 +81,13 @@ audio{{width:100%;min-width:10rem}}img{{max-width:100%;height:auto;width:48%}}fi {void_section()} {nibu_section()}

Unchanged voices

- +

The neutral fallback beside each voice is loudness-matched to it like every other clip (before this, the staged neutral files were 4 to 14 dB louder than the voices).

{"".join(plain(s) for s in unchanged)} ''' open(os.path.join(root, "index.html"), "w").write(page); print("wrote index.html", len(page)) diff --git a/tools/sound-guard/README.md b/tools/sound-guard/README.md index 86f3ad7..eb126b1 100644 --- a/tools/sound-guard/README.md +++ b/tools/sound-guard/README.md @@ -17,8 +17,7 @@ python3 -m unittest -v test_guard # in tools/sound-guard: ``` `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, +`/var/lib/mind-aqua-self/ogg-venv/bin/python`. Self-render builds the Dings (including the neutral fallbacks, from the Earcon Workshop warm-bell identity) and the earcons itself. 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 @@ -47,8 +46,8 @@ and `neutral_events` (the neutral clips are Earcon Workshop warm-bell renders; t ## 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. +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 list is empty: the four neutral fallback clips +were 4.3 to 13.7 dB louder than the sender clips until `finish.py` equalised them with the senders. ## What the guard cannot see (measured, not guessed) diff --git a/tools/sound-guard/guard.py b/tools/sound-guard/guard.py index 78f7755..15a7a06 100644 --- a/tools/sound-guard/guard.py +++ b/tools/sound-guard/guard.py @@ -246,15 +246,11 @@ def earcon_clips(d, want): return out -def render_all(tmp, neutral_dir): +def render_all(tmp): 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)) @@ -323,7 +319,6 @@ 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) @@ -336,7 +331,7 @@ def main(argv=None): 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"]) + rd, re_ = render_all(tmp) dd, ed = dd or rd, ed or re_ clips = dings_clips(dd, th, want) + earcon_clips(ed, want) res = run(clips, th) diff --git a/tools/sound-guard/test_guard.py b/tools/sound-guard/test_guard.py index 401f0ad..c427513 100644 --- a/tools/sound-guard/test_guard.py +++ b/tools/sound-guard/test_guard.py @@ -170,7 +170,7 @@ class Contract(unittest.TestCase): 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"])) + found = list(self.leaves(TH["thresholds"])) + list(self.leaves(TH["voice_policy"])) + list(self.leaves(TH["aqua_ear_pins"])) 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`") diff --git a/tools/sound-guard/thresholds.json b/tools/sound-guard/thresholds.json index 47fb0c8..56c6f28 100644 --- a/tools/sound-guard/thresholds.json +++ b/tools/sound-guard/thresholds.json @@ -144,44 +144,5 @@ "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." - } - ] + "known_failures": [] }