Dings migration step 5a: neutral fallback id tied to the catalog; steps 2, 3, 5b recorded STOPPED #46
5 changed files with 23 additions and 8 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# Dings patch identity: authority map
|
||||
|
||||
**Status (2026-11-12).** Done: step 1 (identity from the catalog, `2e28f56`), step 4 (one validation gate, `e188424`), step 5a (neutral fallback id tied to the catalog mirror, this change). Stopped, with the unblock for each in the Migration plan: step 2 (needs the MCP caller-identity fact and a listening), step 3 (needs a ruling that breaks the byte-identity rule), step 5b (needs a generated file, an owner's decision on drifted values and an ear check).
|
||||
|
||||
Serves follow-up `aqua-body:follow_up:aquasynth-dings-authority-map`. Claims are from
|
||||
source at `c0fe4ee` (origin/main). Nothing was built or run; the one place behaviour is
|
||||
inferred rather than read is marked. Paths are repo-relative; `Host` =
|
||||
|
|
@ -343,13 +345,13 @@ Rules for every step: deprecate before removing (mark the old path with a pointe
|
|||
- Rollback: revert the commit; no stored state or wire format moved.
|
||||
- Echo sites in this step: the `lowered-dings` mirror is tied to the catalog by `AquaLower` calling `Identity` and stays test-guarded. Earcon Workshop `IDENTITIES` is NOT tied: it is browser JS with its own voice model (`fmRatio`, `harmonics`, seconds-based decay) and shares only ids, so tying needs a generated file and a build step, which is step 5. It is marked deprecated with a pointer, and still works.
|
||||
|
||||
**Step 2. Agent identity for the registry** (`AgentInstrumentRegistry` key). Blocked on the open question (what the bridge can learn about its caller). Verify: a test that the same agent id maps to the same instrument across sessions, and listening before and after, which nobody has done. Deprecate: keep the session-id keyed path behind the new key for one release. Rollback: revert; assignments are an in-memory cache.
|
||||
**Step 2. Agent identity for the registry** (`AgentInstrumentRegistry` key). STOPPED, not implemented. Needs a fact nobody has: what the MCP bridge can learn about its caller. The only identity read in source is `server.SessionId` (`tools/AquaSynthDingsMcp/DingTools.cs:45`); the MCP SDK's client-info fields and the harness configuration were not inspected. It also needs a before/after listening that nobody has done, because a new key changes which instrument an agent hears on first use after deploy. Unblock with: (1) the fact, i.e. whether the MCP SDK / harness exposes a stable agent or client identity to a server tool call (supplied by whoever reads the SDK source or runs the bridge under the harness: Aqua can read the SDK, the harness owner can say what a client sends); (2) a ruling on which field becomes the key; (3) an operator listening of old vs new assignment for the agents in use. Verify: a test that the same agent id maps to the same instrument across sessions, and the listening above. Deprecate: keep the session-id keyed path behind the new key for one release. Rollback: revert; assignments are an in-memory cache.
|
||||
|
||||
**Step 3. Content revision** (replace the literal 1 with a catalog hash). Deliberately breaks byte-identity: compile keys, session ids and `sessions/<...>.cc` names all change. Needs its own ruling on what feeds the hash and whether old session files are left or removed. Verify: receipts before and after, with the old keys listed as expected to change. Rollback: revert to literal 1; new session files stay as orphans, harmless.
|
||||
**Step 3. Content revision** (replace the literal 1 with a catalog hash). STOPPED, not implemented. It deliberately breaks byte-identity: compile keys, session ids and `sessions/<...>.cc` names all change, which is contrary to the standing rule above that they stay byte-identical until this step. So it needs its own ruling before anyone cuts: what feeds the hash (script text only, or events, root frequency and control path too) and whether old session files are left or removed. Unblock with that ruling, from the operator (the migration owner who set the rule); Aqua can draft the options but not choose. Verify: receipts before and after, with the old keys listed as expected to change. Rollback: revert to literal 1; new session files stay as orphans, harmless.
|
||||
|
||||
**Step 4. One validation gate (done in this change).** Finding: both gates already read the same `DingCatalog.Events` / `DingCatalog.Instruments` dictionaries, so the change is the single rule text, not the data. The Host was already the only enforcing gate (every pipe client passes it); the bridge check is a pre-filter that cannot be forwarded away without a visible change (bridge throws `ArgumentException` / `ArgumentOutOfRangeException`, the Host path surfaces as `InvalidOperationException`; the bridge also lets a NaN gain through to the Host's rejection). So: `DingCatalog.PlayRejection(eventId, instrumentId, gain)` is now the one rule and returns the Host's exact rejection text or null; `DingsPlaybackHost.PlayAsync` calls it (same texts, same `rejected` status, same non-finite gain rejection). `DingService.PlayAsync` keeps its own check unchanged, marked DEPRECATED with a pointer comment; remove it only in a separate PR after the window. No protocol change, no identity change, no audible change.
|
||||
- Verify (ran): `DingCatalogTests` + `DingsBridgeTests` + all `Ding*`/`StoreFileName*` focused tests (79 passed, includes the step-1 identity pins untouched); `dotnet build tools/AquaSynthDingsHost` clean. New tests pin the gate texts (incl. NaN/Infinity), the exact guard statement in the Host source (`PlayRejection(...) is { } x` followed by `return Response(false, "rejected", x)`, comment-stripped; a source-shape check, not a behaviour check, so it cannot see a bypass built some other way), and that the deprecated bridge path keeps its exception types, sends nothing to the Host when it rejects, and agrees with the gate's texts. Each was mutation-checked (text change, gate call removed from Host, bridge exception type/text changed, NaN check dropped): each mutation failed at least one test.
|
||||
- Not verified: the Host rejecting over a live pipe (compile-checked and read; no Host test project exists and none was added); nothing heard.
|
||||
- Rollback: revert; both gates read one dictionary, so behaviour is the same either way.
|
||||
|
||||
**Step 5. Echo sites by reference.** (a) Neutral fallback in `tools/dings-voices/build.mjs` names a catalog instrument id. (b) Earcon Workshop `IDENTITIES`: export the shared ids and root frequencies from the catalog into a checked-in file (as `lowered-dings` is), guarded by a test, and have `earcon.js` read it; the voice-model parameters stay workshop-owned and the drifted values (table in section E) need an owner's decision first. Deprecation: `IDENTITIES` keeps its inline values, marked, until the generated file is read in production. Verify: `tools/earcon-workshop/test.mjs`, the guard test, and an ear check, since the workshop values were never tuned to the catalog. Rollback: revert; the inline values still exist.
|
||||
**Step 5. Echo sites by reference.** (a) DONE in this change. `tools/dings-voices/src/voices.mjs:11` now holds `NEUTRAL_INSTRUMENT_ID = "warm-bell"` (was a literal in `build.mjs`), `build.mjs:13` renders `renderFamily(NEUTRAL_INSTRUMENT_ID)`, and `tools/dings-voices/src/neutral_instrument.test.mjs` asserts the id is one of the `tools/aqua-feel/lowered-dings/*.dsp` names, the checked-in mirror that `AquaLower` generates from `DingCatalog` and `DingCatalogTests.LoweredDingsFedToTheFeelMeterMatchTheCatalog` (`tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs:92`) guards. The constant lives in `voices.mjs` because `build.mjs` writes files on import and cannot be loaded by a test. The test is wired into the `Run` line of `tools/dings-voices/README.md` (dings-voices has no package.json or test runner; `polyphony.test.mjs` is run the same way). Verify (ran): `node src/neutral_instrument.test.mjs` passes (1 assertion file, 2 asserts); changing the id to `bogus-bell` makes it fail with `neutral instrument "bogus-bell" is not a catalog id`; `node src/polyphony.test.mjs` still passes; `node src/build.mjs` writes `neutral-{dm,invite,mention,message}.wav` with identical sha256 before and after (`a3bf9076...`, `4a4652d6...`, `25938d0e...`, `26f1d74a...`). No audible change; nothing heard. Rollback: revert; nothing else reads the constant. (b) STOPPED, not implemented. Intent: export the shared ids and root frequencies from the catalog into a checked-in file (as `lowered-dings` is), guarded by a test, and have `earcon.js` read it. It needs a generated file plus a build step, and the Earcon Workshop is browser JS with its own voice model (`tools/earcon-workshop/web/earcon.js:24`, `IDENTITIES`), so the generator has no existing home. It also needs an owner's decision on the drifted values (table in section E: root 329.6276 vs 261.6256 for glass-chime, 293.6648 vs 261.6256 for muted-pluck; decay is in different units) and an ear check, since the workshop values were never tuned to the catalog. Unblock with: (1) the workshop owner's ruling per drifted value: catalog wins, workshop wins, or the id is dropped from `IDENTITIES`; (2) a ruling on where the generator runs (AquaLower flag or a node script); (3) an operator ear check of the four identities after the change. Deprecation: `IDENTITIES` keeps its inline values, marked, until the generated file is read in production. Verify: `tools/earcon-workshop/test.mjs`, the guard test, and the ear check. Rollback: revert; the inline values still exist.
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ 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`, `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.
|
||||
- `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/neutral_instrument.test.mjs` - the neutral fallback's instrument id (`NEUTRAL_INSTRUMENT_ID` in `voices.mjs`) is a `DingCatalog` id present in `tools/aqua-feel/lowered-dings/*.dsp`. `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.
|
||||
|
||||
Sound regression guard over every clip this generator and the Earcon Workshop ship (onsets per note, clicks, clipping, true peak, tails, loudness): `python3 tools/sound-guard/guard.py` (see `../sound-guard/README.md`).
|
||||
|
||||
Run: `node src/polyphony.test.mjs && node src/build.mjs raw && python src/finish.py raw out && python src/verify.py .` (`build.mjs` also renders the four neutral fallbacks, Earcon Workshop `warm-bell` at default sliders with mention=warning, dm=waiting, message=message, invite=done, and `finish.py` equalises them to the same A-weighted exposure as the senders; `out/manifest.json` lists them under `neutral`).
|
||||
Run: `node src/polyphony.test.mjs && node src/neutral_instrument.test.mjs && node src/build.mjs raw && python src/finish.py raw out && python src/verify.py .` (`build.mjs` also renders the four neutral fallbacks, Earcon Workshop `warm-bell` at default sliders with mention=warning, dm=waiting, message=message, invite=done, and `finish.py` equalises them to the same A-weighted exposure as the senders; `out/manifest.json` lists them under `neutral`).
|
||||
|
||||
## Publishing (one place)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
// node build.mjs <rawDir>: render every sender x motif at unit scale to float32 mono WAV (finish.py does loudness, ogg and metrics).
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { SENDERS, MOTIFS, renderClip, VOICES, RHYTHM, NIBU_PROPOSALS } from "./voices.mjs";
|
||||
import { SENDERS, MOTIFS, renderClip, VOICES, RHYTHM, NIBU_PROPOSALS, NEUTRAL_INSTRUMENT_ID } from "./voices.mjs";
|
||||
import { SR, renderFamily } from "../../earcon-workshop/web/earcon.js";
|
||||
const out = process.argv[2]; mkdirSync(out, { recursive: true });
|
||||
const wav32 = (x) => { const b = Buffer.alloc(44 + x.length * 4); b.write("RIFF", 0); b.writeUInt32LE(36 + x.length * 4, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(3, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(32, 34); b.write("data", 36); b.writeUInt32LE(x.length * 4, 40); x.forEach((v, i) => b.writeFloatLE(v, 44 + i * 4)); return b; };
|
||||
const NAMES = [...SENDERS, ...Object.keys(NIBU_PROPOSALS)];
|
||||
for (const s of NAMES) for (const m of MOTIFS) writeFileSync(join(out, `${s}-${m}.wav`), wav32(renderClip(s, m)));
|
||||
// Neutral fallback: the Earcon Workshop warm-bell identity at default sliders (renderFamily peak-normalises the whole family), one event per motif.
|
||||
// Neutral fallback: the Earcon Workshop identity named by NEUTRAL_INSTRUMENT_ID at default sliders (renderFamily peak-normalises the whole family), one event per motif.
|
||||
// The mapping is provisional and was chosen for distinctness only (it matches the Ratatoskr handoff b3eb9d01); finish.py equalises these to the sender median.
|
||||
export const NEUTRAL_EVENT = { mention: "warning", dm: "waiting", message: "message", invite: "done" };
|
||||
const fam = renderFamily("warm-bell");
|
||||
const fam = renderFamily(NEUTRAL_INSTRUMENT_ID);
|
||||
for (const m of MOTIFS) writeFileSync(join(out, `neutral-${m}.wav`), wav32(fam.items.find((it) => it.event === NEUTRAL_EVENT[m]).samples));
|
||||
writeFileSync(join(out, "voices.json"), JSON.stringify({ voices: Object.fromEntries([...SENDERS.map((s) => [s, VOICES[s]]), ...Object.entries(NIBU_PROPOSALS)].map(([s, v]) => [s, { object: v.object, family: v.family, caption: v.caption }])), rhythm: RHYTHM }, null, 1));
|
||||
console.log(`wrote ${NAMES.length * MOTIFS.length} raw clips and ${MOTIFS.length} neutral clips to ${out}`);
|
||||
|
|
|
|||
11
tools/dings-voices/src/neutral_instrument.test.mjs
Normal file
11
tools/dings-voices/src/neutral_instrument.test.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// node neutral_instrument.test.mjs: the instrument id build.mjs renders for the neutral fallback is a DingCatalog id.
|
||||
// The catalog's checked-in mirror is tools/aqua-feel/lowered-dings/*.dsp (AquaLower generates it; a dotnet test guards it).
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { NEUTRAL_INSTRUMENT_ID } from "./voices.mjs";
|
||||
const dir = fileURLToPath(new URL("../../aqua-feel/lowered-dings/", import.meta.url));
|
||||
const ids = readdirSync(dir).filter((f) => f.endsWith(".dsp")).map((f) => f.slice(0, -4));
|
||||
assert.ok(ids.length > 0, `no .dsp files in ${dir}`);
|
||||
assert.ok(ids.includes(NEUTRAL_INSTRUMENT_ID), `neutral instrument "${NEUTRAL_INSTRUMENT_ID}" is not a catalog id (${ids.join(", ")})`);
|
||||
console.log(`ok: ${NEUTRAL_INSTRUMENT_ID} in catalog mirror {${ids.join(", ")}}`);
|
||||
|
|
@ -7,6 +7,8 @@ import { SR } from "../../earcon-workshop/web/earcon.js";
|
|||
const TAU = Math.PI * 2;
|
||||
export const SENDERS = ["aqua", "eureka", "heimdall", "kiko", "libby", "nibu", "void", "weksa"];
|
||||
export const MOTIFS = ["mention", "dm", "message", "invite"];
|
||||
// The DingCatalog instrument id whose Earcon Workshop voice build.mjs renders as the neutral fallback; neutral_instrument.test.mjs ties it to the catalog mirror.
|
||||
export const NEUTRAL_INSTRUMENT_ID = "warm-bell";
|
||||
|
||||
// Shared rhythm family: onset times in ms and a rising scale degree (semitones) per onset. Rhythm tells the event:
|
||||
// mention = one note, message = quick pair, dm = slow pair, invite = long gap then short (an opening, then in).
|
||||
|
|
|
|||
Loading…
Reference in a new issue