dings-voices: equalise the neutral fallbacks in the generator (sound-guard 0 failing, 0 known) #29
8 changed files with 32 additions and 69 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`")
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue