diff --git a/AquaSynth.sln b/AquaSynth.sln index dfe1807..c968b46 100644 --- a/AquaSynth.sln +++ b/AquaSynth.sln @@ -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 diff --git a/docs/research/auditory-notifications/README.md b/docs/research/auditory-notifications/README.md new file mode 100644 index 0000000..dd9907d --- /dev/null +++ b/docs/research/auditory-notifications/README.md @@ -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 +. diff --git a/docs/research/auditory-notifications/foley-et-al-2022-more-detectable-less-annoying.pdf b/docs/research/auditory-notifications/foley-et-al-2022-more-detectable-less-annoying.pdf new file mode 100644 index 0000000..627086a Binary files /dev/null and b/docs/research/auditory-notifications/foley-et-al-2022-more-detectable-less-annoying.pdf differ diff --git a/docs/research/auditory-notifications/nist-tn-1950-auditory-warning-signals.pdf b/docs/research/auditory-notifications/nist-tn-1950-auditory-warning-signals.pdf new file mode 100644 index 0000000..4dac5d8 Binary files /dev/null and b/docs/research/auditory-notifications/nist-tn-1950-auditory-warning-signals.pdf differ diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 229d60f..9b6ba4e 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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"} diff --git a/state/map.yaml b/state/map.yaml index e0e9223..a4f262c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/state/spine.yaml b/state/spine.yaml index 1799608..8b441aa 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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. diff --git a/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj b/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj index 31d5142..add814b 100644 --- a/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj +++ b/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs new file mode 100644 index 0000000..cf9b60c --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs @@ -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); + } +} diff --git a/tools/AquaSynthDingsMcp/AquaDaemonClient.cs b/tools/AquaSynthDingsMcp/AquaDaemonClient.cs new file mode 100644 index 0000000..a4fcb69 --- /dev/null +++ b/tools/AquaSynthDingsMcp/AquaDaemonClient.cs @@ -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 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 { ["/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; + } +} diff --git a/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj b/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj new file mode 100644 index 0000000..4ecc302 --- /dev/null +++ b/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/tools/AquaSynthDingsMcp/DingAudioPlayer.cs b/tools/AquaSynthDingsMcp/DingAudioPlayer.cs new file mode 100644 index 0000000..9ef908f --- /dev/null +++ b/tools/AquaSynthDingsMcp/DingAudioPlayer.cs @@ -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 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(); } + } +} diff --git a/tools/AquaSynthDingsMcp/DingCatalog.cs b/tools/AquaSynthDingsMcp/DingCatalog.cs new file mode 100644 index 0000000..828e350 --- /dev/null +++ b/tools/AquaSynthDingsMcp/DingCatalog.cs @@ -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 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 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 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 ValidateCuration() + { + var failures = new List(); + 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); +} diff --git a/tools/AquaSynthDingsMcp/DingService.cs b/tools/AquaSynthDingsMcp/DingService.cs new file mode 100644 index 0000000..a4c8f44 --- /dev/null +++ b/tools/AquaSynthDingsMcp/DingService.cs @@ -0,0 +1,21 @@ +namespace AquaSynthDingsMcp; + +public sealed class DingService(AquaDaemonClient daemon, DingAudioPlayer player) +{ + public async Task 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(); +} diff --git a/tools/AquaSynthDingsMcp/DingTools.cs b/tools/AquaSynthDingsMcp/DingTools.cs new file mode 100644 index 0000000..c8d5e20 --- /dev/null +++ b/tools/AquaSynthDingsMcp/DingTools.cs @@ -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 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" }; } +} diff --git a/tools/AquaSynthDingsMcp/Program.cs b/tools/AquaSynthDingsMcp/Program.cs new file mode 100644 index 0000000..b0e901c --- /dev/null +++ b/tools/AquaSynthDingsMcp/Program.cs @@ -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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/tools/AquaSynthDingsMcp/README.md b/tools/AquaSynthDingsMcp/README.md new file mode 100644 index 0000000..54f7f16 --- /dev/null +++ b/tools/AquaSynthDingsMcp/README.md @@ -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`.