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

@ -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`.