dings-voices: equalise the neutral fallbacks in the generator (sound-guard 0 failing, 0 known) #29

Merged
aqua merged 1 commit from aqua/neutral-eq into main 2026-11-10 18:37:02 +00:00
8 changed files with 32 additions and 69 deletions

View file

@ -9,11 +9,11 @@ Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void,
- `src/gen_page.py <publishDir>` - 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 <rawDir> <outDir> <tag>` - 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)

View file

@ -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}`);

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""finish.py <rawDir> <outDir>: loudness-match, encode ogg (Vorbis, 48 kHz mono), decode back, measure, write manifest + metrics.
"""finish.py <rawDir> <outDir>: 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:

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""gen_page.py <publishDir> : writes <publishDir>/index.html (static, no autoplay, no script needed beyond the loudness-match checkbox) from <publishDir>/metrics.json,
"""gen_page.py <publishDir> : writes <publishDir>/index.html (static, no autoplay, one-clip-at-a-time script only) from <publishDir>/metrics.json,
voices.json, findings/onsets-{before,after}.json and findings/nibu-after.json (inspect_clips.py output). <publishDir>/out holds the v4 files (senders/, proposals/, neutral/)
and out/v3/senders/{void,nibu} the v3 files they are heard beside; <publishDir>/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'<audio controls preload="none" src="{src}" aria-label="{e(label)}" data-role="{role}"{d}></audio>'
def aud(src, label, role="voice"):
return f'<audio controls preload="none" src="{src}" aria-label="{e(label)}" data-role="{role}"></audio>'
def ms(s, m): return f'<div class="sub">{clip[(s, m)]["durationMs"]:.0f} ms</div>'
def table(cols, caption): # cols: [(header, fn(m) -> cell html)]
head = "".join(f'<th scope="col">{e(h)}</th>' 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()}
<h2>Unchanged voices</h2>
<label><input type="checkbox" id="match" checked> Play the neutral fallback at the same A-weighted loudness as the voice beside it (the staged neutral files are louder than the voices). Some devices ignore the per-element volume this uses; the neutral then plays louder.</label>
<p>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).</p>
{"".join(plain(s) for s in unchanged)}
</main>
<script>
(function(){{
var all=[].slice.call(document.querySelectorAll("audio")), match=document.getElementById("match");
function level(a){{a.volume=(a.dataset.role==="neutral"&&match.checked)?parseFloat(a.dataset.match):1}}
all.forEach(function(a){{level(a);a.addEventListener("play",function(){{level(a);all.forEach(function(o){{if(o!==a)o.pause()}})}})}});
match.addEventListener("change",function(){{all.forEach(level)}});
var all=[].slice.call(document.querySelectorAll("audio"));
all.forEach(function(a){{a.addEventListener("play",function(){{all.forEach(function(o){{if(o!==a)o.pause()}})}})}});
}})();
</script></body></html>'''
open(os.path.join(root, "index.html"), "w").write(page); print("wrote index.html", len(page))

View file

@ -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)

View file

@ -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 (<dir>/<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)

View file

@ -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`")

View file

@ -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": []
}