dings-voices: generator for eight Ratatoskr sender voices (unheard) #22

Merged
aqua merged 2 commits from aqua/dings-voices-generator into main 2026-11-10 17:08:49 +00:00
11 changed files with 477 additions and 0 deletions

5
tools/dings-voices/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
out/
page/
metrics.json
voices.json
raw/

View file

@ -0,0 +1,13 @@
# Dings voices (Ratatoskr sender voices) - generator
Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void, weksa) times four notifications (mention, dm, message, invite) as Ogg Vorbis
48 kHz mono, a `manifest.json` in the shape Ratatoskr's `dings.ts` reads (`{neutral, senders:{localpart:{motif:path}}}`), measurements, and a static listening page.
**Nobody has heard any output of this generator.** The metrics describe the files; they are not verdicts.
- `src/voices.mjs` - the voices and the shared rhythm family (event = rhythm, sender = timbre). Pure JS, deterministic; uses only the sample rate from `../earcon-workshop/web/earcon.js`.
- `src/build.mjs <rawDir>` - renders the 32 clips at unit scale. `src/finish.py <rawDir> <outDir>` - equal A-weighted exposure, Ogg encode, decode-back measurement, manifest, `metrics.json` (needs numpy and soundfile with Vorbis). `src/metriclib.py`, `src/measure.py` - measurements (true peak, A-weighted exposure, onset, end-of-clip checks).
- `src/gen_page.py <publishDir>` - writes `index.html` (no autoplay) from `metrics.json`, `findings.json`, `voices.json`. `src/verify.py [dir]` - checks the files against the limits and manifest schema. `src/findings.py <v2senders> <v3out>` - the v2-vs-v3 numbers. `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/`, not in the manifest); Kiko is struck glass; Void's tick is a soft knock.
- 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`.
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).

View file

@ -0,0 +1,11 @@
// node build.mjs <rawDir>: render every sender x motif at unit scale to float32 mono WAV (finish.py does loudness, ogg and metrics).
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";
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)));
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}`);

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""findings.py <v2senders> <v3out> : the v2-vs-v3 numbers quoted by the README and page (void ends and tick, libby per-note level and tail, kiko onset and partials,
proposal distances). Reads decoded oggs; prints JSON."""
import sys, os, json, numpy as np, soundfile as sf
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from metriclib import *
v2, v3 = sys.argv[1], sys.argv[2]
rd = lambda p: sf.read(p)[0]
db = lambda x: float(20 * np.log10(max(x, 1e-12)))
out = {}
# Void: last 50 ms, and the tick (largest 1-sample step over the clip, as a multiple of the median step in the 20 ms before the tick)
void = {}
for m in ["mention", "dm", "message", "invite"]:
row = {}
for tag, root in [("v2", v2 + "/void"), ("v3", v3 + "/senders/void")]:
z = rd(f"{root}/{m}.ogg"); k = SR // 20; t = z[-k:]; step = np.abs(np.diff(z)); j = int(np.argmax(step[SR // 20:])) + SR // 20
base = np.median(step[max(0, j - SR // 50):j]) if j > 0 else 1e-9
row[tag] = dict(last50TruePeakDbTP=round(true_peak_db(t), 2), last50ClippedSamples=int(np.sum(np.abs(t) >= 0.999)), clipClippedSamples=int(np.sum(np.abs(z) >= 0.999)),
clipTruePeakDbTP=round(true_peak_db(z), 2), maxStepAtMs=round(j / 48, 1), maxStepVsMedianStepX=round(float(step[j] / max(base, 1e-12)), 1))
void[m] = row
out["void"] = void
# Libby: per-note peak and the level of the previous note when the next lands, plus note-1 first-120ms centroid
ons = {"mention": [0], "message": [0, 230], "dm": [0, 240], "invite": [0, 200, 300]}; lib = {}
for m in ons:
row = {}
for tag, root in [("v2", v2 + "/libby"), ("v3", v3 + "/senders/libby")]:
z = rd(f"{root}/{m}.ogg"); notes = []
for j, o in enumerate(ons[m]):
seg = z[o * 48:(o + 120) * 48]; X = np.abs(np.fft.rfft(seg * np.hanning(len(seg)), 8192)) ** 2; f = np.fft.rfftfreq(8192, 1 / SR)
prior = round(db(np.max(np.abs(z[(o - 10) * 48:o * 48])) / np.max(np.abs(z))), 1) if o else None
notes.append(dict(onsetMs=o, peakDbFS=round(db(np.max(np.abs(seg))), 1), centroidHz=round(float(np.sum(f * X) / np.sum(X))), priorTailDbReClipPeak=prior))
row[tag] = dict(durationMs=round(1000 * len(z) / SR, 1), notes=notes)
lib[m] = row
out["libby"] = lib
# Kiko: onset rise (mention), first-5-ms envelope, partial ratios from the first 60 ms spectrum of the mention, decay of each partial
def partials(z):
seg = z[int(0.004 * SR):int(0.20 * SR)]; N = 1 << 17; X = np.abs(np.fft.rfft(seg * np.hanning(len(seg)), N)); f = np.fft.rfftfreq(N, 1 / SR)
ps = []; used = np.zeros(len(X), bool)
for _ in range(4):
m = np.where(used, 0, X); i = int(np.argmax(m));
if m[i] < X.max() * 10 ** (-50 / 20): break
ps.append((float(f[i]), db(X[i] / X.max()))); used[max(0, i - 400):i + 400] = True
ps.sort(); return [dict(hz=round(h, 1), ratio=round(h / ps[0][0], 3), dbRe1stPartial=round(a - ps[0][1], 1)) for h, a in ps]
k = {}
for tag, root in [("v2", v2 + "/kiko"), ("v3", v3 + "/senders/kiko")]:
z = rd(f"{root}/mention.ogg"); k[tag] = dict(onsetRiseMs=round(onset_rise_ms(z), 2), onsetMs=round(onset_time_ms(z), 1), centroidHz=round(spectral(z)[0]), partials=partials(z))
out["kiko"] = k
# fundamentals of the bell/glass/FM families, same measure (peaks in the first 200 ms of each mention)
fam = {}
for name, root in [("libby", v3 + "/senders/libby"), ("kiko", v3 + "/senders/kiko"), ("nibu2", v3 + "/proposals/nibu2")]:
z = rd(f"{root}/mention.ogg"); fam[name] = dict(onsetRiseMs=round(onset_rise_ms(z), 2), partials=partials(z))
out["families"] = fam
print(json.dumps(out, indent=1))

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""finish.py <rawDir> <outDir>: loudness-match, encode ogg (Vorbis, 48 kHz mono), 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
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from metriclib import *
SR = 48000
SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"]
MOTIFS = ["mention", "dm", "message", "invite"]
PROPOSALS = ["nibu1", "nibu2", "nibu3"] # Nibu proposals: matched to the same exposure, encoded under out/proposals/, not in the manifest
PEAK_LIMIT_DBTP = -1.0 # brief: true peak under -1 dBTP (measured on the DECODED ogg)
TARGET_PEAK_DBTP = -9.0 # the loudest clip is placed here, so there is 8 dB of headroom before the limit
raw, out = sys.argv[1], sys.argv[2]
os.makedirs(out, exist_ok=True)
clips = {}
for s in SENDERS:
for m in MOTIFS:
x, sr = sf.read(os.path.join(raw, f"{s}-{m}.wav")); assert sr == SR and x.ndim == 1
clips[(s, m)] = x
# One loudness target: every clip is scaled so its A-weighted exposure equals the target. The target is the highest value at which
# the clip with the highest peak-to-exposure ratio lands at TARGET_PEAK_DBTP (so no clip exceeds it).
need = {k: TARGET_PEAK_DBTP - (true_peak_db(x) - lae(x)) for k, x in clips.items()} # exposure target that puts clip k's true peak at TARGET_PEAK_DBTP
target = min(need.values()); limiting = min(need, key=need.get)
rows = []; dec = {}
for (s, m), x in clips.items():
g = 10 ** ((target - lae(x)) / 20); y = x * g
d = os.path.join(out, "senders", s); 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, sr2 = sf.read(path); assert sr2 == SR and z.ndim == 1; dec[(s, m)] = z
cen, flat = spectral(z)
rows.append(dict(sender=s, motif=m, file=f"senders/{s}/{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)))
prop_rows = []
for p in PROPOSALS:
for m in MOTIFS:
x, sr = sf.read(os.path.join(raw, f"{p}-{m}.wav")); y = x * 10 ** ((target - lae(x)) / 20)
d = os.path.join(out, "proposals", p); 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, _ = 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 = []
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)))
# pair distinctness, per motif, over decoded clips
pairs = []
for m in MOTIFS:
for a, b in itertools.combinations(SENDERS, 2):
pairs.append(dict(motif=m, a=a, b=b, distDb=round(spec_dist(dec[(a, m)], dec[(b, m)]), 2)))
family_pairs = [dict(motif=m, a=a, b=b, distDb=round(spec_dist(dec[(a, m)], dec[(b, m)]), 2)) for m in MOTIFS for a, b in [("kiko", "libby"), ("kiko", "nibu2"), ("libby", "nibu2"), ("kiko", "nibu1"), ("kiko", "nibu3"), ("libby", "nibu1"), ("libby", "nibu3")]]
manifest = {"neutral": {m: f"neutral/{m}.ogg" for m in MOTIFS}, "senders": {s: {m: f"senders/{s}/{m}.ogg" for m in MOTIFS} for s in SENDERS}}
json.dump(manifest, open(os.path.join(out, "manifest.json"), "w"), indent=2)
summary = dict(targetLaeDb=round(target, 2), limitingClip=f"{limiting[0]}/{limiting[1]}", targetPeakDbTP=TARGET_PEAK_DBTP, peakLimitDbTP=PEAK_LIMIT_DBTP, heard=False,
maxTruePeakDbTP=max(r["truePeakDbTP"] for r in rows), laeSpreadDb=round(max(r["laeDb"] for r in rows) - min(r["laeDb"] for r in rows), 2),
lafmaxSpreadWithinMotifDb={m: round(max(r["lafmaxDb"] for r in rows if r["motif"] == m) - min(r["lafmaxDb"] for r in rows if r["motif"] == m), 2) for m in MOTIFS},
maxMessageMs=max(r["durationMs"] for r in rows if r["motif"] == "message"), maxAnyMs=max(r["durationMs"] for r in rows))
summary.update(maxProposalTruePeakDbTP=max(r['truePeakDbTP'] for r in prop_rows), maxProposalMs=max(r['durationMs'] for r in prop_rows))
json.dump(dict(summary=summary, clips=rows, proposals=prop_rows, neutral=neutral, pairs=pairs, familyPairs=family_pairs), open(os.path.join(out, "..", "metrics.json"), "w"), indent=1)
print(json.dumps(summary, indent=1))

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""gen_page.py <publishDir> : writes <publishDir>/index.html (static, no autoplay, no script needed) from <publishDir>/metrics.json, findings.json and voices.json.
<publishDir>/out holds the v3 files (senders/, proposals/, neutral/) and out/v2/senders the v2 files they are heard beside."""
import json, html, os, sys
root = sys.argv[1]; here = os.path.dirname(os.path.abspath(__file__))
M = json.load(open(os.path.join(root, "metrics.json"))); F = json.load(open(os.path.join(root, "findings.json"))); V = json.load(open(os.path.join(root, "voices.json")))["voices"]
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 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)
body = "".join(f'<tr><th scope="row">{m}<br><span class="sub">{e(RHY[m])}</span></th>' + "".join(f"<td>{fn(m)}</td>" for _, fn in cols) + "</tr>" for m in MOTIFS)
return f'<table><caption class="sr">{e(caption)}</caption><thead><tr><th scope="col">Notification</th>{head}</tr></thead><tbody>{body}</tbody></table>'
def beside(s, title, note):
v2 = lambda m: aud(f"out/v2/senders/{s}/{m}.ogg", f"{s} {m}, v2")
v3 = lambda m: aud(f"out/senders/{s}/{m}.ogg", f"{s} {m}, v3") + ms(s, m)
return f'<section aria-labelledby="h-{s}"><h2 id="h-{s}">{title} <span class="sub">{e(V[s]["object"])}</span></h2><p class="caption"><strong>Meant to sound like (a hypothesis):</strong> {e(V[s]["caption"])}</p>{note}' + table([("v3 (new, unheard)", v3), ("v2 (as listened to)", v2)], f"{s}: v3 beside v2") + "</section>"
def nibu():
cols = [(f"Proposal {i}", (lambda p: lambda m: aud(f"out/proposals/{p}/{m}.ogg", f"Nibu proposal {p[-1]}, {m}") + ms(p, m))(f"nibu{i}")) for i in (1, 2, 3)]
cols.append(("v2 (rejected)", lambda m: aud(f"out/v2/senders/nibu/{m}.ogg", f"Nibu v2, {m}")))
caps = "".join(f'<li><strong>Proposal {i}: {e(V[f"nibu{i}"]["family"])}.</strong> {e(V[f"nibu{i}"]["caption"])}</li>' for i in (1, 2, 3))
return f'<section aria-labelledby="h-nibu"><h2 id="h-nibu">Nibu: three proposals <span class="sub">the one openly synthetic voice; the saw glide is gone from all three</span></h2><ol class="caps">{caps}</ol><p>Nibu and Meta choose. The manifest points Nibu at proposal 1 only as a placeholder.</p>' + table(cols, "Nibu: three proposals and v2") + "</section>"
def f1(x): return "—" if x is None else f"{x}"
void = F["void"]["mention"]; lib = F["libby"]; k = F["kiko"]
find = f'''<section aria-labelledby="h-find"><h2 id="h-find">What was found <span class="sub">numbers are measurements of the decoded files, not verdicts</span></h2><ul>
<li><strong>Void.</strong> The release does not clip: 0 samples at or above 0.999 full scale in any Void clip (v2 and v3); the last 50 ms of v2 peak at {void["v2"]["last50TruePeakDbTP"]} dBTP (mention), {F["void"]["dm"]["v2"]["last50TruePeakDbTP"]} (dm), {F["void"]["message"]["v2"]["last50TruePeakDbTP"]} (message), {F["void"]["invite"]["v2"]["last50TruePeakDbTP"]} (invite), and every clip's true peak is under {max(F["void"][m]["v2"]["clipTruePeakDbTP"] for m in MOTIFS)} dBTP. The tone's own envelope ends clean (about -35 dB re its peak, then a 15 ms fade). What does click is the "dry tick" riding the release: it had a 0.5 ms attack and its 5 ms peak stood 3.3 dB over the tone around it (-12.4 against -15.7 and -16.4 dB re the clip peak), a {void["v2"]["maxStepVsMedianStepX"]}x jump in sample-to-sample step at {void["v2"]["maxStepAtMs"]} ms. In v3 the tick is a soft knock (4 ms attack, 5 ms peak -16.0 dB, level with the tone instead of over it): that step is {void["v3"]["maxStepVsMedianStepX"]}x, i.e. gone. That the tick is what you heard is a hypothesis; I did not hear it.</li>
<li><strong>Libby.</strong> The generator was already polyphonic in the sense you asked about: rendering each note of a Libby clip alone and summing them reproduces the clip to between -145 and -150 dB re its peak (float rounding), in every sender's dm, message and invite except Void's, so note 2 does not steal note 1's envelope, phase or filter, and there is no shared reverb or filter in the stack. (The one shared state was the noise stream, used only by Void's tick: Void's solo-vs-mixed diff was -21 to -19.5 dB. Now each note owns its own.) What the numbers do show: in v2 the tail of the previous note when the next one landed was {lib["invite"]["v2"]["notes"][1]["priorTailDbReClipPeak"]} and {lib["invite"]["v2"]["notes"][2]["priorTailDbReClipPeak"]} dB re the clip peak in invite (dm {lib["dm"]["v2"]["notes"][1]["priorTailDbReClipPeak"]}, message {lib["message"]["v2"]["notes"][1]["priorTailDbReClipPeak"]}), so invite overlapped itself; and every multi-note clip is matched to the same exposure as the single note, so each note sits 1.3-3.9 dB under the mention's peak. Either could read as muffled or swampy; <strong>that is a hypothesis until measured by ear, and nobody has heard v3.</strong> v3: a note something follows is cut to its gap and has settled to {lib["dm"]["v3"]["notes"][1]["priorTailDbReClipPeak"]} / {lib["invite"]["v3"]["notes"][1]["priorTailDbReClipPeak"]} / {lib["invite"]["v3"]["notes"][2]["priorTailDbReClipPeak"]} dB at the next onset; the last note rings (dm now {lib["dm"]["v3"]["durationMs"]:.0f} ms, message limited to {lib["message"]["v3"]["durationMs"]:.0f} by the 400 ms cap).</li>
<li><strong>Kiko.</strong> v2 was a 142 ms swell ({k["v2"]["onsetRiseMs"]} ms onset rise) on a pitch that rose 7% over the note (0.96 to 1.03 of nominal), with a detuned pair beating against itself. v3 is struck glass: onset rise {k["v3"]["onsetRiseMs"]} ms, no pitch movement, no noise layer, partials at ratios {" : ".join(str(p["ratio"]) for p in k["v3"]["partials"])} ({", ".join(str(p["dbRe1stPartial"]) + " dB" for p in k["v3"]["partials"][1:])} re the first). Against the other families: Libby's bell is 1 : {F["families"]["libby"]["partials"][1]["ratio"]} : {F["families"]["libby"]["partials"][2]["ratio"]} on 784 Hz with a {F["families"]["libby"]["onsetRiseMs"]} ms onset; Nibu's FM bell (proposal 2) is harmonic, 1 : {F["families"]["nibu2"]["partials"][1]["ratio"]} : {F["families"]["nibu2"]["partials"][2]["ratio"]}, on 277 Hz. Whether it reads as glass and not a balloon is a listening question.</li>
</ul></section>'''
unchanged = [s for s in ("aqua", "eureka", "heimdall", "weksa")]
def plain(s):
rows = "".join(f'<tr><th scope="row">{m}<br><span class="sub">{e(RHY[m])}</span></th><td>{aud(f"out/senders/{s}/{m}.ogg", f"{s} {m}")}{ms(s, m)}</td><td>{aud(f"out/neutral/{m}.ogg", f"neutral {m}", "neutral", min(1.0, 10 ** ((clip[(s, m)]["laeDb"] - neu[m]["laeDb"]) / 20)))}</td></tr>' for m in MOTIFS)
return f'<section aria-labelledby="h-{s}"><h2 id="h-{s}">{s.capitalize()} <span class="sub">{e(V[s]["object"])} · unchanged since v2</span></h2><p class="caption">{e(V[s]["caption"])}</p><table><caption class="sr">{s}: voice beside the neutral fallback</caption><thead><tr><th scope="col">Notification</th><th scope="col">Voice</th><th scope="col">Neutral fallback</th></tr></thead><tbody>{rows}</tbody></table></section>'
page = f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ratatoskr dings: v3 changes (unheard)</title>
<style>
:root{{color-scheme:light dark;--fg:#1b1f23;--bg:#fbfbf8;--mut:#4a5159;--line:#c9ccd0;--acc:#0b5cad}}
@media (prefers-color-scheme:dark){{:root{{--fg:#e8eaed;--bg:#14171a;--mut:#aab0b8;--line:#3a4047;--acc:#7ab7ff}}}}
body{{margin:0 auto;max-width:60rem;padding:1rem;font:1.05rem/1.5 system-ui,sans-serif;color:var(--fg);background:var(--bg)}}
h1{{font-size:1.5rem}}h2{{font-size:1.2rem;margin-top:2rem}}.sub{{font-size:.9rem;color:var(--mut);font-weight:400}}
.notice{{border:2px solid var(--acc);padding:.75rem 1rem;border-radius:.4rem}}.caption{{margin:.25rem 0 .75rem}}
table{{border-collapse:collapse;width:100%}}th,td{{border-top:1px solid var(--line);padding:.4rem .5rem;text-align:left;vertical-align:top}}
audio{{width:100%;min-width:10rem}}a,summary{{color:var(--acc)}}:focus-visible{{outline:3px solid var(--acc);outline-offset:2px}}.sr{{position:absolute;left:-9999px}}label{{display:block;margin:.5rem 0}}
</style></head><body>
<header><h1>Ratatoskr dings: what changed after v2 (v3)</h1>
<p class="notice"><strong>Nobody has heard v3.</strong> v2 was listened to by the people whose notes started this round; v3 by no one, its author included. The diagnosis for Libby is a hypothesis until measured by ear, and the fix for Void and the Kiko rework are hypotheses of the same kind. Nothing plays until you press play. The clips are quiet (at most {S["maxTruePeakDbTP"]} dBTP), so start with your volume low. Not installed into Ratatoskr.</p></header>
<main>
{find}
{nibu()}
{beside("void", "Void", "<p>The tick riding the release is softer; the tone is unchanged.</p>")}
{beside("libby", "Libby", "<p>A note that something follows now ends by the next onset; the last note rings.</p>")}
{beside("kiko", "Kiko", "<p>Reworked from bowed glass to struck glass after Deru's note that v2 sounded like a squeaking balloon.</p>")}
<h2>Unchanged voices</h2><p class="sub">Same source as v2. Their level moved by 0.1 dB or less because the shared loudness target moved with Void's quieter tick.</p>
<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>
{"".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)}});
}})();
</script></body></html>'''
open(os.path.join(root, "index.html"), "w").write(page); print("wrote index.html", len(page))

View file

@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""measure.py <sendersDir> : per-clip duration, onset, true peak and end metrics of decoded oggs (<dir>/<sender>/<motif>.ogg) as JSON on stdout."""
import sys, os, json, numpy as np, soundfile as sf
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from metriclib import *
SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"]; MOTIFS = ["mention", "dm", "message", "invite"]
rows = []
for s in SENDERS:
for m in MOTIFS:
z, sr = sf.read(os.path.join(sys.argv[1], s, f"{m}.ogg")); assert sr == SR
rows.append(dict(sender=s, motif=m, durationMs=round(1000 * len(z) / SR, 1), onsetMs=round(onset_time_ms(z), 1), truePeakDbTP=round(true_peak_db(z), 2), **end_metrics(z)))
print(json.dumps(rows))

View file

@ -0,0 +1,75 @@
"""metriclib.py: measurements shared by finish.py and measure.py. SR=48000. Metrics are not verdicts."""
import numpy as np
SR = 48000
def a_weight_db(f):
f2 = f * f
ra = (12194.0**2 * f2 * f2) / ((f2 + 20.6**2) * np.sqrt((f2 + 107.7**2) * (f2 + 737.9**2)) * (f2 + 12194.0**2))
return 20 * np.log10(np.maximum(ra, 1e-12)) + 2.0
def lae(x):
"""A-weighted sound exposure level, dB re full scale, 1 s reference: 10log10(sum(xA^2)/SR). Energy-matched, duration-independent."""
n = 1 << int(np.ceil(np.log2(len(x) * 2)))
X = np.fft.rfft(x, n); f = np.fft.rfftfreq(n, 1 / SR)
w = 10 ** (a_weight_db(np.maximum(f, 1.0)) / 20); w[0] = 0
xa = np.fft.irfft(X * w, n)
return 10 * np.log10(max(np.sum(xa**2) / SR, 1e-20))
def lafmax(x, win_ms=125):
"""Maximum A-weighted 125 ms running energy level (dB re full scale), a short-term peak of perceived level."""
n = 1 << int(np.ceil(np.log2(len(x) * 2)))
X = np.fft.rfft(x, n); f = np.fft.rfftfreq(n, 1 / SR)
w = 10 ** (a_weight_db(np.maximum(f, 1.0)) / 20); w[0] = 0
xa = np.fft.irfft(X * w, n)[: len(x)]
k = int(SR * win_ms / 1000); c = np.cumsum(np.concatenate([[0], xa**2]))
m = (c[k:] - c[:-k]) / k if len(xa) >= k else np.array([np.mean(xa**2)])
return 10 * np.log10(max(m.max(), 1e-20))
def true_peak_db(x, os_=8):
n = len(x); X = np.fft.rfft(x); Y = np.zeros(n * os_ // 2 + 1, dtype=complex); Y[: len(X)] = X
y = np.fft.irfft(Y, n * os_) * os_
return 20 * np.log10(max(np.max(np.abs(y)), 1e-12))
def onset_rise_ms(x):
"""Onset sharpness (reported on the single-note `mention` clip, where the first hit is the whole clip): time between the FIRST crossing
of 10% and the FIRST crossing of 90% of the 1 ms RMS envelope's peak, ms. Small = sharp/struck; large = swelling."""
k = SR // 1000; c = np.cumsum(np.concatenate([[0], x**2])); e = np.sqrt((c[k:] - c[:-k]) / k)
pk = e.max(); i10 = int(np.argmax(e >= 0.1 * pk)); i90 = int(np.argmax(e >= 0.9 * pk))
return (i90 - i10) / (SR / 1000)
def onset_time_ms(x):
"""Time from clip start to the first 10% crossing of the 1 ms RMS envelope's peak (when the sound audibly starts), ms."""
k = SR // 1000; c = np.cumsum(np.concatenate([[0], x**2])); e = np.sqrt((c[k:] - c[:-k]) / k)
return int(np.argmax(e >= 0.1 * e.max())) / (SR / 1000)
def spectral(x):
X = np.abs(np.fft.rfft(x * np.hanning(len(x)), 1 << int(np.ceil(np.log2(len(x))))))**2; f = np.fft.rfftfreq(2 * (len(X) - 1), 1 / SR)
m = (f >= 80) & (f <= 16000); P = X[m]; P = np.maximum(P, P.max() * 1e-9); ff = f[m] # floor at -90 dB re the loudest bin, so leakage below the 16-bit/Vorbis floor does not decide flatness
centroid = float(np.sum(ff * P) / np.sum(P)); flat = float(np.exp(np.mean(np.log(P))) / np.mean(P))
return centroid, flat
def logspec(x, N=2048, H=512, bands=32):
edges = np.geomspace(80, 16000, bands + 1); fr = np.fft.rfftfreq(N, 1 / SR); win = np.hanning(N); cols = []
pad = np.concatenate([x, np.zeros(max(0, N - len(x)))])
for s in range(0, max(1, len(pad) - N + 1), H):
P = np.abs(np.fft.rfft(pad[s:s + N] * win))**2
cols.append(np.array([10 * np.log10(P[(fr >= edges[b]) & (fr < edges[b + 1])].sum() + 1e-12) for b in range(bands)]))
return np.array(cols)
def spec_dist(a, b):
"""Mean absolute dB difference over 32 log bands (80 Hz-16 kHz), cells where both sides sit under -60 dB (re the pair's own loudest band-frame) ignored.
Same construction as the Earcon Workshop's test.mjs sibling check; a spectrogram number, not a perceptual one."""
A, B = logspec(a), logspec(b); n = max(len(A), len(B))
pad = lambda M: np.vstack([M, np.full((n - len(M), M.shape[1]), -120.0)])
A, B = pad(A), pad(B); ref = max(A.max(), B.max())
keep = ~((A < ref - 60) & (B < ref - 60))
return float(np.mean(np.abs(A - B)[keep])) if keep.any() else 0.0
def end_metrics(z, ms_tail=5, ms_window=100):
"""The clip's end, as the brief asks: level of the last `ms_tail` ms re the clip peak (dB; very negative = settled), the peak of the last 10 ms re the clip peak (a step at the very end can be no larger than this, so it bounds any click), the mean (DC) over the last `ms_window` ms as % of the peak,
and the number of samples at or above 0.999 full scale (clipping)."""
pk = float(np.max(np.abs(z))); k = SR * ms_tail // 1000; w = SR * ms_window // 1000
tail = 20 * np.log10(max(np.sqrt(np.mean(z[-k:] ** 2)), 1e-12) / pk)
return dict(tailDb=round(float(tail), 1), endPeakDb=round(float(20 * np.log10(max(float(np.max(np.abs(z[-SR // 100:]))), 1e-12) / pk)), 1),
dcPct=round(100 * float(np.mean(z[-w:])) / pk, 4), lastSamplePct=round(100 * abs(float(z[-1])) / pk, 4), clipped=int(np.sum(np.abs(z) >= 0.999)))

View file

@ -0,0 +1,26 @@
// node polyphony.test.mjs: a clip is the sum of its notes, and no note depends on another note's pitch or noise (per-note state).
// A note may depend on what FOLLOWS it (gap, room) by design; that is the only coupling.
import assert from "node:assert/strict";
import { SENDERS, MOTIFS, RHYTHM, renderNote, renderClip, NIBU_PROPOSALS } from "./voices.mjs";
const SR = 48000, names = [...SENDERS, ...Object.keys(NIBU_PROPOSALS)];
const peak = (x) => x.reduce((a, v) => Math.max(a, Math.abs(v)), 0);
for (const s of names) for (const m of MOTIFS) {
const n = RHYTHM[m].length, notes = RHYTHM[m].map((_, i) => renderNote(s, m, i)), clip = renderClip(s, m);
// 1. the clip is the sum of its notes (to float32 rounding)
for (let k = 0; k < clip.length; k++) { let sum = 0; for (const nt of notes) sum += nt[k]; assert.ok(Math.abs(clip[k] - sum) < 1e-6, `${s} ${m} sum at ${k}`); }
// 2. changing the pitch of any other note leaves this note bit-identical (noise included)
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) if (j !== i) {
const saved = RHYTHM[m][j][1]; RHYTHM[m][j][1] = saved + 3; const again = renderNote(s, m, i); RHYTHM[m][j][1] = saved;
assert.deepEqual(again, notes[i], `${s} ${m}: note ${i} changed when note ${j} did`);
}
}
// 3. Libby: a note something follows has settled (-40 dB re its own peak) when the next one starts; the last note rings longer than any followed one.
for (const m of ["dm", "invite"]) {
const on = RHYTHM[m], len = (x) => { let e = x.length; while (e > 0 && Math.abs(x[e - 1]) < 1e-3 * peak(x)) e--; return e; };
for (let i = 0; i < on.length - 1; i++) {
const x = renderNote("libby", m, i), nextAt = Math.round(on[i + 1][0] * SR / 1000), tail = peak(x.subarray(nextAt - 48, nextAt + 1));
assert.ok(tail < 0.01 * peak(x), `libby ${m} note ${i} still at ${(20 * Math.log10(tail / peak(x))).toFixed(1)} dB when note ${i + 1} lands`);
assert.ok(len(renderNote("libby", m, on.length - 1)) - Math.round(on[on.length - 1][0] * SR / 1000) > len(x) - Math.round(on[i][0] * SR / 1000), `libby ${m}: last note rings longer than note ${i}`);
}
}
console.log(`ok: ${names.length * MOTIFS.length} clips are the sum of per-note renders; notes are independent of each other's pitch and noise`);

View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""verify.py: checks the staged set against the brief's limits and the manifest schema, from the files on disk (not from metrics.json)."""
import json, os, re, sys, itertools
import numpy as np, soundfile as sf
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"]; MOTIFS = ["mention", "dm", "message", "invite"]
PATH = re.compile(r"^[A-Za-z0-9_-]+(/[A-Za-z0-9_.-]+)*\.(ogg|mp3)$")
man = json.load(open(os.path.join(root, "out", "manifest.json"))); bad = []
assert set(man) == {"neutral", "senders"} and set(man["senders"]) == set(SENDERS)
for grp in [man["neutral"], *man["senders"].values()]:
assert set(grp) == set(MOTIFS)
for p in grp.values():
if not PATH.match(p) or not os.path.exists(os.path.join(root, "out", p)): bad.append(("path", p))
def _unused(x, k=8):
X = np.fft.rfft(x); Y = np.zeros(len(x) * k // 2 + 1, dtype=complex); Y[: len(X)] = X; return 20 * np.log10(np.max(np.abs(np.fft.irfft(Y, len(x) * k) * k)))
laes = []; SR = 48000
from metriclib import lae, true_peak_db, end_metrics
for s in SENDERS:
for m in MOTIFS:
x, sr = sf.read(os.path.join(root, "out", man["senders"][s][m])); info = sf.info(os.path.join(root, "out", man["senders"][s][m]))
if sr != 48000 or x.ndim != 1 or info.format != "OGG" or info.subtype != "VORBIS": bad.append(("format", s, m))
if true_peak_db(x) >= -1.0: bad.append(("truepeak", s, m, true_peak_db(x)))
if m == "message" and 1000 * len(x) / sr > 400: bad.append(("message>400ms", s, 1000 * len(x) / sr))
if len(x) / sr > 0.6: bad.append(("over 600ms", s, m))
em = end_metrics(x)
if em['clipped'] or em['tailDb'] > -30 or abs(em['dcPct']) > 1: bad.append(('end', s, m, em))
laes.append(lae(x))
for p in ["nibu1", "nibu2", "nibu3"]:
for m in MOTIFS:
x, sr = sf.read(os.path.join(root, "out", "proposals", p, f"{m}.ogg")); em = end_metrics(x)
if sr != 48000 or x.ndim != 1 or true_peak_db(x) >= -1.0 or (m == "message" and len(x) / sr > 0.4) or len(x) / sr > 0.6 or em['clipped'] or em['tailDb'] > -30 or abs(em['dcPct']) > 1: bad.append(("proposal", p, m, em))
laes.append(lae(x))
print("loudness spread (dB):", round(max(laes) - min(laes), 2), "| files checked:", len(laes))
if max(laes) - min(laes) > 0.5: bad.append(("loudness spread", max(laes) - min(laes)))
print("FAIL" if bad else "OK", bad); sys.exit(1 if bad else 0)

View file

@ -0,0 +1,111 @@
// Eight sender voices for Ratatoskr dings. Each voice is a different small object; the four motifs share one rhythm
// family (event = rhythm, sender = timbre). Pure JS, deterministic (seeded noise), no dependencies. Built on the
// Earcon Workshop (only its sample rate is imported).
// Output is unit-scale float; build.mjs writes it and finish.py sets loudness, encodes and measures. Nothing here has been heard.
import { SR } from "../../earcon-workshop/web/earcon.js";
const TAU = Math.PI * 2;
export const SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"];
export const MOTIFS = ["mention", "dm", "message", "invite"];
// Shared rhythm family: onset times in ms and a rising scale degree (semitones) per onset. Rhythm tells the event:
// mention = one note, message = quick pair, dm = slow pair, invite = long gap then short (an opening, then in).
export const RHYTHM = {
mention: [[0, 0]],
message: [[0, 0], [230, 4]],
dm: [[0, 0], [240, 5]],
invite: [[0, 0], [200, 7], [300, 12]],
};
export const MAX_MS = { message: 395, dm: 600, mention: 800, invite: 600 };
function prng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296 * 2 - 1; }; }
// RBJ biquads (direct form I). bandpass is constant 0 dB peak gain; lowpass Butterworth-ish at q = 0.707.
function biquad(type, fc, q) {
const w = TAU * Math.min(fc, 0.45 * SR) / SR, c = Math.cos(w), s = Math.sin(w), al = s / (2 * q);
let b0, b1, b2;
if (type === "bp") { b0 = al; b1 = 0; b2 = -al; } else { b0 = (1 - c) / 2; b1 = 1 - c; b2 = (1 - c) / 2; }
const a0 = 1 + al, a1 = -2 * c / a0, a2 = (1 - al) / a0;
return { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1, a2, x1: 0, x2: 0, y1: 0, y2: 0 };
}
function run(f, x) { const y = f.b0 * x + f.b1 * f.x1 + f.b2 * f.x2 - f.a1 * f.y1 - f.a2 * f.y2; f.x2 = f.x1; f.x1 = x; f.y2 = f.y1; f.y1 = y; return y; }
const st = (semi) => 2 ** (semi / 12);
const at = (ms) => Math.round(ms * SR / 1000);
// attack-then-exponential-decay envelope; tau seconds to 1/e; short fade so nothing ends on a step.
const env = (n, atk, tau, len) => Math.min(1, (n + 1) / Math.max(1, atk)) * Math.exp(-n / SR / tau) * Math.min(1, (len - n) / at(4));
// additive partials: [ratio, amp, tauSec]. The decay is sped up just enough that the note has fallen ~40 dB by the end of its window
// (so a note shortened to fit a motif ends settled, not cut), then a 15 ms raised-cosine fade closes it.
function partials(buf, t0, f0, parts, atkMs, lenMs, gain = 1) {
const len = Math.min(buf.length - t0, at(lenMs)), atk = at(atkMs), s = Math.min(1, (len / SR) / (4.6 * Math.max(...parts.map((p) => p[2])))), fade = Math.min(at(15), len >> 2);
for (let n = 0; n < len; n++) { let y = 0; for (const [r, a, tau] of parts) y += a * Math.exp(-n / SR / (tau * s)) * Math.sin(TAU * f0 * r * n / SR); buf[t0 + n] += gain * y * Math.min(1, (n + 1) / atk) * Math.sin(Math.PI / 2 * Math.min(1, (len - n) / fade)) ** 2; }
}
function noiseBurst(buf, t0, rand, type, fc, q, atkMs, tau, lenMs, gain) {
const f = biquad(type, fc, q), len = Math.min(buf.length - t0, at(lenMs)), atk = at(atkMs);
for (let n = 0; n < len; n++) buf[t0 + n] += gain * run(f, rand()) * env(n, atk, tau, len);
}
function sine(buf, t0, hz0, hz1, glideTau, atkMs, tau, lenMs, gain) {
const len = Math.min(buf.length - t0, at(lenMs)), atk = at(atkMs), s = Math.min(1, (len / SR) / (4.6 * tau)), fade = Math.min(at(15), len >> 2); let ph = 0;
for (let n = 0; n < len; n++) { const hz = hz1 + (hz0 - hz1) * Math.exp(-n / SR / glideTau); ph += TAU * hz / SR; buf[t0 + n] += gain * Math.sin(ph) * Math.min(1, (n + 1) / atk) * Math.exp(-n / SR / (tau * s)) * Math.sin(Math.PI / 2 * Math.min(1, (len - n) / fade)) ** 2; }
}
// Eight voices, eight tonal families (the 2026-11-10 family assignment). Voice = hit(buf, t0 sample, deg, i (hit index), n (hits),
// gapMs, rand, motif, room (ms left before the motif's length cap)). Each hit shortens itself to fit `room`. No voice is percussive by design; weksa's pluck and void's tick are the sharpest events.
const bell = (b, t, f0, parts, atkMs, lenMs, gain) => partials(b, t, f0, parts, atkMs, lenMs, gain);
export const VOICES = {
aqua: { object: "a soft water droplet", family: "liquid", caption: "One soft droplet per note: a round, watery 'bloop' whose pitch lifts as it lands. No click.",
hit(b, t, d, i, n, gap, r, m, room) { const L = m === "mention"; sine(b, t, 600 * st(d), 880 * st(d), 0.025, 6, L ? 0.11 : 0.06, Math.min(L ? 440 : 210, room), 0.95); } },
eureka: { object: "a pure soft sine", family: "pure tone", caption: "A single soft, clean, steady tone that eases in slowly, holds, and settles away gently. Nothing else.",
hit(b, t, d, i, n, gap, r, m, room) { const len = Math.min(b.length - t, at(Math.min(m === "mention" ? 400 : 230, room))), f = 330 * st(d), atk = at(70), rel = Math.min(at(110), Math.floor(len * 0.4));
for (let k = 0; k < len; k++) b[t + k] += 0.95 * Math.sin(TAU * f * k / SR) * Math.sin(Math.PI / 2 * Math.min(1, (k + 1) / atk)) ** 2 * Math.sin(Math.PI / 2 * Math.min(1, (len - k) / rel)) ** 2; } },
heimdall: { object: "a low horn heard through a door", family: "muted horn", caption: "One low, warm, muffled horn note, as if heard through a closed door. It swells in slowly, holds a steady pitch, and fades away softly.",
hit(b, t, d, i, n, gap, r, m, room) { const len = Math.min(b.length - t, at(Math.min(m === "mention" ? 600 : 300, room))), f0 = 196 * st(d), door = [biquad("lp", 780, 0.6), biquad("lp", 780, 0.6)];
for (let k = 0; k < len; k++) { const p = k / len, e = Math.sin(Math.PI * Math.min(1, p / 0.45) / 2) ** 2 * Math.cos(Math.PI / 2 * Math.max(0, (p - 0.55) / 0.45)) ** 2;
let y = 0; for (let h = 1; h <= 6; h++) y += Math.sin(TAU * f0 * h * k / SR) / (h * h ** 0.5);
b[t + k] += 1.4 * run(door[1], run(door[0], y)) * e; } } },
kiko: { object: "struck glass", family: "struck glass", caption: "Hypothesis, unheard: a small glass tapped once. A clean, instant start, then three inharmonic partials that ring and fall away, the upper ones first. No friction noise, no swell, no pitch movement. A note something follows settles before the next lands; the last note rings.",
hit(b, t, d, i, n, gap, r, m, room) { const last = i === n - 1, k = last ? 1.6 : 1, len = last ? Math.min(480, room - 10) : Math.min(gap, room);
partials(b, t, 1318 * st(d), [[1, 1, 0.12 * k], [2.76, 0.45, 0.07 * k], [5.4, 0.2, 0.04 * k]], 1, len, 0.8); } },
libby: { object: "a small bell", family: "soft bell", caption: "One small bell note with a felt-soft start, no strike. A note that something follows is shortened to settle before the next one lands; the last note rings.",
hit(b, t, d, i, n, gap, r, m, room) { const last = i === n - 1, k = last ? 2 : 1, len = last ? Math.min(520, room - 10) : Math.min(gap, room); bell(b, t, 784 * st(d), [[1, 1, 0.1 * k], [2.32, 0.22, 0.06 * k], [3.9, 0.06, 0.03 * k]], 26, len, 0.85); } },
void: { object: "a hollow wooden note", family: "hollow wood", caption: "One round, hollow, almost wooden note that falls a little, finishing on a tiny dry tick. Higher than the horn.",
hit(b, t, d, i, n, gap, r, m, room) { const f = 300 * st(d); const k = m === "mention" ? 2 : 1; sine(b, t, f * 1.2, f * 0.85, 0.05, 9, 0.085 * k, Math.min(230 * k, room), 0.9); sine(b, t, f * 2.4, f * 1.7, 0.05, 9, 0.03 * k, Math.min(120 * k, room), 0.12);
// the tick is a soft knock, not a click: 4 ms attack (v2: 0.5 ms), peak level with the tone it lands on (v2: 3.3 dB over it)
noiseBurst(b, t + at(95 * k), r, "bp", 1800, 1.0, 4, 0.006, 24, 0.05 * (room > 130 * k ? 1 : 0)); } },
weksa: { object: "a plucked brass compass", family: "plucked brass", caption: "A small plucked brass note, then a small bright rise: the sound of finding the thing. In a mention, just the one pluck.",
hit(b, t, d, i, n, gap, r, m, room) { const br = 0.35 + 0.3 * i, k = m === "mention" ? 2 : 1; partials(b, t, 880 * st(d), [[1, 1, 0.06 * k], [2, 0.55, 0.045 * k], [3, br, 0.03 * k], [4, br * 0.6, 0.02 * k]], 3.5, Math.min(m === "mention" ? 480 : 240, room), 0.7); } },
};
// Nibu is the one openly synthetic voice. v2's glided saw was rejected (Meta, 2026-11-10: atrocious), so there are three proposals from three
// families, none with a pitch glide; Nibu and Meta choose. VOICES.nibu is proposal 1 as a placeholder until they do.
// shape: raised-cosine in over atk samples, raised-cosine out over the last rel samples.
const shape = (k, len, atk, rel) => Math.sin(Math.PI / 2 * Math.min(1, (k + 1) / atk)) ** 2 * Math.sin(Math.PI / 2 * Math.min(1, (len - k) / rel)) ** 2;
// the held-note length: a note something follows ends by the next onset; the last note rings as long as the motif allows.
const heldLen = (b, t, ring, last, gap, room) => Math.min(b.length - t, at(Math.min(last ? ring : gap, room - 10)));
export const NIBU_PROPOSALS = {
nibu1: { object: "a held square tone through a fixed filter", family: "static-filter square", caption: "Two held, soft, hollow square-wave tones a fifth apart (in a mention, just the first), filtered dark and not moving. Slow soft attack, long settle, no glide. Plainly synthetic.",
hit(b, t, d, i, n, gap, r, m, room) { const last = i === n - 1, len = heldLen(b, t, 520, last, gap, room), f0 = 220 * st(d === 4 || d === 5 ? 7 : d), lp = [biquad("lp", 1300, 0.7), biquad("lp", 1300, 0.7)], atk = at(40), rel = Math.floor(len * (last ? 0.55 : 0.4));
for (let k = 0; k < len; k++) { let y = 0; for (let h = 1; h <= 15; h += 2) y += Math.sin(TAU * f0 * h * k / SR) / h; b[t + k] += 0.9 * run(lp[1], run(lp[0], y)) * shape(k, len, atk, rel); } } },
nibu2: { object: "a small FM bell, low and plain", family: "low-index FM bell", caption: "A small FM bell in a low register: a harmonic 1:2 pair with a modest, fading index, so it is bright for a moment and then a plain tone. Pitched a tritone from Libby's bell and an octave and a half under it. No glide.",
hit(b, t, d, i, n, gap, r, m, room) { const last = i === n - 1, len = heldLen(b, t, 520, last, gap, room), f = 277.2 * st(d), tau = last ? 0.16 : 0.09, s = Math.min(1, (len / SR) / (4.6 * tau)), atk = at(8), rel = Math.min(at(15), len >> 2);
for (let k = 0; k < len; k++) { const e = Math.exp(-k / SR / (tau * s)), idx = 0.2 + 1.8 * Math.exp(-k / SR / (0.07 * s)); b[t + k] += 0.9 * e * Math.sin(TAU * f * k / SR + idx * Math.sin(TAU * f * 2 * k / SR)) * shape(k, len, atk, rel); } } },
nibu3: { object: "a low-resolution digital tone", family: "sample-rate-reduced sine", caption: "A soft sine that has been through an old converter: held at 8 kHz and 5 bits, then filtered smooth so it is grainy without being harsh. Soft attack, gentle decay, no glide. Synthetic in a different way from the other two.",
hit(b, t, d, i, n, gap, r, m, room) { const last = i === n - 1, len = heldLen(b, t, 520, last, gap, room), f = 349.2 * st(d), tau = last ? 0.18 : 0.1, s = Math.min(1, (len / SR) / (4.6 * tau)), atk = at(12), rel = Math.min(at(20), len >> 2), lp = [biquad("lp", 2600, 0.7), biquad("lp", 2600, 0.7)]; let held = 0;
for (let k = 0; k < len; k++) { if (k % 6 === 0) held = Math.round(Math.sin(TAU * f * k / SR) * 16) / 16; b[t + k] += 0.9 * run(lp[1], run(lp[0], held)) * Math.exp(-k / SR / (tau * s)) * shape(k, len, atk, rel); } } },
};
VOICES.nibu = { ...NIBU_PROPOSALS.nibu1 };
const ALL = { ...VOICES, ...NIBU_PROPOSALS };
// Each note renders into its own buffer with its own noise stream (seeded by sender and note index), so no note can read or write
// another's state; a clip is the sum of its notes. A note knows only what follows it (gap, room), never what precedes it.
const noteSeed = (sender, i) => (0x5eed ^ [...sender].reduce((a, c) => a * 31 + c.charCodeAt(0), 7) ^ Math.imul(i + 1, 0x9e3779b1)) >>> 0;
export function renderNote(sender, motif, i) {
const onsets = RHYTHM[motif], [ms, deg] = onsets[i], buf = new Float64Array(at(900)), gap = i + 1 < onsets.length ? onsets[i + 1][0] - ms : 160;
let room = MAX_MS[motif] - ms; if (motif === "message" && i + 1 < onsets.length) room = Math.min(room, gap);
ALL[sender].hit(buf, at(ms), deg, i, onsets.length, gap, prng(noteSeed(sender, i)), motif, room); return buf;
}
export function renderClip(sender, motif) {
const buf = new Float64Array(at(900));
RHYTHM[motif].forEach((_, i) => { const note = renderNote(sender, motif, i); for (let k = 0; k < buf.length; k++) buf[k] += note[k]; });
let end = buf.length; while (end > 1 && Math.abs(buf[end - 1]) < 1e-5) end--; // trim silent tail (-100 dB)
return Float32Array.from(buf.subarray(0, end));
}