Centralize Dings playback and volume

This commit is contained in:
Meta Vens 2026-07-15 01:18:09 +01:00
commit 348d677248
22 changed files with 507 additions and 178 deletions

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.8" />
</ItemGroup>
</Project>

View file

@ -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<DingNote> 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);

View file

@ -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");

View file

@ -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"}

View file

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

View file

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

View file

@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj" />
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
<Using Include="Xunit" />
<Using Include="AquaSynth.Faust" />
</ItemGroup>

View file

@ -1,4 +1,4 @@
using AquaSynthDingsMcp;
using AquaSynth.Dings;
namespace AquaSynth.Dsl.Tests;

View file

@ -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<DingsCommand> Commands { get; } = [];
public float Volume { get; set; } = 1;
public bool Muted { get; set; }
public Task<DingsResponse> 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));
}
}
}

View file

@ -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<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 = 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;
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>AquaSynthDingsHost</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
<ProjectReference Include="..\AquaSynthDaemon\AquaSynthDaemon.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="NAudio" Version="2.2.1" />
</ItemGroup>
</Project>

View file

@ -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<AudioFileReader> 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();
}
}

View file

@ -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<DingsCommand>(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<DingsResponse> 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<DingsResponse> 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<T> ReadAsync<T>(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<T>(payload);
}
internal static async Task WriteAsync<T>(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();
}
}

View file

@ -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);

View file

@ -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<string> RenderNoteAsync(DingInstrument instrument, float frequency, float gain, CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
EnsureStarted();
var id = $"ding-{Guid.NewGuid():N}";
var envelope = new
{
command = "instrument.sample",
payload = new
{
commandId = id,
patchId = $"dings.{instrument.Id}",
faustName = $"dings_{instrument.Id.Replace('-', '_')}",
script = instrument.Script,
durationSeconds = 1.5f,
gain,
controls = new Dictionary<string, float> { ["/ding/frequency"] = frequency },
revision = 1
}
};
await process!.StandardInput.WriteLineAsync(JsonSerializer.Serialize(envelope, json));
await process.StandardInput.FlushAsync(cancellationToken);
var line = await process.StandardOutput.ReadLineAsync(cancellationToken)
?? throw new InvalidOperationException("Aqua daemon closed before returning a render receipt.");
using var receipt = JsonDocument.Parse(line);
var render = receipt.RootElement.GetProperty("renderReceipt");
if (!string.Equals(render.GetProperty("status").GetString(), "succeeded", StringComparison.Ordinal))
throw new InvalidOperationException(render.TryGetProperty("failureMessage", out var failure) ? failure.GetString() : "Aqua render failed.");
return new Uri(render.GetProperty("wavUri").GetString()!).LocalPath;
}
finally { gate.Release(); }
}
private void EnsureStarted()
{
if (process is { HasExited: false }) return;
var root = FindRepositoryRoot();
var daemonDll = ResolveDaemonDll(root);
var arguments = $"\"{daemonDll}\" daemon --store \"{Path.Combine(root, ".aquasynth", "dings-mcp")}\"";
process = Process.Start(new ProcessStartInfo("dotnet", arguments)
{
WorkingDirectory = root,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}) ?? throw new InvalidOperationException("Could not start AquaSynth daemon.");
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) Console.Error.WriteLine($"[aqua] {e.Data}"); };
process.BeginErrorReadLine();
}
private static string ResolveDaemonDll(string root)
{
var configured = Environment.GetEnvironmentVariable("AQUASYNTH_DAEMON_DLL");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) return Path.GetFullPath(configured);
var bin = Path.Combine(root, "tools", "AquaSynthDaemon", "bin");
var candidate = Directory.EnumerateFiles(bin, "AquaSynthDaemon.dll", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
return candidate ?? throw new InvalidOperationException("AquaSynthDaemon.dll is not built. Build AquaSynthDingsMcp once or set AQUASYNTH_DAEMON_DLL.");
}
private static string FindRepositoryRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) return dir.FullName;
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir is not null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) return dir.FullName;
throw new InvalidOperationException("Cannot locate AquaSynth.sln. Start the MCP service from the AquaSynth repository or set AQUASYNTH_DAEMON_DLL.");
}
public ValueTask DisposeAsync()
{
if (process is { HasExited: false }) process.Kill(entireProcessTree: true);
process?.Dispose();
gate.Dispose();
return ValueTask.CompletedTask;
}
}

View file

@ -6,9 +6,9 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AquaSynthDaemon\AquaSynthDaemon.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
<ProjectReference Include="..\AquaSynthDingsHost\AquaSynthDingsHost.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
</ItemGroup>
</Project>

View file

@ -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<IDisposable> readers = [];
public DingAudioPlayer()
{
output = new WaveOutEvent { DesiredLatency = 80 };
output.Init(mixer);
output.Play();
}
public void Play(string wavPath, int delayMilliseconds, float gain)
{
var reader = new AudioFileReader(wavPath) { Volume = gain };
var cleanup = new OffsetSampleProvider(reader.ToStereo())
{
DelayBy = TimeSpan.FromMilliseconds(delayMilliseconds),
Take = reader.TotalTime + TimeSpan.FromMilliseconds(delayMilliseconds)
};
lock (sync) readers.Add(reader);
mixer.AddMixerInput(cleanup);
_ = Task.Delay(cleanup.Take + TimeSpan.FromMilliseconds(100)).ContinueWith(_ =>
{
lock (sync) { readers.Remove(reader); reader.Dispose(); }
}, TaskScheduler.Default);
}
public void StopAll() => mixer.RemoveAllMixerInputs();
public void Dispose()
{
output.Stop();
output.Dispose();
lock (sync) { foreach (var reader in readers) reader.Dispose(); readers.Clear(); }
}
}

View file

@ -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<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" };
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<DingsResponse> StopAllAsync(CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.StopAll), cancellationToken));
public async Task<DingsResponse> GetVolumeAsync(CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.GetVolume), cancellationToken));
public async Task<DingsResponse> SetVolumeAsync(float volume, CancellationToken cancellationToken) => EnsureSuccess(await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.SetVolume, Value: volume), cancellationToken));
public async Task<DingsResponse> 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;
}
}

View file

@ -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<DingsResponse> 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<DingsResponse> 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<DingsResponse> 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<DingsResponse> MuteDings(bool muted = true, CancellationToken cancellationToken = default) => service.SetMutedAsync(muted, cancellationToken);
}

View file

@ -0,0 +1,64 @@
using System.Diagnostics;
using System.IO.Pipes;
using AquaSynth.Dings;
using MessagePack;
namespace AquaSynthDingsMcp;
public interface IDingsHostClient
{
Task<DingsResponse> SendAsync(DingsCommand command, CancellationToken cancellationToken);
}
public sealed class DingsHostClient : IDingsHostClient
{
public async Task<DingsResponse> 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<DingsResponse> 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<DingsResponse>(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.");
}
}

View file

@ -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<AquaDaemonClient>();
builder.Services.AddSingleton<DingAudioPlayer>();
builder.Services.AddSingleton<IDingsHostClient, DingsHostClient>();
builder.Services.AddSingleton<DingService>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()

View file

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