Add curated AquaSynth dings MCP service
This commit is contained in:
parent
91c7c61832
commit
9abb7e4164
17 changed files with 426 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
74
docs/research/auditory-notifications/README.md
Normal file
74
docs/research/auditory-notifications/README.md
Normal 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>.
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj" />
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AquaSynth.Faust" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
35
tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs
Normal file
35
tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
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