From 348d677248b0bb4db684028abe198dee9ee82bc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 01:18:09 +0100 Subject: [PATCH] Centralize Dings playback and volume --- src/AquaSynth.Dings/AquaSynth.Dings.csproj | 10 ++ .../AquaSynth.Dings}/DingCatalog.cs | 15 +-- src/AquaSynth.Dings/DingsProtocol.cs | 32 ++++++ state/evidence.jsonl | 1 + state/map.yaml | 2 + state/spine.yaml | 2 +- .../AquaSynth.Dsl.Tests.csproj | 1 + tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs | 2 +- tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs | 66 +++++++++++ tools/AquaSynthDingsHost/AquaDaemonClient.cs | 59 ++++++++++ .../AquaSynthDingsHost.csproj | 14 +++ tools/AquaSynthDingsHost/DingAudioPlayer.cs | 77 +++++++++++++ tools/AquaSynthDingsHost/DingsPlaybackHost.cs | 106 ++++++++++++++++++ tools/AquaSynthDingsHost/Program.cs | 7 ++ tools/AquaSynthDingsMcp/AquaDaemonClient.cs | 93 --------------- .../AquaSynthDingsMcp.csproj | 4 +- tools/AquaSynthDingsMcp/DingAudioPlayer.cs | 44 -------- tools/AquaSynthDingsMcp/DingService.cs | 26 +++-- tools/AquaSynthDingsMcp/DingTools.cs | 12 +- tools/AquaSynthDingsMcp/DingsHostClient.cs | 64 +++++++++++ tools/AquaSynthDingsMcp/Program.cs | 27 ++++- tools/AquaSynthDingsMcp/README.md | 21 ++-- 22 files changed, 507 insertions(+), 178 deletions(-) create mode 100644 src/AquaSynth.Dings/AquaSynth.Dings.csproj rename {tools/AquaSynthDingsMcp => src/AquaSynth.Dings}/DingCatalog.cs (88%) create mode 100644 src/AquaSynth.Dings/DingsProtocol.cs create mode 100644 tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs create mode 100644 tools/AquaSynthDingsHost/AquaDaemonClient.cs create mode 100644 tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj create mode 100644 tools/AquaSynthDingsHost/DingAudioPlayer.cs create mode 100644 tools/AquaSynthDingsHost/DingsPlaybackHost.cs create mode 100644 tools/AquaSynthDingsHost/Program.cs delete mode 100644 tools/AquaSynthDingsMcp/AquaDaemonClient.cs delete mode 100644 tools/AquaSynthDingsMcp/DingAudioPlayer.cs create mode 100644 tools/AquaSynthDingsMcp/DingsHostClient.cs diff --git a/src/AquaSynth.Dings/AquaSynth.Dings.csproj b/src/AquaSynth.Dings/AquaSynth.Dings.csproj new file mode 100644 index 0000000..fbb42b7 --- /dev/null +++ b/src/AquaSynth.Dings/AquaSynth.Dings.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/tools/AquaSynthDingsMcp/DingCatalog.cs b/src/AquaSynth.Dings/DingCatalog.cs similarity index 88% rename from tools/AquaSynthDingsMcp/DingCatalog.cs rename to src/AquaSynth.Dings/DingCatalog.cs index 828e350..4da53ec 100644 --- a/tools/AquaSynthDingsMcp/DingCatalog.cs +++ b/src/AquaSynth.Dings/DingCatalog.cs @@ -1,16 +1,8 @@ -namespace AquaSynthDingsMcp; +namespace AquaSynth.Dings; 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 sealed record DingInstrument(string Id, string Description, string TimbreFamily, string Script, float RootFrequency, float Brightness, float DecaySeconds, bool UsesNoise); public static class DingCatalog { @@ -45,8 +37,7 @@ public static class DingCatalog 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")); + failures.AddRange(Instruments.Values.GroupBy(x => x.TimbreFamily).Where(x => x.Count() > 1).Select(x => $"timbre family '{x.Key}' is not distinct")); foreach (var dingEvent in Events.Values) { var gaps = dingEvent.Notes.Zip(dingEvent.Notes.Skip(1), (left, right) => right.DelayMilliseconds - left.DelayMilliseconds); diff --git a/src/AquaSynth.Dings/DingsProtocol.cs b/src/AquaSynth.Dings/DingsProtocol.cs new file mode 100644 index 0000000..df280fc --- /dev/null +++ b/src/AquaSynth.Dings/DingsProtocol.cs @@ -0,0 +1,32 @@ +using MessagePack; + +namespace AquaSynth.Dings; + +public static class DingsProtocol +{ + public const int Version = 1; + public const string PipeName = "GameCult.AquaSynthDings.Playback.v1"; + public const string MutexName = "Local\\GameCult.AquaSynthDings.Playback.v1"; +} + +public enum DingsCommandKind { Ping, Play, StopAll, GetVolume, SetVolume, SetMuted } + +[MessagePackObject] +public sealed record DingsCommand( + [property: Key(0)] int Version, + [property: Key(1)] DingsCommandKind Kind, + [property: Key(2)] string EventId = "", + [property: Key(3)] string InstrumentId = "", + [property: Key(4)] float Value = 0); + +[MessagePackObject] +public sealed record DingsResponse( + [property: Key(0)] int Version, + [property: Key(1)] bool Succeeded, + [property: Key(2)] string Status, + [property: Key(3)] string Message = "", + [property: Key(4)] string PlaybackId = "", + [property: Key(5)] float Volume = 1, + [property: Key(6)] bool Muted = false, + [property: Key(7)] int HostProcessId = 0, + [property: Key(8)] string SessionName = "AquaSynth Dings"); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9b6ba4e..c6dd556 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -288,3 +288,4 @@ {"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"} +{"timestamp":"2026-07-15T01:30:00+01:00","topic":"dings-playback-authority","claim":"AquaSynth Dings playback is owned by one persistent per-user AquaSynthDingsHost apphost. It owns the named WASAPI session, mixer, Aqua daemon child, and live Windows session master volume/mute. Per-task MCP processes are typed named-pipe bridges with no NAudio output or local fallback; every play, stop, volume, and mute path commits through the host.","evidence":"src/AquaSynth.Dings; tools/AquaSynthDingsHost; tools/AquaSynthDingsMcp; DingsBridgeTests; release smoke reported one host PID 27740 and shared volume/mute state across independent bridge invocations"} diff --git a/state/map.yaml b/state/map.yaml index a4f262c..77d511e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -18,6 +18,8 @@ surfaces: cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj dings_mcp_service: tools/AquaSynthDingsMcp + dings_audio_host: tools/AquaSynthDingsHost + dings_contract: src/AquaSynth.Dings 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 diff --git a/state/spine.yaml b/state/spine.yaml index 8b441aa..fdd10e1 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -58,7 +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. + - AquaSynthDingsHost is the single per-user playback owner: one named shared-mode WASAPI session, one mixer, one Aqua daemon child, and the live Windows session master volume/mute authority. AquaSynthDingsMcp instances are typed named-pipe bridges only and contain no audio output or fallback mixer. Event IDs own the Minimal Dings motif; curated instrument IDs own agent timbre; AquaSynthDaemon remains the synthesis authority. The shared AquaSynth.Dings contract owns catalog and MessagePack command/receipt types. 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 add814b..436c6ad 100644 --- a/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj +++ b/tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs index cf9b60c..4c45a01 100644 --- a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs +++ b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs @@ -1,4 +1,4 @@ -using AquaSynthDingsMcp; +using AquaSynth.Dings; namespace AquaSynth.Dsl.Tests; diff --git a/tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs b/tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs new file mode 100644 index 0000000..959235d --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs @@ -0,0 +1,66 @@ +using AquaSynth.Dings; +using AquaSynthDingsMcp; + +namespace AquaSynth.Dsl.Tests; + +public sealed class DingsBridgeTests +{ + [Fact] + public async Task SeparateBridgesObserveOneHostVolumeAuthority() + { + var host = new FakeHostClient(); + var first = new DingService(host); + var second = new DingService(host); + + await first.SetVolumeAsync(.27f, CancellationToken.None); + var observed = await second.GetVolumeAsync(CancellationToken.None); + + Assert.Equal(.27f, observed.Volume); + Assert.All(host.Commands, command => Assert.Equal(DingsProtocol.Version, command.Version)); + } + + [Fact] + public async Task PerPlayGainDoesNotMutateMasterVolume() + { + var host = new FakeHostClient { Volume = .4f }; + var service = new DingService(host); + + await service.PlayAsync("task.complete", "warm-bell", .7f, CancellationToken.None); + + Assert.Equal(.4f, host.Volume); + Assert.Equal(.7f, host.Commands.Single().Value); + } + + [Fact] + public void BridgeProjectContainsNoAudioOutputOwner() + { + var root = RepositoryRoot(); + var bridge = Path.Combine(root, "tools", "AquaSynthDingsMcp"); + var source = string.Join('\n', Directory.EnumerateFiles(bridge, "*.cs").Select(File.ReadAllText)); + Assert.DoesNotContain("WaveOutEvent", source, StringComparison.Ordinal); + Assert.DoesNotContain("WasapiOut", source, StringComparison.Ordinal); + Assert.DoesNotContain("MixingSampleProvider", source, StringComparison.Ordinal); + } + + private static string RepositoryRoot() + { + 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; + throw new InvalidOperationException("Repository root not found."); + } + + private sealed class FakeHostClient : IDingsHostClient + { + public List Commands { get; } = []; + public float Volume { get; set; } = 1; + public bool Muted { get; set; } + + public Task SendAsync(DingsCommand command, CancellationToken cancellationToken) + { + Commands.Add(command); + if (command.Kind == DingsCommandKind.SetVolume) Volume = command.Value; + if (command.Kind == DingsCommandKind.SetMuted) Muted = command.Value >= .5f; + return Task.FromResult(new DingsResponse(DingsProtocol.Version, true, "ok", Volume: Volume, Muted: Muted, HostProcessId: 42)); + } + } +} diff --git a/tools/AquaSynthDingsHost/AquaDaemonClient.cs b/tools/AquaSynthDingsHost/AquaDaemonClient.cs new file mode 100644 index 0000000..2d5a15e --- /dev/null +++ b/tools/AquaSynthDingsHost/AquaDaemonClient.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using System.Text.Json; +using AquaSynth.Dings; + +namespace AquaSynthDingsHost; + +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 = Directory.EnumerateFiles(Path.Combine(root, "tools", "AquaSynthDaemon", "bin"), "AquaSynthDaemon.dll", SearchOption.AllDirectories).OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault() + ?? throw new InvalidOperationException("AquaSynthDaemon.dll is not built."); + process = Process.Start(new ProcessStartInfo("dotnet", $"\"{daemonDll}\" daemon --store \"{Path.Combine(root, ".aquasynth", "dings-host")}\"") { 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 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; + throw new InvalidOperationException("Cannot locate AquaSynth.sln."); + } + + public ValueTask DisposeAsync() + { + if (process is { HasExited: false }) process.Kill(entireProcessTree: true); + process?.Dispose(); + gate.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj b/tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj new file mode 100644 index 0000000..bfe4732 --- /dev/null +++ b/tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + enable + enable + AquaSynthDingsHost + + + + + + + diff --git a/tools/AquaSynthDingsHost/DingAudioPlayer.cs b/tools/AquaSynthDingsHost/DingAudioPlayer.cs new file mode 100644 index 0000000..f94fbaf --- /dev/null +++ b/tools/AquaSynthDingsHost/DingAudioPlayer.cs @@ -0,0 +1,77 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; +using NAudio.Wave.SampleProviders; + +namespace AquaSynthDingsHost; + +public sealed class DingAudioPlayer : IDisposable +{ + private static readonly Guid SessionGroup = new("8414a5ca-a680-4b7c-9f17-cdb954177f9d"); + private readonly object sync = new(); + private readonly MixingSampleProvider mixer = new(WaveFormat.CreateIeeeFloatWaveFormat(44100, 2)) { ReadFully = true }; + private readonly MMDevice device; + private readonly WasapiOut output; + private readonly List readers = []; + private AudioSessionControl? session; + + public DingAudioPlayer() + { + using var enumerator = new MMDeviceEnumerator(); + device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + output = new WasapiOut(device, AudioClientShareMode.Shared, true, 80); + output.Init(mixer); + output.Play(); + session = FindSession(); + if (session is not null) + { + session.DisplayName = "AquaSynth Dings"; + session.SetGroupingParam(SessionGroup, Guid.Empty); + } + } + + public float Volume { get => RequireSession().SimpleAudioVolume.Volume; set => RequireSession().SimpleAudioVolume.Volume = value; } + public bool Muted { get => RequireSession().SimpleAudioVolume.Mute; set => RequireSession().SimpleAudioVolume.Mute = value; } + + public void Play(string wavPath, int delayMilliseconds) + { + var reader = new AudioFileReader(wavPath); + var input = new OffsetSampleProvider(reader.ToStereo()) { DelayBy = TimeSpan.FromMilliseconds(delayMilliseconds), Take = reader.TotalTime + TimeSpan.FromMilliseconds(delayMilliseconds) }; + lock (sync) { readers.Add(reader); mixer.AddMixerInput(input); } + _ = Task.Delay(input.Take + TimeSpan.FromMilliseconds(150)).ContinueWith(_ => + { + lock (sync) { mixer.RemoveMixerInput(input); readers.Remove(reader); reader.Dispose(); } + }, TaskScheduler.Default); + } + + public void StopAll() + { + lock (sync) { mixer.RemoveAllMixerInputs(); foreach (var reader in readers) reader.Dispose(); readers.Clear(); } + } + + private AudioSessionControl RequireSession() => session ??= FindSession() ?? throw new InvalidOperationException("AquaSynth Dings WASAPI session is unavailable."); + + private AudioSessionControl? FindSession() + { + for (var attempt = 0; attempt < 20; attempt++) + { + device.AudioSessionManager.RefreshSessions(); + var sessions = device.AudioSessionManager.Sessions; + for (var i = 0; i < sessions.Count; i++) + { + var candidate = sessions[i]; + if (candidate.GetProcessID == (uint)Environment.ProcessId) return candidate; + } + Thread.Sleep(25); + } + return null; + } + + public void Dispose() + { + StopAll(); + session?.Dispose(); + output.Stop(); + output.Dispose(); + device.Dispose(); + } +} diff --git a/tools/AquaSynthDingsHost/DingsPlaybackHost.cs b/tools/AquaSynthDingsHost/DingsPlaybackHost.cs new file mode 100644 index 0000000..d0ffae3 --- /dev/null +++ b/tools/AquaSynthDingsHost/DingsPlaybackHost.cs @@ -0,0 +1,106 @@ +using System.IO.Pipes; +using AquaSynth.Dings; +using MessagePack; + +namespace AquaSynthDingsHost; + +public sealed class DingsPlaybackHost : IAsyncDisposable +{ + private readonly AquaDaemonClient daemon = new(); + private readonly DingAudioPlayer audio = new(); + + public async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var pipe = new NamedPipeServerStream(DingsProtocol.PipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.WaitForConnectionAsync(cancellationToken); + _ = HandleClientAsync(pipe, cancellationToken); + } + } + + private async Task HandleClientAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + await using (pipe) + { + try + { + var command = await ReadAsync(pipe, cancellationToken); + var response = await ExecuteAsync(command, cancellationToken); + await WriteAsync(pipe, response, cancellationToken); + } + catch (Exception ex) + { + if (pipe.IsConnected) await WriteAsync(pipe, Response(false, "failed", ex.Message), CancellationToken.None); + } + } + } + + private async Task ExecuteAsync(DingsCommand command, CancellationToken cancellationToken) + { + if (command.Version != DingsProtocol.Version) return Response(false, "incompatible", $"Protocol {command.Version} is not supported."); + switch (command.Kind) + { + case DingsCommandKind.Ping: + case DingsCommandKind.GetVolume: + return Response(true, "ready"); + case DingsCommandKind.SetVolume: + if (!float.IsFinite(command.Value) || command.Value is < 0 or > 1) return Response(false, "rejected", "Volume must be between 0 and 1."); + audio.Volume = command.Value; + return Response(true, "volume-set"); + case DingsCommandKind.SetMuted: + audio.Muted = command.Value >= .5f; + return Response(true, audio.Muted ? "muted" : "unmuted"); + case DingsCommandKind.StopAll: + audio.StopAll(); + return Response(true, "stopped"); + case DingsCommandKind.Play: + return await PlayAsync(command, cancellationToken); + default: + return Response(false, "rejected", "Unknown command."); + } + } + + private async Task PlayAsync(DingsCommand command, CancellationToken cancellationToken) + { + if (!DingCatalog.Events.TryGetValue(command.EventId, out var dingEvent)) return Response(false, "rejected", $"Unknown event '{command.EventId}'."); + if (!DingCatalog.Instruments.TryGetValue(command.InstrumentId, out var instrument)) return Response(false, "rejected", $"Unknown instrument '{command.InstrumentId}'."); + if (!float.IsFinite(command.Value) || command.Value is < 0 or > 1) return Response(false, "rejected", "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, command.Value * note.Gain, cancellationToken); + audio.Play(wav, note.DelayMilliseconds); + } + return Response(true, "playing", playbackId: playbackId); + } + + private DingsResponse Response(bool succeeded, string status, string message = "", string playbackId = "") => + new(DingsProtocol.Version, succeeded, status, message, playbackId, audio.Volume, audio.Muted, Environment.ProcessId); + + internal static async Task ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var header = new byte[4]; + await stream.ReadExactlyAsync(header, cancellationToken); + var length = BitConverter.ToInt32(header); + if (length is <= 0 or > 1024 * 1024) throw new InvalidDataException("Invalid Dings frame length."); + var payload = new byte[length]; + await stream.ReadExactlyAsync(payload, cancellationToken); + return MessagePackSerializer.Deserialize(payload); + } + + internal static async Task WriteAsync(Stream stream, T value, CancellationToken cancellationToken) + { + var payload = MessagePackSerializer.Serialize(value); + await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken); + await stream.WriteAsync(payload, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + audio.Dispose(); + await daemon.DisposeAsync(); + } +} diff --git a/tools/AquaSynthDingsHost/Program.cs b/tools/AquaSynthDingsHost/Program.cs new file mode 100644 index 0000000..858bfaa --- /dev/null +++ b/tools/AquaSynthDingsHost/Program.cs @@ -0,0 +1,7 @@ +using AquaSynthDingsHost; + +using var singleton = new Mutex(initiallyOwned: true, "Local\\GameCult.AquaSynthDings.Playback.v1", out var ownsMutex); +if (!ownsMutex) return; + +await using var host = new DingsPlaybackHost(); +await host.RunAsync(CancellationToken.None); diff --git a/tools/AquaSynthDingsMcp/AquaDaemonClient.cs b/tools/AquaSynthDingsMcp/AquaDaemonClient.cs deleted file mode 100644 index a4fcb69..0000000 --- a/tools/AquaSynthDingsMcp/AquaDaemonClient.cs +++ /dev/null @@ -1,93 +0,0 @@ -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 index 4ecc302..3fcead4 100644 --- a/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj +++ b/tools/AquaSynthDingsMcp/AquaSynthDingsMcp.csproj @@ -6,9 +6,9 @@ enable - + + - diff --git a/tools/AquaSynthDingsMcp/DingAudioPlayer.cs b/tools/AquaSynthDingsMcp/DingAudioPlayer.cs deleted file mode 100644 index 9ef908f..0000000 --- a/tools/AquaSynthDingsMcp/DingAudioPlayer.cs +++ /dev/null @@ -1,44 +0,0 @@ -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/DingService.cs b/tools/AquaSynthDingsMcp/DingService.cs index a4c8f44..4ca715e 100644 --- a/tools/AquaSynthDingsMcp/DingService.cs +++ b/tools/AquaSynthDingsMcp/DingService.cs @@ -1,21 +1,27 @@ +using AquaSynth.Dings; + namespace AquaSynthDingsMcp; -public sealed class DingService(AquaDaemonClient daemon, DingAudioPlayer player) +public sealed class DingService(IDingsHostClient host) { 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" }; + var response = await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.Play, dingEvent.Id, instrument.Id, gain), cancellationToken); + EnsureSuccess(response); + return new { playbackId = response.PlaybackId, eventId = dingEvent.Id, instrumentId = instrument.Id, response.Status, response.HostProcessId }; } - public void StopAll() => player.StopAll(); + public async Task StopAllAsync(CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.StopAll), cancellationToken)); + public async Task GetVolumeAsync(CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.GetVolume), cancellationToken)); + public async Task SetVolumeAsync(float volume, CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.SetVolume, Value: volume), cancellationToken)); + public async Task SetMutedAsync(bool muted, CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.SetMuted, Value: muted ? 1 : 0), cancellationToken)); + + private static DingsResponse EnsureSuccess(DingsResponse response) + { + if (!response.Succeeded) throw new InvalidOperationException(response.Message); + return response; + } } diff --git a/tools/AquaSynthDingsMcp/DingTools.cs b/tools/AquaSynthDingsMcp/DingTools.cs index c8d5e20..6680398 100644 --- a/tools/AquaSynthDingsMcp/DingTools.cs +++ b/tools/AquaSynthDingsMcp/DingTools.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using ModelContextProtocol.Server; +using AquaSynth.Dings; namespace AquaSynthDingsMcp; @@ -20,5 +21,14 @@ public sealed class DingTools(DingService service) 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" }; } + public Task StopDings(CancellationToken cancellationToken = default) => service.StopAllAsync(cancellationToken); + + [McpServerTool, Description("Gets the authoritative AquaSynth Dings master volume and mute state shared with Windows Volume Mixer.")] + public Task GetVolume(CancellationToken cancellationToken = default) => service.GetVolumeAsync(cancellationToken); + + [McpServerTool, Description("Sets the global AquaSynth Dings master volume shared by every Codex task and Windows Volume Mixer.")] + public Task SetVolume([Description("Master volume from 0 to 1.")] float volume, CancellationToken cancellationToken = default) => service.SetVolumeAsync(volume, cancellationToken); + + [McpServerTool, Description("Mutes or unmutes the global AquaSynth Dings Windows audio session.")] + public Task MuteDings(bool muted = true, CancellationToken cancellationToken = default) => service.SetMutedAsync(muted, cancellationToken); } diff --git a/tools/AquaSynthDingsMcp/DingsHostClient.cs b/tools/AquaSynthDingsMcp/DingsHostClient.cs new file mode 100644 index 0000000..58680a0 --- /dev/null +++ b/tools/AquaSynthDingsMcp/DingsHostClient.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using System.IO.Pipes; +using AquaSynth.Dings; +using MessagePack; + +namespace AquaSynthDingsMcp; + +public interface IDingsHostClient +{ + Task SendAsync(DingsCommand command, CancellationToken cancellationToken); +} + +public sealed class DingsHostClient : IDingsHostClient +{ + public async Task SendAsync(DingsCommand command, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + try { return await SendOnceAsync(command, 350, timeout.Token); } + catch (TimeoutException) { StartHost(); } + catch (IOException) { StartHost(); } + for (var attempt = 0; attempt < 20; attempt++) + { + try { return await SendOnceAsync(command, 500, timeout.Token); } + catch (TimeoutException) when (attempt < 19) { await Task.Delay(100, timeout.Token); } + catch (IOException) when (attempt < 19) { await Task.Delay(100, timeout.Token); } + } + throw new InvalidOperationException("AquaSynth Dings audio host did not become available."); + } + + private static async Task SendOnceAsync(DingsCommand command, int connectTimeoutMs, CancellationToken cancellationToken) + { + await using var pipe = new NamedPipeClientStream(".", DingsProtocol.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(connectTimeoutMs, cancellationToken); + var payload = MessagePackSerializer.Serialize(command); + await pipe.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken); + await pipe.WriteAsync(payload, cancellationToken); + await pipe.FlushAsync(cancellationToken); + var header = new byte[4]; + await pipe.ReadExactlyAsync(header, cancellationToken); + var length = BitConverter.ToInt32(header); + if (length is <= 0 or > 1024 * 1024) throw new InvalidDataException("Invalid Dings response frame."); + var responsePayload = new byte[length]; + await pipe.ReadExactlyAsync(responsePayload, cancellationToken); + var response = MessagePackSerializer.Deserialize(responsePayload); + if (response.Version != DingsProtocol.Version) throw new InvalidDataException("Incompatible Dings host protocol."); + return response; + } + + private static void StartHost() + { + var root = FindRepositoryRoot(); + var host = Directory.EnumerateFiles(Path.Combine(root, "tools", "AquaSynthDingsHost", "bin"), "AquaSynthDingsHost.exe", SearchOption.AllDirectories).OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault() + ?? throw new InvalidOperationException("AquaSynthDingsHost.exe is not built."); + Process.Start(new ProcessStartInfo(host) { WorkingDirectory = root, UseShellExecute = true, WindowStyle = ProcessWindowStyle.Hidden }); + } + + 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; + throw new InvalidOperationException("Cannot locate AquaSynth repository root."); + } +} diff --git a/tools/AquaSynthDingsMcp/Program.cs b/tools/AquaSynthDingsMcp/Program.cs index b0e901c..c203baa 100644 --- a/tools/AquaSynthDingsMcp/Program.cs +++ b/tools/AquaSynthDingsMcp/Program.cs @@ -2,21 +2,36 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using AquaSynthDingsMcp; +using AquaSynth.Dings; 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); + var service = new DingService(new DingsHostClient()); await service.PlayAsync(eventId, instrumentId, .7f, CancellationToken.None); - await Task.Delay(TimeSpan.FromSeconds(2.5)); + return; +} +if (args is ["--get-volume"]) +{ + var state = await new DingService(new DingsHostClient()).GetVolumeAsync(CancellationToken.None); + Console.WriteLine($"volume={state.Volume:0.###} muted={state.Muted} hostPid={state.HostProcessId} session=\"{state.SessionName}\""); + return; +} +if (args is ["--set-volume", var volumeText] && float.TryParse(volumeText, System.Globalization.CultureInfo.InvariantCulture, out var volume)) +{ + var state = await new DingService(new DingsHostClient()).SetVolumeAsync(volume, CancellationToken.None); + Console.WriteLine($"volume={state.Volume:0.###} muted={state.Muted} hostPid={state.HostProcessId}"); + return; +} +if (args is ["--mute", var muteText] && bool.TryParse(muteText, out var muted)) +{ + var state = await new DingService(new DingsHostClient()).SetMutedAsync(muted, CancellationToken.None); + Console.WriteLine($"volume={state.Volume:0.###} muted={state.Muted} hostPid={state.HostProcessId}"); return; } var builder = Host.CreateApplicationBuilder(args); builder.Logging.ClearProviders(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithStdioServerTransport() diff --git a/tools/AquaSynthDingsMcp/README.md b/tools/AquaSynthDingsMcp/README.md index 54f7f16..f5dc97b 100644 --- a/tools/AquaSynthDingsMcp/README.md +++ b/tools/AquaSynthDingsMcp/README.md @@ -1,23 +1,28 @@ # 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. +Stdio MCP bridge for synthesized agent notifications. Event motif says what +happened; curated instrument timbre says which agent spoke. + +All MCP instances are command bridges. One persistent +`AquaSynthDingsHost.exe` owns the WASAPI session named **AquaSynth Dings**, the +Aqua synthesis child, mixer, master volume, and mute state. Windows Volume +Mixer and MCP volume tools address the same session. ```powershell dotnet run --project tools/AquaSynthDingsMcp ``` -For a direct synthesis/playback smoke outside an MCP host: +Direct smokes and controls: ```powershell dotnet run --project tools/AquaSynthDingsMcp -- --play task.complete warm-bell +dotnet run --project tools/AquaSynthDingsMcp -- --get-volume +dotnet run --project tools/AquaSynthDingsMcp -- --set-volume 0.5 +dotnet run --project tools/AquaSynthDingsMcp -- --mute true ``` -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`. +MCP tools are `play_ding`, `list_ding_events`, `list_instruments`, +`stop_dings`, `get_volume`, `set_volume`, and `mute_dings`. Instrument availability is catalog-only. Additions must satisfy mechanical curation checks and then survive listening/identification tests; see