aqua-feel: effects shelf for overlay packaging (unheard) #10

Merged
aqua merged 1 commit from aqua/effects-shelf into main 2026-11-10 17:08:37 +00:00
5 changed files with 3134 additions and 1 deletions

View file

@ -61,6 +61,10 @@ at the shipped renders. They are tripwires, not taste.
Current waivers: `sfxr-classic-classic-sfxr-abstract-golf` (+0.9 dBTP, -5.3 LUFS, held noise) and `sfxr-explosion` (3 s of
steady noise without decay). Nobody has listened to either to see if the number is the sound.
## Effects shelf
`SHELF.md` and `shelf.json` list every renderable effect (thruster, Dings, earcon families, playground patches) with params, trigger, render command and duration; `node shelf.mjs check` runs every render command and checks the WAVs are finite and not silent. Nothing on it is heard (`listening: unheard`).
## Reuse
`report.js` imports `mean`, `spearman` and `permutationP` from `../aqua-trial/analyze.mjs` and the variants from

125
tools/aqua-feel/SHELF.md Normal file
View file

@ -0,0 +1,125 @@
# Effects shelf
What AquaSynth can render to a WAV today without a window, an audio device or a server: the thruster, the Dings event sounds, the
earcon workshop's families and the playground's synth patches. `shelf.json` is the machine-readable list (generated); the tables
below are generated from it. Both come from `node tools/aqua-feel/shelf.mjs build`.
**Nothing on this shelf has been heard.** Every `listening` field is `unheard`. `node tools/aqua-feel/shelf.mjs check` runs each
entry's render command and proves the file is finite, not silent (peak above -70 dBFS) and as long as listed. It cannot say
that anything sounds good, fits an overlay, or is distinguishable on a stream. A person edits `listening` after listening.
## For overlay packaging
- **No entry reacts to audio.** Nothing here analyses a signal. Entries react to *events* (Ding event ids, earcon event ids), to a
*control value* (the thruster's `rpm`), or to being *started* (patches). Mapping a StreamPixels event onto any of these is not
done anywhere in the repo; that mapping is the packager's.
- Two event vocabularies exist and do not match: Dings use task-state ids (`task.complete`, `input.required`, ...), the earcon
workshop uses `success, fail, warning, message, done, waiting`. The earcon workshop is a separate JS implementation and
does not sound like the Dings.
- Every command runs from the repo root, needs node >= 20, and writes under `tools/aqua-feel/out/shelf/` (gitignored). Patch and
Ding renders need the faustwasm runtime once: `(cd tools/aqua-playground && npm ci)`. Earcon renders need nothing installed.
- Patch and Ding WAVs are mono float32 at 48 kHz; earcon WAVs are mono 16-bit at 48 kHz.
- `duration_s` for patches is the 3 s render window, not the effect's length: `behaviour` says whether the patch was still sounding
at 3 s (sustained) or had died away. Re-render with `--seconds N` for longer.
- Ding loudness uses command gain 0.6, an assumption carried over from `tools/aqua-feel`.
## Render one
```sh
node tools/aqua-feel/shelf.mjs render patch/thruster rpm=2400 --out /tmp/thruster.wav --seconds 5
node tools/aqua-feel/shelf.mjs render ding/task.complete instrument=glass-chime --out /tmp/done.wav
node tools/earcon-workshop/cli.mjs warm-bell /tmp/earcons tempo=1.2 level=0.6 # six WAVs + pack.json
node tools/aqua-feel/shelf.mjs check # every entry (about two minutes); --only SUBSTR for a subset
```
Each entry in `shelf.json` carries its own `render.command` (the exact string `check` runs) and the files it writes.
Sustained patches such as the thruster keep sounding until stopped; the render command cuts them at the window.
## Entries
<!-- shelf:begin (generated by `node tools/aqua-feel/shelf.mjs build`; do not edit) -->
### Thruster (1)
| id | reacts to | params | behaviour | duration | listening |
|---|---|---|---|---|---|
| `patch/thruster` | control: rpm | rpm 300..6000 (default 1200) | sustained | 3 s | unheard |
### Dings (event sounds) (9)
| id | reacts to | params | behaviour | duration | listening |
|---|---|---|---|---|---|
| `ding/input.required` | event `input.required` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.76 s | unheard |
| `ding/resource.limit` | event `resource.limit` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 2.06 s | unheard |
| `ding/session.end` | event `session.end` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.74 s | unheard |
| `ding/session.start` | event `session.start` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.72 s | unheard |
| `ding/task.acknowledge` | event `task.acknowledge` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.5 s | unheard |
| `ding/task.complete` | event `task.complete` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.72 s | unheard |
| `ding/task.error` | event `task.error` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.98 s | unheard |
| `ding/task.progress` | event `task.progress` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.5 s | unheard |
| `ding/user.spam` | event `user.spam` | instrument ∈ {glass-chime, muted-pluck, soft-triangle, warm-bell} (default warm-bell) | decays | 1.88 s | unheard |
### Earcon workshop (one WAV per event) (4)
| id | reacts to | params | behaviour | duration | listening |
|---|---|---|---|---|---|
| `earcon/warm-bell` | events: success, fail, warning, message, done, waiting | tempo 0.6..1.6 (default 1); transpose -12..12 (default 0); brightness -0.25..0.25 (default 0); decay 0.5..1.6 (default 1); level 0.1..0.9 (default 0.7) | decays | 1.45 s | unheard |
| `earcon/glass-chime` | events: success, fail, warning, message, done, waiting | tempo 0.6..1.6 (default 1); transpose -12..12 (default 0); brightness -0.25..0.25 (default 0); decay 0.5..1.6 (default 1); level 0.1..0.9 (default 0.7) | decays | 1.35 s | unheard |
| `earcon/soft-triangle` | events: success, fail, warning, message, done, waiting | tempo 0.6..1.6 (default 1); transpose -12..12 (default 0); brightness -0.25..0.25 (default 0); decay 0.5..1.6 (default 1); level 0.1..0.9 (default 0.7) | decays | 1.4 s | unheard |
| `earcon/muted-pluck` | events: success, fail, warning, message, done, waiting | tempo 0.6..1.6 (default 1); transpose -12..12 (default 0); brightness -0.25..0.25 (default 0); decay 0.5..1.6 (default 1); level 0.1..0.9 (default 0.7) | decays | 1.15 s | unheard |
### Playground patches (one-shot and sustained synth sounds) (41)
| id | reacts to | params | behaviour | duration | listening |
|---|---|---|---|---|---|
| `patch/808-clap` | start | none | decays | 3 s | unheard |
| `patch/808-cowbell` | start | none | decays | 3 s | unheard |
| `patch/808-hat` | start | none | decays | 3 s | unheard |
| `patch/808-kick` | start | none | decays | 3 s | unheard |
| `patch/808-snare` | start | none | decays | 3 s | unheard |
| `patch/808-tom` | start | none | decays | 3 s | unheard |
| `patch/advanced-alien-multivalve-graph` | start | alien/aperture 0..2 (default 0.85); alien/core/opening 0..1 (default 0.24); alien/core/pressure 0..1.5 (default 0.74); alien/side/opening 0..1 (default 0.3); alien/side/pressure 0..1.5 (default 0.52); curves/core_breath/depth 0..1 (default 0.9); curves/mouth_shape/depth 0..1 (default 0.8); curves/side_breath/depth 0..1 (default 0.9) | decays | 3 s | unheard |
| `patch/advanced-aurora-pad` | start | none | decays | 3 s | unheard |
| `patch/advanced-bird-syrinx` | start | bird/beak/opening 0..1.5 (default 0.95); bird/left/damping 0..2 (default 0.14); bird/left/flow_scale 0..4 (default 1.2); bird/left/load_coupling 0..2 (default 0.42); bird/left/mass 0.02..2 (default 0.32); bird/left/opening 0..1 (default 0.24); bird/left/pressure 0..1 (default 0.82); bird/left/rest_opening 0..1 (default 0.025); bird/left/stiffness 0..1 (default 0.025); bird/left/tension 0..1 (default 0.38); bird/left/tissue_loss 0..1 (default 0.18); bird/right/damping 0..2 (default 0.16); bird/right/flow_scale 0..4 (default 1.1); bird/right/load_coupling 0..2 (default 0.42); bird/right/mass 0.02..2 (default 0.34); bird/right/opening 0..1 (default 0.21); bird/right/pressure 0..1 (default 0.7); bird/right/rest_opening 0..1 (default 0.025); bird/right/stiffness 0..1 (default 0.03); bird/right/tension 0..1 (default 0.45); bird/right/tissue_loss 0..1 (default 0.2); curves/beak_opening/depth 0..1 (default 0.7); curves/left_opening/depth 0..1 (default 0.85); curves/left_pressure/depth 0..1 (default 1); curves/right_opening/depth 0..1 (default 0.85); curves/right_pressure/depth 0..1 (default 1) | decays | 3 s | unheard |
| `patch/advanced-bird-syrinx-graph` | start | bird/beak/opening 0..1.5 (default 0.92); bird/left/opening 0..1 (default 0.2); bird/left/pressure 0..1.5 (default 0.84); bird/left/tension 0..1 (default 0.46); bird/right/opening 0..1 (default 0.18); bird/right/pressure 0..1.5 (default 0.72); bird/right/tension 0..1 (default 0.52); curves/beak_gate/depth 0..1 (default 0.75); curves/left_air/depth 0..1 (default 0.95); curves/left_labium_gate/depth 0..1 (default 0.9); curves/right_air/depth 0..1 (default 0.95); curves/right_labium_gate/depth 0..1 (default 0.9) | decays | 3 s | unheard |
| `patch/advanced-glass-creature` | start | none | decays | 3 s | unheard |
| `patch/advanced-human-larynx-graph` | start | curves/breath_phrase/depth 0..1 (default 0.9); curves/fold_onset/depth 0..1 (default 0.8); curves/lip_vowel/depth 0..1 (default 0.65); human/constriction/diameter 0.02..3 (default 0.95); human/constriction/index 0..6 (default 4.8); human/fold/opening 0..1 (default 0.16); human/fold/tension 0..1 (default 0.58); human/lip/opening 0..2 (default 1.2); human/subglottal 0..1.5 (default 0.78); human/tongue/diameter 0.05..3 (default 1.25); human/tongue/index 0..6 (default 3.3) | decays | 3 s | unheard |
| `patch/advanced-machine-breath` | start | none | decays | 3 s | unheard |
| `patch/advanced-ritual-sequence` | start | none | sustained | 3 s | unheard |
| `patch/advanced-subtractive-drums` | start | none | decays | 3 s | unheard |
| `patch/advanced-syrinx-voice` | start | curves/beak_phrase/depth 0..1 (default 0.72); curves/left_phrase_opening/depth 0..1 (default 0.85); curves/left_phrase_pressure/depth 0..1 (default 0.92); curves/right_phrase_opening/depth 0..1 (default 0.85); curves/right_phrase_pressure/depth 0..1 (default 0.92); voice/beak/opening 0..1.5 (default 0.88); voice/left/opening 0..1 (default 0.23); voice/left/pressure 0..1.5 (default 0.78); voice/left/tension 0..1 (default 0.43); voice/right/opening 0..1 (default 0.19); voice/right/pressure 0..1.5 (default 0.66); voice/right/tension 0..1 (default 0.5) | decays | 3 s | unheard |
| `patch/bfxr-coin-spark` | start | none | decays | 3 s | unheard |
| `patch/bfxr-portal-chirp` | start | none | decays | 3 s | unheard |
| `patch/bfxr-shield-pop` | start | none | decays | 3 s | unheard |
| `patch/bfxr-ui-bloom` | start | none | sustained | 3 s | unheard |
| `patch/dx7-algorithm-32-additive-organ` | start | macro/drawbar 0..1 (default 0.78); macro/shimmer 0..0.3 (default 0.08) | decays | 3 s | unheard |
| `patch/dx7-algorithm-8-bright-pair` | start | macro/brightness 0..5 (default 2.6); macro/strike 0.02..0.6 (default 0.18) | decays | 3 s | unheard |
| `patch/dx7-public-domain-mc-mm-5-3` | start | none | decays | 3 s | unheard |
| `patch/dx7-public-domain-prc-synth1-calibrated` | start | none | decays | 3 s | unheard |
| `patch/examples-language-tour` | start | none | sustained | 3 s | unheard |
| `patch/fm-bell-bell` | start | none | decays | 3 s | unheard |
| `patch/fm-bell-chime` | start | none | decays | 3 s | unheard |
| `patch/fm-bell-coin` | start | none | decays | 3 s | unheard |
| `patch/fm-bell-gong` | start | none | decays | 3 s | unheard |
| `patch/sfxr-blip` | start | none | decays | 3 s | unheard |
| `patch/sfxr-classic-classic-sfxr-abstract-golf` | start | none | sustained | 3 s | unheard |
| `patch/sfxr-explosion` | start | none | sustained | 3 s | unheard |
| `patch/sfxr-hit` | start | none | decays | 3 s | unheard |
| `patch/sfxr-jump` | start | none | decays | 3 s | unheard |
| `patch/sfxr-laser` | start | none | decays | 3 s | unheard |
| `patch/sfxr-pickup` | start | none | decays | 3 s | unheard |
| `patch/sfxr-powerup` | start | none | sustained | 3 s | unheard |
| `patch/wobble-bass-growl` | start | none | decays | 3 s | unheard |
| `patch/wobble-bass-neuro` | start | none | decays | 3 s | unheard |
| `patch/wobble-bass-talker` | start | none | decays | 3 s | unheard |
| `patch/wobble-bass-yoy` | start | none | decays | 3 s | unheard |
<!-- shelf:end -->
## Not on the shelf
- `tools/aqua-trial`'s `thruster-trial.dsp` (the thruster plus a `noise` percent control): a listening-trial fixture, not an effect.
- `advanced/additive-pad` and `advanced/electro-drop-demo`: they lower to about 1.5 MB of Faust each and are not in the playground.
- The Dings daemon's native libfaust path (`-vec`): the shelf renders through faustwasm, the same host as `tools/aqua-feel`; the two
have not been diffed.
- Nothing is mapped to StreamPixels events, and no overlay-side player exists in this repo.

View file

@ -3,5 +3,5 @@
"private": true,
"type": "module",
"description": "Headless feel meter for AquaSynth: renders every shipped patch and Ding offline and measures them. Measures; does not listen.",
"scripts": { "test": "node test.mjs", "feel": "node feel.mjs" }
"scripts": { "test": "node test.mjs", "feel": "node feel.mjs", "shelf": "node shelf.mjs build", "shelf:check": "node shelf.mjs check" }
}

2824
tools/aqua-feel/shelf.json Normal file

File diff suppressed because it is too large Load diff

180
tools/aqua-feel/shelf.mjs Normal file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env node
// The effects shelf: every sound AquaSynth can render headless today, as one machine-readable list (shelf.json) and a
// human page (SHELF.md), plus a check that runs each entry's render command and measures what it wrote.
// NOTHING HERE HEARS ANYTHING. `listening` stays "unheard" until a person edits it; the check proves a render is
// finite and not silent, which says nothing about whether it sounds good.
// Usage (from the repo root):
// node tools/aqua-feel/shelf.mjs build regenerate tools/aqua-feel/shelf.json and the table block of SHELF.md
// node tools/aqua-feel/shelf.mjs check [--only S] run every entry's render command, read the WAVs back, fail on silence/NaN/drift
// node tools/aqua-feel/shelf.mjs render ID [k=v ...] --out FILE.wav [--seconds N] render one patch or ding (what the entries call)
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { SR, SILENT_PEAK, samplePeak, measure } from "./metrics.js";
import { loadRuntime, compile, renderOnce, renderDing, writeWav, readText, playground } from "./render.js";
import { PATCH_SECONDS, DING_COMMAND_GAIN } from "./catalog.js";
import { EVENTS, IDENTITIES, DEFAULT_PARAMS, PARAM_RANGES, renderFamily, fileName } from "../earcon-workshop/web/earcon.js";
const here = dirname(fileURLToPath(import.meta.url)), root = resolve(here, "../..");
const SHELF_JSON = join(here, "shelf.json"), SHELF_MD = join(here, "SHELF.md");
const OUT = "tools/aqua-feel/out/shelf"; // relative to the repo root, gitignored
const BEGIN = "<!-- shelf:begin (generated by `node tools/aqua-feel/shelf.mjs build`; do not edit) -->", END = "<!-- shelf:end -->";
const dingsDir = join(here, "lowered-dings");
const r3 = (x) => Math.round(x * 1000) / 1000;
const args = process.argv.slice(2), cmd = args.shift();
const flag = (n) => { const i = args.indexOf(n); if (i < 0) return undefined; return args.splice(i, 2)[1]; };
function controlsOf(entry) { // Faust UI -> shelf params; the ranges are the DSP's own declarations
const out = [];
const walk = (items) => { for (const it of items ?? []) {
if (it.items) walk(it.items);
else if (it.address) {
const name = it.label.replace(/^\//, "");
if (/slider|nentry/i.test(it.type)) out.push({ name, kind: "slider", min: Number(it.min), max: Number(it.max), default: Number(it.init), step: Number(it.step) });
else out.push({ name, kind: it.type }); // button / checkbox: reported as declared, not interpreted
} } };
walk(JSON.parse(entry.g.factory.json).ui);
return out;
}
async function build() {
const rt = await loadRuntime(), entries = [];
const dir = join(playground, "lowered"), cat = JSON.parse(readText(join(dir, "catalog.json")));
for (const e of cat) {
const entry = await compile(rt, e.name, readText(join(dir, e.dsp)));
const params = controlsOf(entry), r = await renderOnce(entry, {}, PATCH_SECONDS), x = r.samples, tail = x.slice(x.length - Math.round(SR * 0.2));
const m = measure(x), sustained = samplePeak(tail) >= 0.1 * samplePeak(x);
const isThruster = e.name === "thruster";
entries.push({
id: `patch/${e.name}`, group: isThruster ? "thruster" : "patch", name: e.name, family: e.name.split("-")[0],
source: e.kind === "faust" ? `tools/aqua-playground/lowered/${e.dsp}` : `tools/aqua-playground/lowered/${e.dsp} (lowered from patches/${e.origin})`,
params,
reacts_to: isThruster
? { kind: "control", detail: "the rpm slider (300..6000, smoothed over 0.2 s): drive it from whatever number should make the thruster work harder. Nothing in the repo maps a stream metric to rpm." }
: { kind: "start", detail: "plays from the moment it is started; no gate, trigger or audio-feature input. Sliders, where listed, are live while it plays." },
behaviour: sustained ? "sustained: still sounding at the end of the render window, runs until stopped" : "decays: dies away inside the render window",
duration_s: PATCH_SECONDS, duration_note: "the render window, not the effect's length",
render: { command: `node tools/aqua-feel/shelf.mjs render patch/${e.name} --out ${OUT}/patch_${e.name}.wav`, outputs: [{ file: `${OUT}/patch_${e.name}.wav`, duration_s: PATCH_SECONDS }] },
...(isThruster ? { check_variants: [300, 6000].map((rpm) => ({ params: { rpm }, command: `node tools/aqua-feel/shelf.mjs render patch/${e.name} rpm=${rpm} --out ${OUT}/patch_${e.name}_rpm${rpm}.wav`, outputs: [{ file: `${OUT}/patch_${e.name}_rpm${rpm}.wav`, duration_s: PATCH_SECONDS }] })) } : {}),
listening: "unheard",
});
}
const d = JSON.parse(readText(join(dingsDir, "dings.json"))), instruments = d.instruments.map((i) => i.id), DEF_INS = "warm-bell";
for (const ev of d.events) {
const dur = r3(d.noteSeconds + Math.max(...ev.notes.map((n) => n.delayMilliseconds)) / 1000);
const one = (ins) => ({ command: `node tools/aqua-feel/shelf.mjs render ding/${ev.id} instrument=${ins} --out ${OUT}/ding_${ev.id}_${ins}.wav`, outputs: [{ file: `${OUT}/ding_${ev.id}_${ins}.wav`, duration_s: dur }] });
entries.push({
id: `ding/${ev.id}`, group: "ding", name: ev.id, family: "dings",
source: "tools/aqua-feel/lowered-dings (lowered from src DingCatalog)",
params: [{ name: "instrument", kind: "enum", values: instruments, default: DEF_INS, note: `no instrument is the shipped default in the catalog; ${DEF_INS} is the shelf's choice` }],
reacts_to: { kind: "event", event_id: ev.id, detail: `Ding event "${ev.id}": ${ev.meaning}. A Ding event is ${ev.notes.length} note(s) at semitones ${ev.notes.map((n) => n.semitones).join(", ")} above ${d.instruments[0].rootFrequency} Hz. Mapping stream events onto Ding events is not done anywhere.` },
behaviour: "decays",
duration_s: dur, duration_note: `${d.noteSeconds} s per note plus the last note's delay; command gain ${DING_COMMAND_GAIN} is an assumption (aqua-feel README)`,
render: one(DEF_INS),
check_variants: instruments.filter((i) => i !== DEF_INS).map((i) => ({ params: { instrument: i }, ...one(i) })),
listening: "unheard",
});
}
for (const [id, ident] of Object.entries(IDENTITIES)) {
const fam = renderFamily(id), outDir = `${OUT}/earcon/${id}`;
entries.push({
id: `earcon/${id}`, group: "earcon", name: id, family: "earcon-workshop",
source: "tools/earcon-workshop/web/earcon.js (plain JS reimplementation; not the Dings .aqua scripts, so it does not sound like the Dings)",
params: Object.entries(PARAM_RANGES).map(([name, [min, max, step]]) => ({ name, kind: "slider", min, max, step, default: DEFAULT_PARAMS[name] })),
reacts_to: { kind: "event", events: Object.entries(EVENTS).map(([k, v]) => ({ event_id: k, detail: v.meaning })), detail: "one WAV per event; the caller picks the file for the event it has. Voice: " + ident.note },
behaviour: "decays",
duration_s: r3(Math.max(...fam.items.map((i) => i.durationMs)) / 1000), duration_note: "longest of the six event WAVs at default sliders",
render: { command: `node tools/earcon-workshop/cli.mjs ${id} ${outDir}`, outputs: fam.items.map((i) => ({ file: `${outDir}/${fileName(id, i.event)}`, duration_s: r3(i.durationMs / 1000) })) },
listening: "unheard",
});
}
return { schema: "aquasynth.effects-shelf.v0", note: "Generated by tools/aqua-feel/shelf.mjs build. Nothing here was heard: every entry's listening status is 'unheard'. Commands run from the repo root.", sampleRate: SR, entries };
}
function tableBlock(shelf) {
const rng = (p) => p.kind === "slider" ? `${p.name} ${p.min}..${p.max} (default ${p.default})` : p.kind === "enum" ? `${p.name} ∈ {${p.values.join(", ")}} (default ${p.default})` : `${p.name} (${p.kind})`;
const L = [BEGIN, ""];
for (const [title, pick] of [["Thruster", (e) => e.group === "thruster"], ["Dings (event sounds)", (e) => e.group === "ding"], ["Earcon workshop (one WAV per event)", (e) => e.group === "earcon"], ["Playground patches (one-shot and sustained synth sounds)", (e) => e.group === "patch"]]) {
const es = shelf.entries.filter(pick);
L.push(`### ${title} (${es.length})`, "", "| id | reacts to | params | behaviour | duration | listening |", "|---|---|---|---|---|---|");
for (const e of es) {
const re = e.reacts_to.kind === "event" && e.reacts_to.events ? `events: ${e.reacts_to.events.map((v) => v.event_id).join(", ")}` : e.reacts_to.kind === "event" ? `event \`${e.reacts_to.event_id}\`` : e.reacts_to.kind === "control" ? "control: rpm" : "start";
L.push(`| \`${e.id}\` | ${re} | ${e.params.map(rng).join("; ") || "none"} | ${e.behaviour.split(":")[0]} | ${e.duration_s} s | ${e.listening} |`);
}
L.push("");
}
L.push(END);
return L.join("\n");
}
function writeShelf(shelf) {
writeFileSync(SHELF_JSON, JSON.stringify(shelf, null, 1) + "\n");
const md = readFileSync(SHELF_MD, "utf8"), a = md.indexOf(BEGIN), b = md.indexOf(END);
if (a < 0 || b < 0) throw new Error("SHELF.md has no generated block markers");
writeFileSync(SHELF_MD, md.slice(0, a) + tableBlock(shelf) + md.slice(b + END.length));
}
// ---- render: what the entries' commands call ------------------------------------------------------------------------
async function render() {
const out = flag("--out"), seconds = flag("--seconds"), [id, ...kv] = args;
if (!id || !out) { console.error("usage: shelf.mjs render ID [k=v ...] --out FILE.wav [--seconds N]"); process.exit(2); }
const params = Object.fromEntries(kv.map((s) => s.split("=")).map(([k, v]) => [k, isNaN(Number(v)) ? v : Number(v)]));
const rt = await loadRuntime(); let samples;
if (id.startsWith("patch/")) {
const name = id.slice(6), dir = join(playground, "lowered"), e = JSON.parse(readText(join(dir, "catalog.json"))).find((x) => x.name === name);
if (!e) { console.error(`no patch ${name}`); process.exit(2); }
samples = (await renderOnce(await compile(rt, name, readText(join(dir, e.dsp))), params, seconds ? Number(seconds) : PATCH_SECONDS)).samples;
} else if (id.startsWith("ding/")) {
const d = JSON.parse(readText(join(dingsDir, "dings.json"))), ev = d.events.find((x) => x.id === id.slice(5)), ins = d.instruments.find((x) => x.id === (params.instrument ?? "warm-bell"));
if (!ev || !ins) { console.error(`no ding event ${id.slice(5)} / instrument ${params.instrument}`); process.exit(2); }
samples = (await renderDing(rt, ins, readText(join(dingsDir, ins.dsp)), ev, { noteSeconds: d.noteSeconds, commandGain: DING_COMMAND_GAIN })).samples;
} else { console.error("render handles patch/ and ding/ ids; earcon entries use tools/earcon-workshop/cli.mjs"); process.exit(2); }
writeWav(resolve(out), samples);
console.log(`wrote ${out} (${(samples.length / SR).toFixed(3)} s)`);
}
// ---- check -----------------------------------------------------------------------------------------------------------
function readWav(path) { // PCM16 or float32 mono, as this repo's two writers produce
const b = readFileSync(path), v = new DataView(b.buffer, b.byteOffset, b.byteLength);
if (b.toString("latin1", 0, 4) !== "RIFF" || b.toString("latin1", 8, 12) !== "WAVE") throw new Error("not a RIFF/WAVE file");
const tag = v.getUint16(20, true), ch = v.getUint16(22, true), sr = v.getUint32(24, true), bits = v.getUint16(34, true), n = v.getUint32(40, true) / (bits / 8);
if (ch !== 1 || !((tag === 1 && bits === 16) || (tag === 3 && bits === 32))) throw new Error(`unsupported wav tag ${tag} bits ${bits} channels ${ch}`);
const x = new Float32Array(n);
for (let i = 0; i < n; i++) x[i] = tag === 3 ? v.getFloat32(44 + i * 4, true) : v.getInt16(44 + i * 2, true) / 32768;
return { sr, x };
}
async function check() {
const only = flag("--only"), problems = [], rows = [];
mkdirSync(join(root, OUT), { recursive: true });
const fresh = await build(), onDisk = existsSync(SHELF_JSON) ? readFileSync(SHELF_JSON, "utf8") : "";
if (onDisk !== JSON.stringify(fresh, null, 1) + "\n") problems.push("shelf.json is stale: run `node tools/aqua-feel/shelf.mjs build`");
const md = readFileSync(SHELF_MD, "utf8"), a = md.indexOf(BEGIN), b = md.indexOf(END);
if (a < 0 || md.slice(a, b + END.length) !== tableBlock(fresh)) problems.push("SHELF.md table block is stale: run `node tools/aqua-feel/shelf.mjs build`");
for (const e of fresh.entries.filter((x) => !only || x.id.includes(only))) {
if (e.listening !== "unheard") problems.push(`${e.id}: listening is '${e.listening}'; this check cannot confirm that`);
for (const r of [e.render, ...(e.check_variants ?? [])]) {
try {
execSync(r.command, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
for (const o of r.outputs) {
const { sr, x } = readWav(join(root, o.file)), peak = samplePeak(x), dur = x.length / sr;
if (sr !== SR) throw new Error(`${o.file}: sample rate ${sr}`);
if (!x.every(Number.isFinite)) throw new Error(`${o.file}: non-finite samples`);
if (peak < SILENT_PEAK) throw new Error(`${o.file}: silent (peak ${peak}, floor ${SILENT_PEAK.toFixed(6)})`);
if (Math.abs(dur - o.duration_s) > 0.01) throw new Error(`${o.file}: ${dur.toFixed(3)} s, shelf says ${o.duration_s} s`);
rows.push(`ok ${o.file.replace(OUT + "/", "")} peak ${(20 * Math.log10(peak)).toFixed(1)} dBFS ${dur.toFixed(3)} s [${r.command}]`);
}
} catch (err) { problems.push(`${e.id}: ${r.command}\n ${String(err.message ?? err).split("\n")[0]}`); }
}
}
console.log(rows.join("\n"));
console.log(`\n${rows.length} rendered file(s) checked across ${fresh.entries.filter((x) => !only || x.id.includes(only)).length} entries. Nothing here was heard.`);
if (problems.length) { console.log(`\nFAIL (${problems.length}):\n` + problems.map((p) => " " + p).join("\n")); process.exit(1); }
console.log("PASS: every render command ran, every WAV is finite, not silent, and as long as the shelf says; shelf.json and SHELF.md are current.");
}
const cmds = { build: async () => { const s = await build(); writeShelf(s); console.log(`wrote ${SHELF_JSON}: ${s.entries.length} entries`); }, render, check };
if (!cmds[cmd]) { console.error(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 9).join("\n")); process.exit(2); }
await cmds[cmd]();