dings-voices v3: per-note rendering, Libby tails, Void tick, Kiko struck glass, three Nibu proposals (unheard)
Nobody has heard v3. Each note renders into its own buffer with its own noise stream (a clip is the sum; polyphony.test.mjs). Notes something follows end by the next onset, the last rings (Libby, Kiko). Void's release tick is a soft knock instead of a 0.5 ms click. Kiko is struck glass instead of bowed. Nibu's glided saw is replaced by three no-glide proposals. findings.py and gen_page.py produce the v2-vs-v3 numbers and page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
35ed701689
commit
e9216b2f24
8 changed files with 190 additions and 61 deletions
|
|
@ -6,7 +6,8 @@ Generates eight sender voices (aqua, eureka, heimdall, kiko, libby, nibu, void,
|
|||
|
||||
- `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` - writes `page/index.html` (no autoplay, no script needed). `src/verify.py` - checks the staged files against the limits and manifest schema.
|
||||
- `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/build.mjs raw && python src/finish.py raw out && python src/gen_page.py && 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 .` (`finish.py` expects `out/neutral/*.ogg` to exist for the neutral comparison).
|
||||
|
|
|
|||
|
|
@ -1,10 +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 } from "./voices.mjs";
|
||||
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; };
|
||||
for (const s of SENDERS) 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, { object: VOICES[s].object, family: VOICES[s].family, caption: VOICES[s].caption }])), rhythm: RHYTHM }, null, 1));
|
||||
console.log(`wrote ${SENDERS.length * MOTIFS.length} raw clips to ${out}`);
|
||||
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}`);
|
||||
|
|
|
|||
53
tools/dings-voices/src/findings.py
Normal file
53
tools/dings-voices/src/findings.py
Normal 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))
|
||||
|
|
@ -9,6 +9,7 @@ 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]
|
||||
|
|
@ -33,6 +34,14 @@ for (s, m), x in clips.items():
|
|||
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:
|
||||
|
|
@ -43,11 +52,13 @@ 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))
|
||||
json.dump(dict(summary=summary, clips=rows, neutral=neutral, pairs=pairs), open(os.path.join(out, "..", "metrics.json"), "w"), indent=1)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -1,47 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""gen_page.py: writes page/index.html (static, no scripts required, no autoplay) from metrics.json and src/voices.json."""
|
||||
import json, html, os
|
||||
here = os.path.dirname(os.path.abspath(__file__)); root = os.path.dirname(here)
|
||||
M = json.load(open(os.path.join(root, "metrics.json"))); V = json.load(open(os.path.join(here, "voices.json")))["voices"]
|
||||
SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"]
|
||||
"""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 notes, rising, spaced like a direct message", "invite": "a long pause, then a short one: three notes, rising"}
|
||||
clip = {(r["sender"], r["motif"]): r for r in M["clips"]}; neu = {n["motif"]: n for n in M["neutral"]}
|
||||
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 row(s, m):
|
||||
c, n = clip[(s, m)], neu[m]; gain = min(1.0, 10 ** ((c["laeDb"] - n["laeDb"]) / 20))
|
||||
return f'''<tr><th scope="row">{m}<br><span class="sub">{e(RHY[m])}</span></th>
|
||||
<td><audio controls preload="none" src="../out/senders/{s}/{m}.ogg" aria-label="{s}, {m}: {e(V[s]["object"])}" data-role="voice"></audio><div class="sub">{c["durationMs"]:.0f} ms</div></td>
|
||||
<td><audio controls preload="none" src="../out/neutral/{m}.ogg" aria-label="neutral fallback for {m}" data-role="neutral" data-match="{gain:.4f}"></audio><div class="sub">{n["durationMs"]:.0f} ms</div></td></tr>'''
|
||||
secs = "\n".join(f'''<section aria-labelledby="h-{s}"><h2 id="h-{s}">{s.capitalize()} <span class="sub">{e(V[s]["object"])} · {e(V[s]["family"])}</span></h2>
|
||||
<p class="caption"><strong>What it is meant to sound like:</strong> {e(V[s]["caption"])}</p>
|
||||
<table><caption class="sr">{s}: the four notifications, voice beside the neutral fallback</caption><thead><tr><th scope="col">Notification</th><th scope="col">{s.capitalize()}'s voice</th><th scope="col">Neutral fallback</th></tr></thead><tbody>
|
||||
{"".join(row(s, m) for m in MOTIFS)}</tbody></table></section>''' for s in SENDERS)
|
||||
mt = "\n".join(f'<tr><th scope="row">{r["sender"]}</th>' + "".join(f'<td>{clip[(r["sender"], m)]["durationMs"]:.0f}</td>' for m in MOTIFS) + f'<td>{r["onsetRiseMs"]}</td><td>{r["centroidHz"]}</td><td>{r["flatnessDb"]}</td><td>{r["laeDb"]}</td><td>{r["truePeakDbTP"]}</td></tr>' for r in M["clips"] if r["motif"] == "mention")
|
||||
S = M["summary"]
|
||||
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: eight sender voices, v2 (unheard)</title>
|
||||
<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:56rem;padding:1rem;font:1.05rem/1.5 system-ui,sans-serif;color:var(--fg);background:var(--bg)}}
|
||||
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:12rem}}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}}
|
||||
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: eight sender voices (v2)</h1>
|
||||
<p class="notice"><strong>Nobody has heard these (v2 included).</strong> This revision answers one listener's notes on v1: longer mentions, a softer and more settled Eureka, a redone Heimdall horn, and message notes spaced apart. Whether it fixed any of that is a listening question. This page exists so a person can listen. The numbers below are measurements of the files; they are not verdicts, and none of them says a sound is pleasant, clear in your room, or reads as its object. Nothing plays until you press play. The clips are quiet (about {S["maxTruePeakDbTP"]} dBTP at most), so start with your volume low and raise it until you can hear them.</p></header>
|
||||
<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>
|
||||
<p>Each voice is a different small object. Each plays the same four notifications on a shared rhythm family (the rhythm tells the event, the voice tells who sent it): <em>mention</em> is one note, <em>message</em> two spaced notes, <em>dm</em> two slightly slower, wider notes, <em>invite</em> a long pause then a short one. The right-hand column is the neutral fallback Ratatoskr plays for any sender without a voice. Things to listen for: can you tell the eight voices apart; does each read as its named object; can you tell the four notifications apart within one voice; is anything startling, sharp or tiring if heard fifty times a day?</p>
|
||||
<noscript><p class="notice">Without JavaScript the neutral fallback plays at its staged level, which is up to 14 dB louder than the voices. Lower your volume before playing it.</p></noscript>
|
||||
<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). Untick to hear them as staged. (This uses the browser's per-element volume; some devices, iOS among them, ignore it, and then the neutral plays at its staged, louder level.)</label>
|
||||
{secs}
|
||||
<section aria-labelledby="h-metrics"><h2 id="h-metrics">Measurements (not verdicts)</h2>
|
||||
<p>Measured on the decoded Ogg files. Loudness is A-weighted sound exposure, matched across all 32 clips to {S["targetLaeDb"]} dB re full scale (spread {S["laeSpreadDb"]} dB); the loudest true peak is {S["maxTruePeakDbTP"]} dBTP against a limit of {S["peakLimitDbTP"]} dBTP. The longest message clip is {S["maxMessageMs"]:.0f} ms. Onset rise is the time for the 1 ms RMS envelope to climb from 10% to 90% of its peak on the single-note clip (small means sharp). Details and caveats: <code>README.md</code>, <code>metrics.json</code>.</p>
|
||||
<details><summary>Per-voice table (the single-note mention clip)</summary><div style="overflow-x:auto"><table><thead><tr><th scope="col">Voice</th><th scope="col">mention ms</th><th scope="col">dm ms</th><th scope="col">message ms</th><th scope="col">invite ms</th><th scope="col">onset rise ms</th><th scope="col">centroid Hz</th><th scope="col">flatness dB</th><th scope="col">LAE dB</th><th scope="col">true peak dBTP</th></tr></thead><tbody>{mt}</tbody></table></div></details></section>
|
||||
{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(){{
|
||||
|
|
@ -51,6 +68,4 @@ audio{{width:100%;min-width:12rem}}a,summary{{color:var(--acc)}}:focus-visible{{
|
|||
match.addEventListener("change",function(){{all.forEach(level)}});
|
||||
}})();
|
||||
</script></body></html>'''
|
||||
os.makedirs(os.path.join(root, "page"), exist_ok=True)
|
||||
open(os.path.join(root, "page", "index.html"), "w").write(page)
|
||||
print("wrote page/index.html", len(page))
|
||||
open(os.path.join(root, "index.html"), "w").write(page); print("wrote index.html", len(page))
|
||||
|
|
|
|||
26
tools/dings-voices/src/polyphony.test.mjs
Normal file
26
tools/dings-voices/src/polyphony.test.mjs
Normal 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`);
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
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 = os.path.dirname(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 = []
|
||||
|
|
@ -26,6 +26,11 @@ for s in SENDERS:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ function sine(buf, t0, hz0, hz1, glideTau, atkMs, tau, lenMs, gain) {
|
|||
|
||||
// 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 fadeOut = at(8);
|
||||
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.",
|
||||
|
|
@ -64,31 +63,49 @@ export const VOICES = {
|
|||
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: "bowed glass", family: "bowed glass", caption: "A rising, shimmering glassy swell that blooms and fades like a flash cooling. Smooth, no click.",
|
||||
hit(b, t, d, i, n, gap, r, m, room) { const len = Math.min(b.length - t, at(Math.min(m === "mention" ? 480 : 250, room))), f0 = 1318 * st(d);
|
||||
for (let k = 0; k < len; k++) { const p = k / len, f = f0 * (0.96 + 0.07 * p), e = Math.sin(Math.PI * p ** 0.7) ** 2; let y = 0;
|
||||
for (const [r2, a] of [[1, 1], [1.004, 0.8], [2.003, 0.3], [3.01, 0.12]]) y += a * Math.sin(TAU * f * r2 * k / SR);
|
||||
b[t + k] += 0.45 * y * e * Math.min(1, (len - k) / fadeOut); } } },
|
||||
libby: { object: "a small bell", family: "soft bell", caption: "One small bell note with a felt-soft start, no strike, ringing for about a quarter of a second.",
|
||||
hit(b, t, d, i, n, gap, r, m, room) { const k = m === "mention" ? 2 : 1; 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, Math.min(250 * k + (k > 1 ? 20 : 0), room), 0.85); } },
|
||||
nibu: { object: "a console waking up", family: "analog synth", caption: "A soft, slightly cold filtered-saw tone that glides up a fifth and settles, with a faint dashboard blip underneath, like a console coming awake. In a mention, just the one note.",
|
||||
hit(b, t, d, i, n, gap, r, m, room) { const len = Math.min(b.length - t, at(Math.min(m === "mention" ? 520 : 300, room))), f0 = 220 * st(d), lp = biquad("lp", 1700, 1.1), atk = at(28); let ph = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
for (let k = 0; k < len; k++) { const f = f0 * (1 + 0.5 * (1 - Math.exp(-k / SR / 0.05))); let y = 0;
|
||||
for (let h = 1; h <= 10; h++) { ph[h - 1] += TAU * f * h / SR; y += Math.sin(ph[h - 1]) / h; }
|
||||
const p = k / len, e = Math.sin(Math.PI / 2 * Math.min(1, (k + 1) / atk)) ** 2 * (p < 0.6 ? 1 : Math.cos(Math.PI / 2 * (p - 0.6) / 0.4) ** 2);
|
||||
b[t + k] += 0.95 * run(lp, y) * e; }
|
||||
sine(b, t + at(90), 1760, 1760, 1, 5, 0.02, Math.min(60, Math.max(0, room - 90)), 0.1); } },
|
||||
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);
|
||||
noiseBurst(b, t + at(95 * k), r, "bp", 1800, 1.0, 0.5, 0.003, 14, 0.2 * (room > 130 * k ? 1 : 0)); } },
|
||||
// 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 v = VOICES[sender], onsets = RHYTHM[motif];
|
||||
const buf = new Float64Array(at(900)), rand = prng(0x5eed ^ (sender.length * 7919) ^ [...sender].reduce((a, c) => a * 31 + c.charCodeAt(0), 7) >>> 0);
|
||||
onsets.forEach(([ms, deg], i) => { const 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); v.hit(buf, at(ms), deg, i, onsets.length, gap, rand, motif, room); });
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue