22 KiB
Dings patch identity: authority map
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 =
tools/AquaSynthDingsHost, Mcp = tools/AquaSynthDingsMcp.
Companion to docs/research/dings-playback-and-ogg-export.md, which describes the
playback path and an export sketch. This note asks a narrower question: in the Dings
path, what decides which sound "this patch" is, and how many places decide it.
What I counted
The follow-up says four owners. The source supports four places that decide a voice's identity, plus one place that names the same thing a second time without deciding, and one generated mirror:
| # | Owner | Decides | Section |
|---|---|---|---|
| 1 | DingCatalog |
the instrument id and the script that defines its sound | A |
| 2 | Host and daemon derivation | the daemon patchId, faustName, revision, session id and store file names |
B |
| 3 | MCP AgentInstrumentRegistry |
which instrument an agent session gets | C |
| 4 | tools/dings-voices |
which voice a sender (agent localpart) gets, in a separate synth | D |
| - | Earcon Workshop IDENTITIES |
reuses catalog instrument ids with its own parameters | E |
| - | tools/aqua-feel/lowered-dings |
checked-in copy of #1, guarded by a test | E |
The follow-up does not list its four, so this grouping is my reading. Someone who counted differently (for example splitting the daemon's session id from the store file name, or counting Earcon Workshop as a fifth) would get a different number from the same code. The grouping above follows who writes the key.
A. DingCatalog instrument id and script
- Where:
src/AquaSynth.Dings/DingCatalog.cs:22-28(four instruments), built by the privateInstrument(...)at:48-50, which writes theScriptstring (an AquaSynth patch script,param path=...; voice wave=..., thatFaustEmitter.EmitScriptlowers to Faust; not Faust source) and fixesRootFrequency = 261.6256ffor all four. - Keys on: the instrument
Idstring (warm-bell,glass-chime,soft-triangle,muted-pluck), case-insensitive dictionary (:28). Events are a separate keyed dictionary (:9-20, nine ids) and are not voice identity. - Lifetime: compiled in. Changing an entry means a new build of every consumer.
- Writer: whoever edits the file.
ValidateCuration(:31-46) is the only gate. - Readers:
Host/DingsPlaybackHost.cs:66-67,Mcp/DingService.cs:9-10,Mcp/AgentInstrumentRegistry.cs:11,Mcp/DingTools.cs:28,31,49,tools/AquaLower/Program.cs:17, and the testsDingCatalogTests. - Content hash: none. The id is a label; the script is the content. Nothing here says "this script, at this version".
B. Host and daemon derivation of the patch
The Host does not pass the catalog id through. It builds the daemon's identity fields itself, and the daemon builds more keys from them.
Host/AquaDaemonClient.cs:20per note:patchId = "dings." + instrument.Id,faustName = "dings_" + instrument.Id.Replace('-', '_'),revision = 1(a literal),durationSeconds = 1.5f,controls["/ding/frequency"].src/AquaSynth.Faust/NativeFaustRuntime.cs:596-599: the compile key is SHA-256 of"{revision}\n{faustName}\n{script}".patchIdis not in it. So two ids with the same name and script share a compile key; one id with an edited script gets a new key even thoughrevisionstays 1.src/AquaSynth.Faust/AquaSynthDaemonService.cs:1136: session id =StoreFileName.From(patchId) + "-" + compileKey[..12]. A recompile with the same session id disposes the old session (:366-376).src/AquaSynth.Core/StoreFileName.cs:26-54: the mapping from any id to a file name (ASCII letters, digits,-,_,.pass unchanged unless the id has an uppercase letter, a device stem, a trailing dot, is over 100 characters or already ends in a hash shape; anything else gets a 16-hex SHA-256 suffix,StoreFileName.cs:37,59-67).dings.warm-bellis a fixed point (pinned byStoreFileNameTests).- Keyed by patch: the session document.
WriteReceiptAsync("sessions", sessionId, session)(:407) runs on every compile, andSampleAsynccompiles on every note (:622-635), so the store holdssessions/<StoreFileName(sessionId)>.ccfor each distinct(patchId, compile key), rewritten on each note. This is the one store artifact addressable by instrument.NativeFaustRuntime.cs:516,628-636would also write<StoreFileName(patchId)>.dsp, but only whenoptions.DspSourceDirectoryis set; the daemon builds its native options from sample rate and render limits only (AquaSynthDaemonService.cs:330-334) and nothing else insrcortoolssets it, so for the Dings host it is off. (Read from source, not observed.) - Not keyed by patch: render output and the render and compile receipts. Samples are
<store>/samples/<StoreFileName(commandId + "-render")>.wav(:664-667), wherecommandIdis a freshding-<guid>per note (AquaDaemonClient.cs:19). Receipts go throughWriteReceiptAsync(:922-937) under command-derived ids, with the patch id only in the body. The store therefore keeps one WAV per note played, none addressable by instrument. Whether the store ever gets cleaned I did not find. - Faust name, three forms: the Host sends
dings_<id with - as _>; the daemon appliesSafeFaustName(NativeFaustRuntime.cs:602-607, called atAquaSynthDaemonService.cs:911andNativeFaustRuntime.cs:513) so the manifest and emitted source carry the rewritten name, while the compile key (:596-599) hashes the rawidentity.FaustName. Two raw names that sanitise alike compile alike but key differently. - The
dings_<id>rule is written three times:AquaDaemonClient.cs:20andtools/AquaLower/Program.cs:19, andDingCatalogTests.cs:45(LoweredDingsFedToTheFeelMeterMatchTheCatalogbuilds the name with the same expression). Nothing ties the three together except the test. - Writer: the Host (fields) and the daemon (session id, file names). Readers: the daemon's compile/render path and receipts; no Dings code reads the patch id back.
Host/AquaDaemonClient.cs:23-28returns the WAV path from the receipt. The Host never asks the daemon which patch rendered it.
C. MCP AgentInstrumentRegistry
- Where:
Mcp/AgentInstrumentRegistry.cs:10(dictionary),:13-17GetOrAssign,:19-29Claim,:31-36AssignDeterministically. - Keys on:
agentSessionId, which isserver.SessionId ?? "stdio:" + ProcessId(Mcp/DingTools.cs:45; thatSessionIdis null under stdio is inferred from the??fallback, not read from the SDK). That is the MCP transport session, not the agent. In HTTP mode (Mcp/Program.cs:34-54,Stateless = false) it lives as long as that MCP session; in stdio mode it is the bridge process id. - Value: an instrument id string. Unclaimed sessions get
SHA256(sessionId)[0..4] % sortedInstrumentIds.Length.Claimoverwrites silently, and there is no uniqueness check, so two sessions can hold the same instrument. - Lifetime: in-process, a
ConcurrentDictionary. Lost on bridge restart. A restarted or reconnected session gets a different session id, hence generally a different hashed instrument unless it claims again. (Inferred from the keying; I did not observe a reconnect.) - Writer: the agent, through
claim_instrument, or the hash. Reader:play_ding(DingTools.cs:16) andget_agent_instrument(:19). - Also decides:
DingService.PlayAsync(Mcp/DingService.cs:9-11) re-validates event, instrument and gain, and the Host validates the same three again (DingsPlaybackHost.cs:66-68). Two gates on one decision; they agree today because both readDingCatalog.
D. tools/dings-voices sender voices
- Where:
tools/dings-voices/src/voices.mjs:8(SENDERS, eight localparts),:51-73(VOICES, onehitfunction each),:13-18(RHYTHM, four motifs). - Keys on: sender localpart x motif (
mention,dm,message,invite), emitted assenders/<sender>/<motif>.oggandmanifest.json(finish.py:62, shape{neutral, senders:{localpart:{motif:path}}}). - It does not use
DingCatalog,AquaSynthDaemonor Faust. It is a JS synthesizer that imports only the sample rate fromearcon-workshop/web/earcon.js(voices.mjs:5). Its vocabulary (4 motifs, 8 senders) shares no ids with Aqua's (9 events, 4 instruments). - One link to the catalog family: the neutral fallback is rendered through the Earcon
Workshop
warm-bellidentity (build.mjs:10-14), with an event mapping ofmention=warning, dm=waiting, message=message, invite=done(:12) that the file itself calls provisional. - Lifetime: a build artifact. The files are not committed (README, "Not here"); the
consumer, Ratatoskr's
dings.ts, is outside this repo and I did not read it. - Writer: a person editing
voices.mjs;NIBU_PROPOSALS(:81-92) shows the selection of the default voice is a one-line assignment (VOICES.nibu = ...,:92). - This is the only place a sender is identity. Everything else in the Dings path keys on an MCP session.
E. Places that echo the identity without deciding it
-
Earcon Workshop
IDENTITIES,tools/earcon-workshop/web/earcon.js:22-27. Same four ids; its comment (:21) says values "start from the Dings catalog instruments and were not tuned by ear". The values have drifted from the catalog:id catalog root / decay ( DingCatalog.cs:24-27)workshop rootHz/decaywarm-bell 261.6256 / 1.15 261.6256 / 0.55 glass-chime 261.6256 / 0.85 329.6276 / 0.45 muted-pluck 261.6256 / 0.34 293.6648 / 0.25 Decay is read with different units in the two voice models, so only the root column is a clean disagreement. A "warm-bell" in the Ratatoskr neutral fallback is therefore not the "warm-bell" an MCP agent hears.
-
tools/aqua-feel/lowered-dings/(checked in):.dspper instrument anddings.json, produced byAquaLower --dings.DingCatalogTestsfails when it falls out of date (the test comment at the top ofLoweredDingsFedToTheFeelMeterMatchTheCatalognames the regeneration command). A mirror guarded by a test; not an independent owner.tools/aqua-feel/shelf.mjs:64andcatalog.js:22-24read it, andshelf.mjs:64defineswarm-bellas the shelf default (DEF_INS, used at:71,:75,:76), a fifth voice default beyond the registry's hash.
Where owners can disagree
- Name versus content. A is a name plus a script. B keys sessions on the content
(compile key) and labels receipts with the name. Editing a script in place keeps
patchId = dings.warm-bellandrevision = 1, so receipts from before and after the edit carry the same patch id and different session ids. Nothing in a receipt says which catalog version produced it. - Session versus agent. C assigns by MCP session; the stated intent in
Mcp/README.md:3-4is "which agent spoke". After a reconnect the same agent can change timbre. D, meanwhile, keys on the agent address localpart. No code maps a session id to a localpart, so the two cannot be joined today. - Four timbres versus eight senders. C picks among 4 catalog instruments by hash;
D has 8 hand-made voices. An agent can have one voice in
play_dingand a different one in its Ratatoskr notification, and nothing records either choice. - Same id, different sound.
warm-bellis a catalog instrument (A), a workshop identity (E) and the neutral fallback voice (D). Three definitions, parameter sets that have drifted (table above). - Derivation written three times.
dings_<id>inAquaDaemonClient.cs:20,AquaLower/Program.cs:19, and the test; and thedings.<id>patch id appears inStoreFileNameTestsas a literal. A rename rule changed in one place breaks store-file naming in another without a compile error. - Double validation.
DingService.cs:9-11andDingsPlaybackHost.cs:66-68each decide whether an id is valid. Safe while both read one dictionary.
The Host pipe protocol, as read from source
This is the "private pipe": the only channel into the audio host. MCP is a client of it; it is not itself the pipe.
Listener. AquaSynthDingsHost.exe. Host/Program.cs:3-4 takes the named mutex
Local\GameCult.AquaSynthDings.Playback.v1 and exits if another instance holds it, then
runs DingsPlaybackHost.RunAsync (:7). The accept loop is
DingsPlaybackHost.cs:12-20: one NamedPipeServerStream per accepted client, name
GameCult.AquaSynthDings.Playback.v1 (src/AquaSynth.Dings/DingsProtocol.cs:8),
PipeDirection.InOut, byte mode, MaxAllowedServerInstances, Asynchronous | CurrentUserOnly (:16). Each connection is handled fire-and-forget (:18). The host
uses NAudio WASAPI (Host/DingAudioPlayer.cs) and is Windows-only in practice.
Clients. Mcp/DingsHostClient.cs, used by the MCP bridge (both stdio and
--http) and by the bridge's direct flags --play, --get-volume, --set-volume,
--mute (Mcp/Program.cs:9-32). Nothing else in the repo opens the pipe.
Framing (identical in both directions):
[int32 length][MessagePack body]. The length is BitConverter.GetBytes(int), so
native byte order (little-endian on every platform this builds for, but the code does not
fix it). Valid length is 1 to 1,048,576 bytes; outside that the reader throws
(DingsPlaybackHost.cs:86-87, DingsHostClient.cs:42). Body is
MessagePackSerializer of the keyed records below. One request, one response, then the
server disposes the pipe (DingsPlaybackHost.cs:24-36). No streaming, no pipelining, no
request id.
Request DingsCommand (DingsProtocol.cs:13-19), keyed:
| key | field | note |
|---|---|---|
| 0 | Version |
must equal DingsProtocol.Version = 1, else status incompatible (DingsPlaybackHost.cs:41) |
| 1 | Kind |
Ping, Play, StopAll, GetVolume, SetVolume, SetMuted (:11) |
| 2 | EventId |
catalog event id, Play only |
| 3 | InstrumentId |
catalog instrument id, Play only |
| 4 | Value |
Play: gain 0..1; SetVolume: volume 0..1; SetMuted: >= .5 means muted |
Response DingsResponse (DingsProtocol.cs:21-31): Version, Succeeded,
Status, Message, PlaybackId, Volume, Muted, HostProcessId, SessionName
(default "AquaSynth Dings"). Every response carries current volume and mute, built at
DingsPlaybackHost.cs:79-80.
Per kind (DingsPlaybackHost.cs:39-62): Ping and GetVolume are the same branch
(status ready). SetVolume rejects non-finite or out-of-range values
(rejected) and otherwise sets audio.Volume (volume-set). SetMuted -> muted /
unmuted. StopAll -> stopped. Play (:64-77) validates event, instrument and
gain, renders each note through the daemon, queues each WAV with its delay, and answers
playing with a fresh ding-<guid> PlaybackId. The answer comes after rendering and
queueing, not after sound. PlaybackId is not stored and nothing takes it as input.
Any exception becomes Succeeded = false, Status = "failed", Message = ex.Message
(:32-35), provided the pipe is still connected.
Client behaviour (Mcp/DingsHostClient.cs:15-48): the client validates the
response Version (:46). Connect timeout 350 ms; on TimeoutException or
IOException it launches the host (StartHost, :50-56: newest
AquaSynthDingsHost.exe under tools/AquaSynthDingsHost/bin, hidden window, working
directory the repo root found by walking up to AquaSynth.sln) and retries up to 20
times at 500 ms connect with 100 ms gaps, all inside a 15 s overall timeout. A
Succeeded = false response is turned into InvalidOperationException(response.Message)
by DingService.EnsureSuccess (DingService.cs:22-26).
MCP side, for completeness. Tools (Mcp/DingTools.cs): play_ding,
get_agent_instrument, claim_instrument, list_ding_events, list_instruments,
stop_dings, get_volume, set_volume, mute_dings. Transports: stdio by default
(Program.cs:56-65); --http [url] serves Streamable HTTP at /mcp, default
http://127.0.0.1:17878, singleton by mutex Local\GameCult.AquaSynthDings.McpHttp.v1
(Program.cs:34-54). Only play_ding, get_agent_instrument, claim_instrument touch
agent identity; the volume and stop tools address the whole host and are
machine-global.
Not authenticated beyond the OS. CurrentUserOnly limits it to the same Windows
user. Any process of that user can send Play. There is no per-caller identity on the
pipe: DingsCommand carries an instrument, not a caller, so the Host cannot know who
asked.
PROPOSED: one owner (a recommendation for the stewarding decision, not decided)
Proposal. DingCatalog (src/AquaSynth.Dings) becomes the only place that decides
what a voice is, and also the only place that derives the daemon keys from it. Its
instrument gains a content revision (a hash of Script plus the parameters that matter),
and one function in that assembly produces PatchId, FaustName and the revision for
the daemon (AquaLower and the test need only the faust-name part of it, not
patchId or revision). Everything else keys on the catalog id and takes the rest from the catalog.
What the others become.
- B (Host/daemon): the Host stops composing
patchId/faustName/revision; it calls the catalog function.AquaLower/Program.cs:19andDingCatalogTests.cs:45call only the faust-name slice of it. The daemon keeps its session and file naming; those are daemon-internal and derive from what it is given. - C (registry): stays in the bridge as a cache of choices, but its assignment function moves beside the catalog (so a future export can call it) and its key becomes an agent identity rather than an MCP session id. This depends on the open question below.
- D (
dings-voices): stays a separate synthesizer for sender notifications, since its voices are JS functions with no Faust form. Its link to the owner is by reference: the neutral fallback names a catalog instrument id instead of a secondwarm-bell. Whether sender voices should ever become catalog instruments is a product fork, not part of this change. - E: Earcon Workshop
IDENTITIESeither read their shared values from a generated file (aslowered-dingsalready is) or drop the shared ids; the drifted values in the table above are resolved by whoever owns the workshop's tuning.
Why this owner. A already has the most readers (the list in section A), is the only definition of the script, and is the only one the host cannot run without. C, D and E are all consumers or approximations of it by name.
Migration order (each step independent and reversible):
- Add the catalog function; switch
AquaDaemonClient.cs:20andAquaLoweronto it; delete the test's private copy (see the oracle risk below). Pure refactor whilerevisionstays the literal 1: the compile key, and so the session id, stay equal for unchanged scripts, which is checkable by comparing receipts before and after. - Decide the agent-identity question (below); change the registry key. Wire-visible: existing sessions lose their hashed assignment.
- Content revision: DEFERRED, not scheduled here. Replacing the literal
revision = 1with a catalog hash changes every compile key and session id (and thesessions/<...>.ccfile names), so it breaks step 1's equality check by design. It needs its own decision on what feeds the hash and whether old session files are left or removed. - Remove the duplicate gates in
DingService.cs:9-11or have the Host's gate be the single one (the bridge then only forwards). Keep whichever side the tests (DingsBridgeTests) need for fail-fast, but one of them must be the owner. - Point the neutral fallback and Earcon Workshop at the catalog.
Risks.
- Step 2 changes what agents hear on first use after deploy; nobody has listened to a before/after.
- The deferred content revision changes session ids and session file names, visible in receipts but not read by anything I found.
- Oracle loss in step 1: deleting the test's private
"dings_" + idcopy makesLoweredDingsFedToTheFeelMeterMatchTheCatalogcompare the shared function against itself. The test should keep an independent expected value (a literal per instrument or the checked-in.dspheader) so a wrong rule still fails. - Collapsing 8 senders onto 4 instruments would lose distinctness; the proposal does not do that, and I am recommending against it without having listened to either set.
- Moving derivation to the catalog assembly makes
AquaSynth.Dingsthe home of daemon-protocol knowledge; if the daemon is expected to run without that assembly, this is the wrong direction. I did not check whether anything else wants that.
Confidence. Moderate on the owner choice; it follows reader counts and the write path, not any prior art on identity registries. Lower on step 2, because it depends on what the MCP bridge can learn about its caller.
Could not verify
- That "four owners" in the follow-up means these four. The count here is mine.
- Whether an MCP client can tell the bridge which agent it is. The only identity read in
source is
server.SessionId(DingTools.cs:45); I did not inspect the MCP SDK for client-info fields, or any harness config. - What Ratatoskr's
dings.tsactually reads and whether it shares any naming withDingCatalog; it is not in this repo. - Behaviour at runtime: reconnect session ids, store growth under
.aquasynth/dings-host, and whether the Host'sPing/GetVolumehave different callers in practice. The pipe protocol above is from reading, not from a captured exchange. Nothing was built, run, or listened to for this note.