aqua-feel: effects shelf for overlay packaging (unheard) #10
5 changed files with 3134 additions and 1 deletions
|
|
@ -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
125
tools/aqua-feel/SHELF.md
Normal 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.
|
||||
|
|
@ -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
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
180
tools/aqua-feel/shelf.mjs
Normal 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]();
|
||||
Loading…
Reference in a new issue