Add curated AquaSynth dings MCP service

This commit is contained in:
Meta Vens 2026-07-14 01:06:12 +01:00
commit 9abb7e4164
17 changed files with 426 additions and 0 deletions

View file

@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IpaTrialWorker", "tools\Ipa
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AquaSynthDaemon", "tools\AquaSynthDaemon\AquaSynthDaemon.csproj", "{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AquaSynthDingsMcp", "tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj", "{C38832D2-06A7-463E-8D99-C048ED51E9E1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -103,6 +105,10 @@ Global
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x64.Build.0 = Release|Any CPU
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x86.ActiveCfg = Release|Any CPU
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x86.Build.0 = Release|Any CPU
{C38832D2-06A7-463E-8D99-C048ED51E9E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C38832D2-06A7-463E-8D99-C048ED51E9E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C38832D2-06A7-463E-8D99-C048ED51E9E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C38832D2-06A7-463E-8D99-C048ED51E9E1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -114,5 +120,6 @@ Global
{A1914F24-9CE5-47DA-943E-E26D3AC4DFD3} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{C38832D2-06A7-463E-8D99-C048ED51E9E1} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,74 @@
# Auditory Notification Research Distillation
This ledger grounds AquaSynth Dings in auditory-interface research without
pretending that a desktop agent notification is a medical or aviation safety
alarm. The local PDFs are research inputs, not redistributed product assets.
## Findings that constrain the implementation
- **Use decaying, percussive envelopes.** Foley et al. found temporally varying,
percussive tones less annoying and more detectable than flat tones under
concurrent speech load. Every curated instrument therefore has a short attack,
brief sustain, and decaying tail. Flat beeps and broadband noise are rejected.
- **Map urgency through timing deliberately.** Haas and Casali compared 0, 150,
and 300 ms inter-pulse intervals; shorter intervals increased perceived
urgency without improving detection time. Routine AquaSynth motifs therefore
keep onset gaps in the 180–320 ms range. We do not make ordinary progress
notifications sound urgent merely to make them noticeable.
- **Keep pitch and spectral agitation restrained.** Hellier, Edworthy, and
Dennis found speed, fundamental frequency, repetition, and inharmonicity all
affect perceived urgency. The catalog stays in a calm mid register, uses
consonant C-major intervals, limits brightness, and forbids noisy timbres.
- **Do not rely on arbitrary melodies alone.** Sanderson, Wee, and Lacherez
found poor learning and persistent confusion in a standardized melodic alarm
set. Here the motif grammar is small and systematic: landing pitch and motion
carry event class, while timbre independently identifies the speaking agent.
- **Expect masking when sounds overlap.** Bolton et al. experimentally showed
that masking a tonal alarm's primary harmonic can make it indistinguishable.
The service mixes concurrent sounds, but the curated catalog intentionally
separates timbre families and exposes `stop_dings`; a later field test should
measure identification under realistic overlap before expanding the catalog.
- **Prefer a small, tested vocabulary.** Studies of IEC melodic alarms and
overlapping alarms show that nominal distinctness is not reliable human
discriminability. New instruments require listening/identification trials;
parameter validation is only admission hygiene.
## Implemented policy
- Event selects motif; instrument selects agent identity.
- Instrument source is catalog-only. MCP callers cannot inject patch scripts.
- Root frequency: 196–392 Hz.
- Brightness curation score: 0.20–0.78.
- Decay: 0.25–1.50 seconds; attacks remain short and nonzero where practical.
- No broadband-noise instruments.
- Multi-note onset gaps: 180–320 ms.
- One persistent playback mixer owns the output device.
- Aqua daemon remains the sole synthesis authority.
## Sources
1. Foley, L. et al. (2022), *More detectable, less annoying: Temporal
variation in amplitude envelope and spectral content improves auditory
interface efficacy*, JASA 151(5), DOI `10.1121/10.0010447`. Saved as
`foley-et-al-2022-more-detectable-less-annoying.pdf`.
2. Hellier, E. J., Edworthy, J., & Dennis, I. (1993), *Improving auditory
warning design: quantifying and predicting the effects of different warning
parameters on perceived urgency*, Human Factors 35(4), DOI
`10.1177/001872089303500408`.
3. Haas, E. C., & Casali, J. G. (1993), *The Perceived Urgency and Detection
Time of Multi-Tone and Frequency-Modulated Warning Signals*, HFES Annual
Meeting, DOI `10.1177/154193129303700906`.
4. Sanderson, P. M., Wee, A., & Lacherez, P. (2006), *Learnability and
discriminability of melodic medical equipment alarms*, Anaesthesia 61(2),
DOI `10.1111/j.1365-2044.2005.04502.x`.
5. Lacherez, P., Seah, E. L., & Sanderson, P. M. (2007), *Overlapping Melodic
Alarms Are Almost Indiscriminable*, Human Factors 49(4), DOI
`10.1518/001872007X215719`.
6. Bolton, M. L. et al. (2020), *An Experimental Validation of Masking in IEC
60601-1-8:2006-Compliant Alarm Sounds*, Human Factors 62(6), DOI
`10.1177/0018720819862911`.
7. NIST Technical Note 1950, *Auditory Warning Signals*. Saved as
`nist-tn-1950-auditory-warning-signals.pdf`.
The source README for the event vocabulary is
<https://github.com/iain/minimal-dings/blob/main/minimal-dings/README.md>.

View file

@ -287,3 +287,4 @@
{"timestamp":"2026-06-15T03:55:00+01:00","topic":"aquasynth-daemon-live-session-state","claim":"Live AquaSynth daemon sessions now publish retained aquasynth.live_instrument_session.v1 state under .aquasynth/live-sessions/{sessionId}.cc. AquaSynthDaemonService rewrites it on open/control/block/close with status, current controls, block size, next sequence, and last command/receipt ids, so clients can inspect current session truth without replaying event receipts.","evidence":"src/AquaSynth.Faust/AquaSynthDaemonService.cs; src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs; tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs; docs/verse-service-contract.md; focused AquaSynthDaemonServiceTests passed 5/5"}
{"timestamp":"2026-06-15T04:16:00+01:00","topic":"aquasynth-provider-advertisement-live-session-schemas","claim":"The static Eve provider advertisement now exposes the actual live daemon session command/state schemas instead of the old abstract instrument_handle/control_session pair. The generator owns the fixture, and the generated JSON advertises compiled sessions, retained live session state, open/control/block/close commands, and matching receipts.","evidence":"tools/export-provider-advertisement.mjs; docs/fixtures/aquasynth-provider-advertisement.json; docs/verse-service-contract.md"}
{"timestamp":"2026-06-15T02:35:00+01:00","topic":"greater-daemon-composition-rule","claim":"The daemonic swarm docs now state the greater-daemon composition rule: smaller daemon capabilities compose upward through typed command/receipt surfaces, while larger workflow daemons own orchestration and aggregate receipts without absorbing lower-daemon truth, provenance, rejections, or failures.","evidence":"docs/gamecult-daemonic-swarm.md; docs/daemonic-swarm-prior-work.md"}
{"timestamp":"2026-07-14T00:00:00+01:00","topic":"auditory-notification-curation","claim":"Routine agent earcons should use percussive decaying envelopes, restrained mid-register consonant pitch, and 180-320 ms onset gaps. Shorter gaps, higher pitch, repetition, and inharmonicity increase perceived urgency, while similar overlapping tonal melodies can mask and confuse. Mechanical bounds are admission hygiene; curated instruments still require listening and overlap-identification trials.","evidence":"docs/research/auditory-notifications/README.md; doi:10.1121/10.0010447; doi:10.1177/154193129303700906; doi:10.1177/001872089303500408; doi:10.1111/j.1365-2044.2005.04502.x; doi:10.1177/0018720819862911"}

View file

@ -17,6 +17,8 @@ surfaces:
daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs
cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs
daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj
dings_mcp_service: tools/AquaSynthDingsMcp
auditory_notification_research: docs/research/auditory-notifications/README.md
verse_service_contract: docs/verse-service-contract.md
daemonic_swarm_article: docs/gamecult-daemonic-swarm.md
daemonic_swarm_prior_work: docs/daemonic-swarm-prior-work.md

View file

@ -58,6 +58,7 @@ current_mechanism:
live_invariants:
- AquaSynth owns its patch graph; external synths are references, not runtime dependencies.
- AquaSynthDaemonService owns compile/render/stream/live-session execution for patch.compile, instrument.sample, bounded instrument.stream automation, instrument.open, instrument.control, instrument.block, and instrument.close. It delegates DSP to AquaSynth.Faust, writes compile/session/render/stream/live/live-session-state/operator .cc witnesses under the configured .aquasynth store, writes .f32/.wav sample artifacts, keeps native streaming patch instances for live sessions, rewrites retained aquasynth.live_instrument_session.v1 state for open/closed status, current controls, next sequence, and last command/receipt ids, releases live sessions without deleting reusable compiled patch witnesses, and emits CultMesh-compatible audio/control stream descriptors plus per-block packet receipts. AquaSynthCultNetDaemon owns the local CultNet command-observation boundary: typed command documents enter the CultNet database, the daemon serializes execution through AquaSynthDaemonService, and typed receipt/provider documents come back out. JSON-lines modes are only operator/xeno edges; they must not become second audio/session authorities.
- AquaSynthDingsMcp is a consumer-owned stdio MCP and playback organ. Event IDs own the Minimal Dings motif; curated instrument IDs own agent timbre; AquaSynthDaemon remains the sole synthesis authority. The MCP service owns one NAudio output mixer, accepts no arbitrary patch source, and admits instruments through bounded register/brightness/decay/no-noise checks plus required listening and overlap-identification review. Research provenance and timing policy live in docs/research/auditory-notifications/README.md.
- Patch scripts lower into explicit graph records before Faust emission.
- Song-snippet parity is scene-audio pressure, not redistributed fixture data. The worker may derive local tempo/register/scale hints from private clips, but agents must express live rhythm and pitch motion through owned AquaSynth controls until native sequencing sugar has an explicit DSL/lowering owner.
- Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields.

View file

@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj" />
<Using Include="Xunit" />
<Using Include="AquaSynth.Faust" />
</ItemGroup>

View file

@ -0,0 +1,35 @@
using AquaSynthDingsMcp;
namespace AquaSynth.Dsl.Tests;
public sealed class DingCatalogTests
{
[Fact]
public void CuratedInstrumentsStayDistinctAndInsidePleasantnessEnvelope() =>
Assert.Empty(DingCatalog.ValidateCuration());
[Fact]
public void MinimalDingsVocabularyIsComplete()
{
string[] expected = ["session.start", "task.acknowledge", "task.complete", "task.error", "input.required", "resource.limit", "user.spam", "session.end", "task.progress"];
Assert.Equal(expected.Order(), DingCatalog.Events.Keys.Order());
}
[Fact]
public void InstrumentsUseOneSemanticPitchControlAndNoNoise()
{
foreach (var instrument in DingCatalog.Instruments.Values)
{
Assert.Contains("/ding/frequency", instrument.Script, StringComparison.Ordinal);
Assert.DoesNotContain("wave=noise", instrument.Script, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void MultiNoteMotifsUseNonUrgentOnsetSpacing()
{
foreach (var dingEvent in DingCatalog.Events.Values)
foreach (var pair in dingEvent.Notes.Zip(dingEvent.Notes.Skip(1)))
Assert.InRange(pair.Second.DelayMilliseconds - pair.First.DelayMilliseconds, 180, 320);
}
}

View file

@ -0,0 +1,93 @@
using System.Diagnostics;
using System.Text.Json;
namespace AquaSynthDingsMcp;
public sealed class AquaDaemonClient : IAsyncDisposable
{
private readonly SemaphoreSlim gate = new(1, 1);
private readonly JsonSerializerOptions json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private Process? process;
public async Task<string> RenderNoteAsync(DingInstrument instrument, float frequency, float gain, CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
EnsureStarted();
var id = $"ding-{Guid.NewGuid():N}";
var envelope = new
{
command = "instrument.sample",
payload = new
{
commandId = id,
patchId = $"dings.{instrument.Id}",
faustName = $"dings_{instrument.Id.Replace('-', '_')}",
script = instrument.Script,
durationSeconds = 1.5f,
gain,
controls = new Dictionary<string, float> { ["/ding/frequency"] = frequency },
revision = 1
}
};
await process!.StandardInput.WriteLineAsync(JsonSerializer.Serialize(envelope, json));
await process.StandardInput.FlushAsync(cancellationToken);
var line = await process.StandardOutput.ReadLineAsync(cancellationToken)
?? throw new InvalidOperationException("Aqua daemon closed before returning a render receipt.");
using var receipt = JsonDocument.Parse(line);
var render = receipt.RootElement.GetProperty("renderReceipt");
if (!string.Equals(render.GetProperty("status").GetString(), "succeeded", StringComparison.Ordinal))
throw new InvalidOperationException(render.TryGetProperty("failureMessage", out var failure) ? failure.GetString() : "Aqua render failed.");
return new Uri(render.GetProperty("wavUri").GetString()!).LocalPath;
}
finally { gate.Release(); }
}
private void EnsureStarted()
{
if (process is { HasExited: false }) return;
var root = FindRepositoryRoot();
var daemonDll = ResolveDaemonDll(root);
var arguments = $"\"{daemonDll}\" daemon --store \"{Path.Combine(root, ".aquasynth", "dings-mcp")}\"";
process = Process.Start(new ProcessStartInfo("dotnet", arguments)
{
WorkingDirectory = root,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}) ?? throw new InvalidOperationException("Could not start AquaSynth daemon.");
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) Console.Error.WriteLine($"[aqua] {e.Data}"); };
process.BeginErrorReadLine();
}
private static string ResolveDaemonDll(string root)
{
var configured = Environment.GetEnvironmentVariable("AQUASYNTH_DAEMON_DLL");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) return Path.GetFullPath(configured);
var bin = Path.Combine(root, "tools", "AquaSynthDaemon", "bin");
var candidate = Directory.EnumerateFiles(bin, "AquaSynthDaemon.dll", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
return candidate ?? throw new InvalidOperationException("AquaSynthDaemon.dll is not built. Build AquaSynthDingsMcp once or set AQUASYNTH_DAEMON_DLL.");
}
private static string FindRepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) return dir.FullName;
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir is not null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) return dir.FullName;
throw new InvalidOperationException("Cannot locate AquaSynth.sln. Start the MCP service from the AquaSynth repository or set AQUASYNTH_DAEMON_DLL.");
}
public ValueTask DisposeAsync()
{
if (process is { HasExited: false }) process.Kill(entireProcessTree: true);
process?.Dispose();
gate.Dispose();
return ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AquaSynthDaemon\AquaSynthDaemon.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,44 @@
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
namespace AquaSynthDingsMcp;
public sealed class DingAudioPlayer : IDisposable
{
private readonly object sync = new();
private readonly MixingSampleProvider mixer = new(WaveFormat.CreateIeeeFloatWaveFormat(44100, 2)) { ReadFully = true };
private readonly WaveOutEvent output;
private readonly List<IDisposable> readers = [];
public DingAudioPlayer()
{
output = new WaveOutEvent { DesiredLatency = 80 };
output.Init(mixer);
output.Play();
}
public void Play(string wavPath, int delayMilliseconds, float gain)
{
var reader = new AudioFileReader(wavPath) { Volume = gain };
var cleanup = new OffsetSampleProvider(reader.ToStereo())
{
DelayBy = TimeSpan.FromMilliseconds(delayMilliseconds),
Take = reader.TotalTime + TimeSpan.FromMilliseconds(delayMilliseconds)
};
lock (sync) readers.Add(reader);
mixer.AddMixerInput(cleanup);
_ = Task.Delay(cleanup.Take + TimeSpan.FromMilliseconds(100)).ContinueWith(_ =>
{
lock (sync) { readers.Remove(reader); reader.Dispose(); }
}, TaskScheduler.Default);
}
public void StopAll() => mixer.RemoveAllMixerInputs();
public void Dispose()
{
output.Stop();
output.Dispose();
lock (sync) { foreach (var reader in readers) reader.Dispose(); readers.Clear(); }
}
}

View file

@ -0,0 +1,60 @@
namespace AquaSynthDingsMcp;
public sealed record DingNote(double Semitones, int DelayMilliseconds, float Gain = 1f);
public sealed record DingEvent(string Id, string Meaning, IReadOnlyList<DingNote> Notes);
public sealed record DingInstrument(
string Id,
string Description,
string TimbreFamily,
string Script,
float RootFrequency,
float Brightness,
float DecaySeconds,
bool UsesNoise);
public static class DingCatalog
{
public static readonly IReadOnlyDictionary<string, DingEvent> Events = new DingEvent[]
{
new("session.start", "Opening welcome", [new(0, 0), new(7, 220)]),
new("task.acknowledge", "Quiet ongoing acknowledgement", [new(7, 0)]),
new("task.complete", "Satisfying closure", [new(7, 0), new(12, 220)]),
new("task.error", "Gentle failure notice", [new(7, 0), new(4, 240), new(0, 480)]),
new("input.required", "Input requested", [new(12, 0), new(5, 260)]),
new("resource.limit", "Resource is holding", [new(0, 0), new(0, 280), new(0, 560)]),
new("user.spam", "Curt rate warning", [new(9, 0), new(9, 190), new(9, 380)]),
new("session.end", "Session closure", [new(7, 0), new(0, 240)]),
new("task.progress", "Faint ignorable progress", [new(-5, 0, .45f)])
}.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase);
public static readonly IReadOnlyDictionary<string, DingInstrument> Instruments = new DingInstrument[]
{
Instrument("warm-bell", "Warm cabin bell; soft fundamental and restrained upper partial", "rounded-fm-bell", "sine", .17f, .004f, .035f, 1.15f, .34f, "fm=3.01 fm_index=2.6 fm_decay=.42 lpf=.86"),
Instrument("glass-chime", "Clear glass chime; bright and precise", "clear-fm-chime", "sine", .12f, .001f, .025f, .85f, .72f, "fm=4.12 fm_index=4.1 fm_decay=.28 hpf=.04"),
Instrument("soft-triangle", "Rounded triangle voice; calm and distinct", "filtered-triangle", "triangle", .14f, .006f, .04f, .9f, .46f, "lpf=.72"),
Instrument("muted-pluck", "Short muted pluck; dry and unobtrusive", "muted-pluck", "triangle", .13f, .001f, .018f, .34f, .56f, "lpf=.58 drive=.05")
}.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase);
public static IReadOnlyList<string> ValidateCuration()
{
var failures = new List<string>();
foreach (var instrument in Instruments.Values)
{
if (instrument.RootFrequency is < 196 or > 392) failures.Add($"{instrument.Id}: root leaves the calm mid register");
if (instrument.Brightness is < .2f or > .78f) failures.Add($"{instrument.Id}: brightness leaves the pleasantness window");
if (instrument.DecaySeconds is < .25f or > 1.5f) failures.Add($"{instrument.Id}: decay leaves the notification window");
if (instrument.UsesNoise) failures.Add($"{instrument.Id}: broadband noise is forbidden");
}
var duplicateFamilies = Instruments.Values.GroupBy(x => x.TimbreFamily).Where(x => x.Count() > 1).Select(x => x.Key);
failures.AddRange(duplicateFamilies.Select(x => $"timbre family '{x}' is not distinct"));
foreach (var dingEvent in Events.Values)
{
var gaps = dingEvent.Notes.Zip(dingEvent.Notes.Skip(1), (left, right) => right.DelayMilliseconds - left.DelayMilliseconds);
if (gaps.Any(gap => gap is < 180 or > 320)) failures.Add($"{dingEvent.Id}: onset gap leaves the researched routine-notification window");
}
return failures;
}
private static DingInstrument Instrument(string id, string description, string family, string wave, float gain, float attack, float sustain, float decay, float brightness, string color) =>
new(id, description, family, $"param path=/ding/frequency default=261.6256 min=40 max=4000 step=.001; voice wave={wave} freq=@/ding/frequency gain={gain} attack={attack} sustain={sustain} decay={decay} {color}", 261.6256f, brightness, decay, false);
}

View file

@ -0,0 +1,21 @@
namespace AquaSynthDingsMcp;
public sealed class DingService(AquaDaemonClient daemon, DingAudioPlayer player)
{
public async Task<object> PlayAsync(string eventId, string instrumentId, float gain, CancellationToken cancellationToken)
{
if (!DingCatalog.Events.TryGetValue(eventId, out var dingEvent)) throw new ArgumentException($"Unknown event '{eventId}'.");
if (!DingCatalog.Instruments.TryGetValue(instrumentId, out var instrument)) throw new ArgumentException($"Unknown instrument '{instrumentId}'.");
if (gain is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(gain), "Gain must be between 0 and 1.");
var playbackId = $"ding-{Guid.NewGuid():N}";
foreach (var note in dingEvent.Notes)
{
var frequency = instrument.RootFrequency * MathF.Pow(2, (float)note.Semitones / 12f);
var wav = await daemon.RenderNoteAsync(instrument, frequency, gain * note.Gain, cancellationToken);
player.Play(wav, note.DelayMilliseconds, 1f);
}
return new { playbackId, eventId = dingEvent.Id, instrumentId = instrument.Id, status = "playing" };
}
public void StopAll() => player.StopAll();
}

View file

@ -0,0 +1,24 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace AquaSynthDingsMcp;
[McpServerToolType]
public sealed class DingTools(DingService service)
{
[McpServerTool, Description("Synthesizes and audibly plays a semantic notification motif. Event says what happened; instrument identifies the agent by timbre.")]
public Task<object> PlayDing(
[Description("Semantic event such as task.complete or input.required.")] string eventId,
[Description("Registered timbre such as warm-bell or glass-chime.")] string instrumentId,
[Description("Playback gain from 0 to 1.")] float gain = .7f,
CancellationToken cancellationToken = default) => service.PlayAsync(eventId, instrumentId, gain, cancellationToken);
[McpServerTool, Description("Lists the semantic notification events and their meanings.")]
public object ListDingEvents() => DingCatalog.Events.Values.Select(x => new { x.Id, x.Meaning, noteCount = x.Notes.Count }).ToArray();
[McpServerTool, Description("Lists registered agent timbres available to play_ding.")]
public object ListInstruments() => DingCatalog.Instruments.Values.Select(x => new { x.Id, x.Description, x.TimbreFamily, x.Brightness, x.DecaySeconds }).ToArray();
[McpServerTool, Description("Stops all notification sounds currently being mixed by this MCP service.")]
public object StopDings() { service.StopAll(); return new { status = "stopped" }; }
}

View file

@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using AquaSynthDingsMcp;
if (args is ["--play", var eventId, var instrumentId])
{
await using var daemon = new AquaDaemonClient();
using var audio = new DingAudioPlayer();
var service = new DingService(daemon, audio);
await service.PlayAsync(eventId, instrumentId, .7f, CancellationToken.None);
await Task.Delay(TimeSpan.FromSeconds(2.5));
return;
}
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddSingleton<AquaDaemonClient>();
builder.Services.AddSingleton<DingAudioPlayer>();
builder.Services.AddSingleton<DingService>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<DingTools>();
await builder.Build().RunAsync();

View file

@ -0,0 +1,24 @@
# AquaSynth Dings MCP
Stdio MCP service for synthesized agent notifications. The event motif says
what happened; the curated instrument timbre says which agent spoke. The
service starts/calls `AquaSynthDaemon`, then owns speaker playback through one
persistent NAudio mixer.
```powershell
dotnet run --project tools/AquaSynthDingsMcp
```
For a direct synthesis/playback smoke outside an MCP host:
```powershell
dotnet run --project tools/AquaSynthDingsMcp -- --play task.complete warm-bell
```
The project builds and launches `AquaSynthDaemon.dll` as its synthesis process.
Set `AQUASYNTH_DAEMON_DLL` only to select a different built daemon. MCP tools are `play_ding`,
`list_ding_events`, `list_instruments`, and `stop_dings`.
Instrument availability is catalog-only. Additions must satisfy mechanical
curation checks and then survive listening/identification tests; see
`docs/research/auditory-notifications/README.md`.