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