dings-voices v4: Void single onset, Nibu proposal 3 everywhere (unheard) #23
6 changed files with 169 additions and 99 deletions
|
|
@ -6,8 +6,9 @@ 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 <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.
|
||||
- `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`.
|
||||
|
||||
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,53 +0,0 @@
|
|||
#!/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))
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
#!/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."""
|
||||
"""gen_page.py <publishDir> : writes <publishDir>/index.html (static, no autoplay, no script needed beyond the loudness-match checkbox) 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
|
||||
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"]
|
||||
root = sys.argv[1]
|
||||
J = lambda *p: json.load(open(os.path.join(root, *p)))
|
||||
M = J("metrics.json"); V = J("voices.json")["voices"]; OB = J("findings", "onsets-before.json"); OA = J("findings", "onsets-after.json"); NB = J("findings", "nibu-after.json")
|
||||
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"]
|
||||
|
|
@ -16,47 +18,70 @@ 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 evs(o): return ", ".join(str(i) for i in o["events"])
|
||||
# what each pre-fix onset was, from the one-suspect-removed renders (see "How it was found")
|
||||
CAUSE = {"mention": {193: "tick (note's own knock, 190 ms in; the mention's note is stretched 2x)"},
|
||||
"dm": {98: "tick of note 1", 339: "tick of note 2"}, "message": {98: "tick of note 1", 329: "tick of note 2"},
|
||||
"invite": {98: "tick of note 1", 309: "note 3's attack (with note 2's tick, ~295 ms, folded into it)", 398: "tick of note 3"}}
|
||||
def void_rows():
|
||||
rows = []
|
||||
for m in MOTIFS:
|
||||
notes = OB[m]["noteMs"]; ons = OB[m]["events"]
|
||||
cells = []
|
||||
for i in ons:
|
||||
near = [n for n in notes if 0 <= i - n < 20]
|
||||
cells.append(f'{i} ms: ' + (f'note {notes.index(near[0]) + 1} attack' if near and i not in CAUSE[m] else e(CAUSE[m].get(i, "?"))))
|
||||
rows.append(f'<tr><th scope="row">{m}<br><span class="sub">notes at {", ".join(map(str, notes))} ms</span></th><td>{"<br>".join(cells)}</td><td>{evs(OA[m])}</td></tr>')
|
||||
return "".join(rows)
|
||||
def void_section():
|
||||
v3 = lambda m: aud(f"out/v3/senders/void/{m}.ogg", f"void {m}, v3")
|
||||
v4 = lambda m: aud(f"out/senders/void/{m}.ogg", f"void {m}, v4") + ms("void", m)
|
||||
img = "".join(f'<figure><figcaption>{m}: before (v3) then after (v4)</figcaption><a href="findings/void-{m}-before.png"><img alt="void {m} before: spectrogram, waveform and 1 ms envelope" src="findings/void-{m}-before.png" loading="lazy"></a> <a href="findings/void-{m}-after.png"><img alt="void {m} after" src="findings/void-{m}-after.png" loading="lazy"></a></figure>' for m in MOTIFS)
|
||||
return f'''<section aria-labelledby="h-void"><h2 id="h-void">Void: the double hit <span class="sub">{e(V["void"]["object"])}</span></h2>
|
||||
<p class="caption"><strong>Meant to sound like (a hypothesis):</strong> {e(V["void"]["caption"])}</p>
|
||||
<p>Meta heard a weird double hit in v3. In every v3 Void note the spectrogram shows a second event 95 ms after the note starts (190 ms in the mention): a short broadband knock, the "soft tick" v3 kept on the tail of every note, level with the tone it sits on, so the loudness envelope barely shows it but the spectrum does. Removing only the tick removes every extra onset except one: in invite, note 2 (gap 100 ms) was still ringing at -16 dB when note 3 struck, which makes a second rise (+19 dB, 9 ms later +8.5 dB) in the envelope as the two tones beat; note 3 rendered alone has exactly one onset. v4 removes the tick and ends a note that something follows by the next onset, as Libby's do.</p>
|
||||
<table><caption class="sr">Void onsets before and after</caption><thead><tr><th scope="col">Clip</th><th scope="col">v3 onsets (ms) and what made each</th><th scope="col">v4 onsets (ms)</th></tr></thead><tbody>{void_rows()}</tbody></table>
|
||||
<p class="sub">An onset is a rise of the 1 ms envelope of at least 6 dB over the quietest point of the previous 15 ms (full band), or of 10 dB in the band above 1.5 kHz, where a click or knock shows even when it is level with the tone; events within 20 ms are one. v4 has exactly one per note.</p>
|
||||
{table([("v4 (new, unheard)", v4), ("v3 (as listened to)", v3)], "void: v4 beside v3")}
|
||||
<details open><summary>Spectrograms and envelopes (click for full size)</summary>{img}</details></section>'''
|
||||
def nibu_section():
|
||||
cols = [("Proposal 3 (Nibu's voice, all four events)", lambda m: aud(f"out/senders/nibu/{m}.ogg", f"Nibu, proposal 3, {m}") + ms("nibu", m))]
|
||||
for i in (1, 2): cols.append((f"Proposal {i} (alternate, not in the manifest)", (lambda p: lambda m: aud(f"out/proposals/{p}/{m}.ogg", f"Nibu alternate proposal {p[-1]}, {m}") + ms(p, m))(f"nibu{i}")))
|
||||
cols.append(("v3 manifest voice (proposal 1)", lambda m: aud(f"out/v3/senders/nibu/{m}.ogg", f"Nibu v3, {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 (3, 1, 2))
|
||||
def note_cells(m): return "; ".join("note %d: %s dB above 4 kHz, %s dB at its end" % (i + 1, n["above4kHzDb"], n["last5msDbRePeak"]) for i, n in enumerate(NB[m]["notes"]))
|
||||
mult = "".join(f'<tr><th scope="row">{m}</th><td>{note_cells(m)}</td><td>{NB[m]["imageBandPeakDbRePeak"]} dB</td></tr>' for m in ("message", "dm", "invite"))
|
||||
return f'''<section aria-labelledby="h-nibu"><h2 id="h-nibu">Nibu: proposal 3 for every event <span class="sub">chosen by Nibu after Meta liked it; same motif rhythms as the other voices</span></h2><ol class="caps">{caps}</ol>
|
||||
<p>Proposal 3 is Nibu's voice in the manifest for mention, message, dm and invite, at the shared rhythms (message two quick notes, dm two slow notes, invite three). Proposals 1 and 2 are kept here as alternates and are not in the manifest.</p>
|
||||
{table(cols, "Nibu: proposal 3 beside the alternates and v3")}
|
||||
<h3>Does proposal 3 survive more than one note?</h3>
|
||||
<p>Measured on the raw renders, not by ear. Notes do not overlap: each note is silent (under -120 dB re its peak, checked as an exact test) by the next onset, and the clip is the sum of its per-note renders. No smear: the multi-note spectrograms show three clean steps and the 8 kHz sample-rate images (7.3 and 8.7 kHz for the invite's third note) as thin lines. Note 1 of every multi-note clip has the same spectrum as the single note (same pitch, first 200 ms, same peak at 349 Hz and the same energy above 4 kHz, -60.1 dB re total). The invite's high floor is more tones and more attacks, not aliasing: the strongest image line is {NB["invite"]["imageBandPeakDbRePeak"]} dB re the clip's peak (mention {NB["mention"]["imageBandPeakDbRePeak"]}, message {NB["message"]["imageBandPeakDbRePeak"]}, dm {NB["dm"]["imageBandPeakDbRePeak"]}).</p>
|
||||
<table><caption class="sr">Nibu proposal 3 multi-note measurements</caption><thead><tr><th scope="col">Clip</th><th scope="col">Per note</th><th scope="col">Strongest line 6.5-9.5 kHz</th></tr></thead><tbody>{mult}</tbody></table>
|
||||
<p><a href="findings/nibu-spectrum-after.png">spectrum, invite against mention</a> · <a href="findings/nibu-invite-after.png">invite spectrogram</a> · <a href="findings/nibu-message-after.png">message</a> · <a href="findings/nibu-dm-after.png">dm</a></p></section>'''
|
||||
unchanged = ["aqua", "eureka", "heimdall", "kiko", "libby", "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>'
|
||||
return f'<section aria-labelledby="h-{s}"><h2 id="h-{s}">{s.capitalize()} <span class="sub">{e(V[s]["object"])} · unchanged since v3</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>
|
||||
<title>Ratatoskr dings: v4 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}}
|
||||
h1{{font-size:1.5rem}}h2{{font-size:1.2rem;margin-top:2rem}}h3{{font-size:1.05rem}}.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}}
|
||||
audio{{width:100%;min-width:10rem}}img{{max-width:100%;height:auto;width:48%}}figure{{margin:1rem 0}}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>
|
||||
<header><h1>Ratatoskr dings: what changed after v3 (v4)</h1>
|
||||
<p class="notice"><strong>Nobody has heard v4.</strong> Not Meta, not Nibu, not its author. The Void diagnosis comes from spectrograms and from renders with one suspect removed; that the tick is what was heard as a double hit is a hypothesis until someone listens. 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>
|
||||
<p>Changed: Void (no tick; a note that something follows ends by the next onset) and Nibu (proposal 3 for all four events). The other six voices are the same renders as v3, sample for sample (hashes of the raw renders checked); the shared loudness target is the minimum over all clips and is set by void/mention, which changed, so it moved by about 0.005 dB (both round to {S["targetLaeDb"]} dB); with that gain change the Vorbis quantisation differs, so decoded they differ from v3's by up to 0.015 dB of gain, at most 0.0028 of full scale (-51 dB) as a residual. Re-encoding the same input at the same gain is bit-exact</p>
|
||||
<p>Loudness: every clip is matched to one A-weighted exposure; across all {len(M["clips"])} sender clips and {len(M["proposals"])} proposal clips as decoded, the spread is {S["laeSpreadDb"]} dB (target {S["targetLaeDb"]} dB, limited by {S["limitingClip"]}); the loudest true peak is {S["maxTruePeakDbTP"]} dBTP.</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>
|
||||
{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>
|
||||
{"".join(plain(s) for s in unchanged)}
|
||||
</main>
|
||||
|
|
|
|||
63
tools/dings-voices/src/inspect_clips.py
Normal file
63
tools/dings-voices/src/inspect_clips.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env python3
|
||||
"""inspect_clips.py <rawDir> <outDir> <tag>: the looking-at-it tool behind the v4 findings. Writes, into outDir,
|
||||
void-<motif>-<tag>.png (spectrogram, waveform, 1 ms Hilbert envelope with detected onsets) for every Void clip, onsets-<tag>.json (onset list per Void clip, in the
|
||||
full band and above 1.5 kHz), and nibu-<motif>-<tag>.png (spectrogram) plus nibu-spectrum-<tag>.png (invite vs mention spectrum) and nibu-<tag>.json
|
||||
(energy above 4 kHz per note window, tail level before the next onset, image-band level). Reads the float WAVs build.mjs wrote (unit scale). Measurements, not verdicts."""
|
||||
import sys, os, json
|
||||
import numpy as np, soundfile as sf, matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from scipy.signal import spectrogram, hilbert, butter, sosfiltfilt
|
||||
SR = 48000
|
||||
MOTIFS = ["mention", "dm", "message", "invite"]
|
||||
RHYTHM_MS = {"mention": [0], "message": [0, 230], "dm": [0, 240], "invite": [0, 200, 300]}
|
||||
HP = butter(4, 1500, "hp", fs=SR, output="sos")
|
||||
RISE_DB, FLOOR_DB, HP_RISE_DB, HP_FLOOR_DB = 6.0, -45.0, 10.0, -40.0 # an onset: 1 ms envelope up by RISE_DB over the quietest of the 15 ms before, above FLOOR re its band's peak
|
||||
|
||||
def env_db(x):
|
||||
a = np.abs(hilbert(x)); m = len(x) // 48; e = 20 * np.log10(a[:m * 48].reshape(m, 48).mean(1) + 1e-9); return e - e.max()
|
||||
def onsets(x, rise, floor):
|
||||
e = env_db(x); out = []
|
||||
for i in range(1, len(e) - 1):
|
||||
lo = e[max(0, i - 15):i].min()
|
||||
if e[i] > floor and e[i] - lo >= rise and e[i] == e[max(0, i - 3):i + 4].max(): out.append((i, round(float(e[i] - lo), 1)))
|
||||
return out
|
||||
def cluster(*lists): # onsets within 20 ms of the previous one are one event (a note's own attack spans ~9 ms)
|
||||
allv = sorted(i for l in lists for i, _ in l); out = []
|
||||
for i in allv:
|
||||
if out and i - out[-1][-1] <= 20: out[-1].append(i)
|
||||
else: out.append([i])
|
||||
return [c[0] for c in out]
|
||||
def panel(x, title, path, marks):
|
||||
fig, ax = plt.subplots(3, 1, figsize=(11, 8)); f, t, S = spectrogram(x, SR, nperseg=512, noverlap=480, nfft=2048)
|
||||
ax[0].pcolormesh(t * 1000, f, 10 * np.log10(S + 1e-14), vmin=-110, vmax=-30, shading="auto"); ax[0].set_ylim(0, 6000); ax[0].set_ylabel("Hz"); ax[0].set_title(title)
|
||||
ax[1].plot(np.arange(len(x)) / SR * 1000, x, lw=.5); ax[1].set_ylabel("amplitude (unit scale)")
|
||||
e = env_db(x); ax[2].plot(np.arange(len(e)), e, lw=1); ax[2].set_ylim(-60, 2); ax[2].set_ylabel("1 ms envelope, dB re peak"); ax[2].grid(alpha=.3); ax[2].set_xlabel("ms")
|
||||
for i in marks: ax[2].axvline(i, color="r", alpha=.5); ax[2].text(i, -8, f"{i} ms", color="r", fontsize=8)
|
||||
for a in ax[:2]: a.set_xlim(0, len(x) / 48)
|
||||
ax[2].set_xlim(0, len(x) / 48); fig.tight_layout(); fig.savefig(path, dpi=90); plt.close(fig)
|
||||
def spec(x):
|
||||
S = np.abs(np.fft.rfft(x * np.hanning(len(x)), 32768)) ** 2; return np.fft.rfftfreq(32768, 1 / SR), S
|
||||
def hf_db(x): f, S = spec(x); return float(10 * np.log10(S[f > 4000].sum() / S.sum() + 1e-20))
|
||||
|
||||
raw, out, tag = sys.argv[1:4]; os.makedirs(out, exist_ok=True)
|
||||
ons = {}
|
||||
for m in MOTIFS:
|
||||
x = sf.read(os.path.join(raw, f"void-{m}.wav"))[0]; full, hp = onsets(x, RISE_DB, FLOOR_DB), onsets(sosfiltfilt(HP, x), HP_RISE_DB, HP_FLOOR_DB); ev = cluster(full, hp)
|
||||
ons[m] = dict(noteMs=RHYTHM_MS[m], fullBand=full, above1500Hz=hp, events=ev); panel(x, f"void {m} ({tag}); red = onset events", os.path.join(out, f"void-{m}-{tag}.png"), ev)
|
||||
json.dump(ons, open(os.path.join(out, f"onsets-{tag}.json"), "w"), indent=1)
|
||||
nib = {}; men = sf.read(os.path.join(raw, "nibu-mention.wav"))[0]
|
||||
for m in ["message", "dm", "invite"]:
|
||||
x = sf.read(os.path.join(raw, f"nibu-{m}.wav"))[0]; on = RHYTHM_MS[m]; ends = on[1:] + [len(x) / 48]; notes = []
|
||||
for i, (a, b) in enumerate(zip(on, ends)):
|
||||
s = x[int(a * 48):int(b * 48)]; notes.append(dict(startMs=a, endMs=round(b), above4kHzDb=round(hf_db(s), 1), last5msDbRePeak=round(float(20 * np.log10(np.sqrt((s[-240:] ** 2).mean()) / np.abs(s).max() + 1e-12)), 1)))
|
||||
f, S = spec(x); band = (f > 6500) & (f < 9500); w = int(on[1] * 48) # note 1 against the single note (same pitch) over the same window
|
||||
nib[m] = dict(notes=notes, note1VsMention=dict(windowMs=on[1], peakHz=[round(float(spec(a)[0][spec(a)[1].argmax()])) for a in (x[:w], men[:w])], above4kHzDb=[round(hf_db(a), 1) for a in (x[:w], men[:w])]), imageBandPeakDbRePeak=round(float(10 * np.log10(S[band].max() / S.max())), 1), clipEvents=cluster(onsets(x, RISE_DB, FLOOR_DB)))
|
||||
fig, a = plt.subplots(1, 1, figsize=(10, 4)); ff, t, SS = spectrogram(x, SR, nperseg=1024, noverlap=900, nfft=4096)
|
||||
a.pcolormesh(t * 1000, ff, 10 * np.log10(SS + 1e-14), vmin=-120, vmax=-40, shading="auto"); a.set_ylim(0, 12000); a.set_xlabel("ms"); a.set_title(f"nibu (3) {m} ({tag}), 0-12 kHz"); fig.tight_layout(); fig.savefig(os.path.join(out, f"nibu-{m}-{tag}.png"), dpi=90); plt.close(fig)
|
||||
f, S = spec(men); nib["mention"] = dict(imageBandPeakDbRePeak=round(float(10 * np.log10(S[(f > 6500) & (f < 9500)].max() / S.max())), 1))
|
||||
fig, a = plt.subplots(1, 1, figsize=(10, 4))
|
||||
for name, x in [("invite (3 notes)", sf.read(os.path.join(raw, "nibu-invite.wav"))[0]), ("mention (1 note)", men)]:
|
||||
f, S = spec(x); a.semilogx(f[1:], 10 * np.log10(S[1:] / S.max() + 1e-14), label=name, lw=.8)
|
||||
a.set_xlim(100, 24000); a.set_ylim(-100, 3); a.legend(); a.grid(alpha=.3); a.set_xlabel("Hz"); a.set_title(f"nibu (3) full-clip spectrum, dB re its peak ({tag})"); fig.tight_layout(); fig.savefig(os.path.join(out, f"nibu-spectrum-{tag}.png"), dpi=90)
|
||||
json.dump(nib, open(os.path.join(out, f"nibu-{tag}.json"), "w"), indent=1); print(json.dumps(dict(void={m: v["events"] for m, v in ons.items()}, nibuImageBandDb={m: v["imageBandPeakDbRePeak"] for m, v in nib.items()})))
|
||||
|
|
@ -23,4 +23,42 @@ for (const m of ["dm", "invite"]) {
|
|||
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`);
|
||||
// 4. One onset per note. An onset is a rise of a 1 ms peak envelope (4 ms window) of at least ONSET_RISE_DB over the quietest point of the 15 ms before it, above
|
||||
// -45 dB re that band's peak, in the full band or above 1.5 kHz (a dry tick or click is broadband and sits level with the tone, so only the high band shows it).
|
||||
// Rises within 20 ms of each other are one onset (a note's own attack takes ~9 ms). A hit riding a release (v3 Void's tick), a re-attack or a ring that swells under
|
||||
// the next note adds one. Void is checked for every motif: a clip must have exactly as many onsets as notes, at the notes' times.
|
||||
const ONSET_RISE_DB = 6;
|
||||
function highpass(x, fc) { // two cascaded RBJ biquads, Q 0.707
|
||||
const w = 2 * Math.PI * fc / 48000, c = Math.cos(w), al = Math.sin(w) / (2 * 0.707), a0 = 1 + al, b0 = (1 + c) / 2 / a0, b1 = -(1 + c) / a0, a1 = -2 * c / a0, a2 = (1 - al) / a0;
|
||||
let y = Float64Array.from(x); for (let pass = 0; pass < 2; pass++) { const o = new Float64Array(y.length); for (let n = 0; n < y.length; n++) o[n] = b0 * y[n] + b1 * (y[n - 1] || 0) + b0 * (y[n - 2] || 0) - a1 * (o[n - 1] || 0) - a2 * (o[n - 2] || 0); y = o; }
|
||||
return y;
|
||||
}
|
||||
function riseMs(x, rise) {
|
||||
const m = Math.floor(x.length / 48), env = new Float64Array(m);
|
||||
for (let i = 0; i < m; i++) { let p = 0; for (let k = Math.max(0, (i - 3) * 48); k < (i + 1) * 48; k++) p = Math.max(p, Math.abs(x[k])); env[i] = p; }
|
||||
const top = Math.max(...env), db = (v) => 20 * Math.log10(Math.max(v, 1e-9) / top), out = [];
|
||||
for (let i = 1; i < m; i++) { const lo = Math.min(...Array.from(env.subarray(Math.max(0, i - 15), i), db)); if (db(env[i]) > -45 && db(env[i]) - lo >= rise) out.push(i); }
|
||||
return out;
|
||||
}
|
||||
function onsetsMs(x) {
|
||||
const all = [...riseMs(x, ONSET_RISE_DB), ...riseMs(highpass(x, 1500), ONSET_RISE_DB)].sort((p, q) => p - q), out = [];
|
||||
for (const i of all) if (!out.length || i - out[out.length - 1].last > 20) out.push({ first: i, last: i }); else out[out.length - 1].last = i;
|
||||
return out.map((o) => o.first);
|
||||
}
|
||||
for (const m of MOTIFS) {
|
||||
const found = onsetsMs(renderClip("void", m)), expect = RHYTHM[m].map(([ms]) => ms);
|
||||
assert.equal(found.length, expect.length, `void ${m}: onsets at ${found.join(", ")} ms, notes at ${expect.join(", ")} ms`);
|
||||
found.forEach((f, i) => assert.ok(f >= expect[i] && f < expect[i] + 20, `void ${m}: onset ${f} ms is not at note ${i} (${expect[i]} ms)`));
|
||||
RHYTHM[m].forEach((_, i) => assert.equal(onsetsMs(renderNote("void", m, i)).length, 1, `void ${m} note ${i} alone has more than one onset`));
|
||||
}
|
||||
// 5. Nibu (the 8 kHz 5-bit sine): a note something follows is silent by the next onset (notes do not overlap).
|
||||
for (const m of ["dm", "message", "invite"]) for (let i = 0; i < RHYTHM[m].length - 1; i++) {
|
||||
const x = renderNote("nibu", m, i), nextAt = Math.round(RHYTHM[m][i + 1][0] * 48), after = x.subarray(nextAt).reduce((a, v) => Math.max(a, Math.abs(v)), 0);
|
||||
assert.ok(after < 1e-6 * peak(x), `nibu ${m} note ${i} still sounds at ${after / peak(x)} of its peak after note ${i + 1} starts`);
|
||||
}
|
||||
// 6. Void: a note something follows is silent (under -120 dB re its own peak) once the next note starts, so a ring never swells under the next attack.
|
||||
for (const m of MOTIFS) for (let i = 0; i < RHYTHM[m].length - 1; i++) {
|
||||
const x = renderNote("void", m, i), nextAt = Math.round(RHYTHM[m][i + 1][0] * 48), after = x.subarray(nextAt).reduce((a, v) => Math.max(a, Math.abs(v)), 0);
|
||||
assert.ok(after < 1e-6 * peak(x), `void ${m} note ${i} still sounds at ${(20 * Math.log10(after / peak(x))).toFixed(1)} dB re its peak after note ${i + 1} starts`);
|
||||
}
|
||||
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; Void has one onset per note; Nibu notes do not overlap`);
|
||||
|
|
|
|||
|
|
@ -40,10 +40,6 @@ 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; }
|
||||
|
|
@ -68,16 +64,16 @@ export const VOICES = {
|
|||
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)); } },
|
||||
void: { object: "a hollow wooden note", family: "hollow wood", caption: "One round, hollow, almost wooden note that falls a little and settles. Higher than the horn. One onset per note: nothing rides the release.",
|
||||
// v4: no tick (v3's soft knock 95 ms into every note was the second hit Meta heard), and a note something follows ends by the next onset (as Libby's do), so a note's ring never re-rises under the next attack.
|
||||
hit(b, t, d, i, n, gap, r, m, room) { const f = 300 * st(d), k = m === "mention" ? 2 : 1, rm = i + 1 < n ? Math.min(room, gap) : room;
|
||||
sine(b, t, f * 1.2, f * 0.85, 0.05, 9, 0.085 * k, Math.min(230 * k, rm), 0.9); sine(b, t, f * 2.4, f * 1.7, 0.05, 9, 0.03 * k, Math.min(120 * k, rm), 0.12); } },
|
||||
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.
|
||||
// families, none with a pitch glide; Nibu and Meta choose. VOICES.nibu is proposal 3 (chosen by Nibu and Meta, 2026-11-10); proposals 1 and 2 stay as non-default alternates.
|
||||
// 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.
|
||||
|
|
@ -93,7 +89,7 @@ export const NIBU_PROPOSALS = {
|
|||
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 };
|
||||
VOICES.nibu = { ...NIBU_PROPOSALS.nibu3 };
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue