Add curated AquaSynth dings MCP service
This commit is contained in:
parent
91c7c61832
commit
9abb7e4164
17 changed files with 426 additions and 0 deletions
93
tools/AquaSynthDingsMcp/AquaDaemonClient.cs
Normal file
93
tools/AquaSynthDingsMcp/AquaDaemonClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj
Normal file
14
tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj
Normal 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>
|
||||
44
tools/AquaSynthDingsMcp/DingAudioPlayer.cs
Normal file
44
tools/AquaSynthDingsMcp/DingAudioPlayer.cs
Normal 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(); }
|
||||
}
|
||||
}
|
||||
60
tools/AquaSynthDingsMcp/DingCatalog.cs
Normal file
60
tools/AquaSynthDingsMcp/DingCatalog.cs
Normal 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);
|
||||
}
|
||||
21
tools/AquaSynthDingsMcp/DingService.cs
Normal file
21
tools/AquaSynthDingsMcp/DingService.cs
Normal 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();
|
||||
}
|
||||
24
tools/AquaSynthDingsMcp/DingTools.cs
Normal file
24
tools/AquaSynthDingsMcp/DingTools.cs
Normal 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" }; }
|
||||
}
|
||||
25
tools/AquaSynthDingsMcp/Program.cs
Normal file
25
tools/AquaSynthDingsMcp/Program.cs
Normal 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();
|
||||
24
tools/AquaSynthDingsMcp/README.md
Normal file
24
tools/AquaSynthDingsMcp/README.md
Normal 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`.
|
||||
Loading…
Reference in a new issue