Centralize Dings playback and volume
This commit is contained in:
parent
9abb7e4164
commit
348d677248
22 changed files with 507 additions and 178 deletions
10
src/AquaSynth.Dings/AquaSynth.Dings.csproj
Normal file
10
src/AquaSynth.Dings/AquaSynth.Dings.csproj
Normal 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>
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
namespace AquaSynthDingsMcp;
|
namespace AquaSynth.Dings;
|
||||||
|
|
||||||
public sealed record DingNote(double Semitones, int DelayMilliseconds, float Gain = 1f);
|
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 DingEvent(string Id, string Meaning, IReadOnlyList<DingNote> Notes);
|
||||||
public sealed record DingInstrument(
|
public sealed record DingInstrument(string Id, string Description, string TimbreFamily, string Script, float RootFrequency, float Brightness, float DecaySeconds, bool UsesNoise);
|
||||||
string Id,
|
|
||||||
string Description,
|
|
||||||
string TimbreFamily,
|
|
||||||
string Script,
|
|
||||||
float RootFrequency,
|
|
||||||
float Brightness,
|
|
||||||
float DecaySeconds,
|
|
||||||
bool UsesNoise);
|
|
||||||
|
|
||||||
public static class DingCatalog
|
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.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");
|
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(Instruments.Values.GroupBy(x => x.TimbreFamily).Where(x => x.Count() > 1).Select(x => $"timbre family '{x.Key}' is not distinct"));
|
||||||
failures.AddRange(duplicateFamilies.Select(x => $"timbre family '{x}' is not distinct"));
|
|
||||||
foreach (var dingEvent in Events.Values)
|
foreach (var dingEvent in Events.Values)
|
||||||
{
|
{
|
||||||
var gaps = dingEvent.Notes.Zip(dingEvent.Notes.Skip(1), (left, right) => right.DelayMilliseconds - left.DelayMilliseconds);
|
var gaps = dingEvent.Notes.Zip(dingEvent.Notes.Skip(1), (left, right) => right.DelayMilliseconds - left.DelayMilliseconds);
|
||||||
32
src/AquaSynth.Dings/DingsProtocol.cs
Normal file
32
src/AquaSynth.Dings/DingsProtocol.cs
Normal 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");
|
||||||
|
|
@ -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-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-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-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"}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ surfaces:
|
||||||
cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs
|
cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs
|
||||||
daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj
|
daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj
|
||||||
dings_mcp_service: tools/AquaSynthDingsMcp
|
dings_mcp_service: tools/AquaSynthDingsMcp
|
||||||
|
dings_audio_host: tools/AquaSynthDingsHost
|
||||||
|
dings_contract: src/AquaSynth.Dings
|
||||||
auditory_notification_research: docs/research/auditory-notifications/README.md
|
auditory_notification_research: docs/research/auditory-notifications/README.md
|
||||||
verse_service_contract: docs/verse-service-contract.md
|
verse_service_contract: docs/verse-service-contract.md
|
||||||
daemonic_swarm_article: docs/gamecult-daemonic-swarm.md
|
daemonic_swarm_article: docs/gamecult-daemonic-swarm.md
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ current_mechanism:
|
||||||
live_invariants:
|
live_invariants:
|
||||||
- AquaSynth owns its patch graph; external synths are references, not runtime dependencies.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj" />
|
<ProjectReference Include="..\..\tools\AquaSynthDingsMcp\AquaSynthDingsMcp.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
<Using Include="AquaSynth.Faust" />
|
<Using Include="AquaSynth.Faust" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using AquaSynthDingsMcp;
|
using AquaSynth.Dings;
|
||||||
|
|
||||||
namespace AquaSynth.Dsl.Tests;
|
namespace AquaSynth.Dsl.Tests;
|
||||||
|
|
||||||
|
|
|
||||||
66
tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs
Normal file
66
tests/AquaSynth.Dsl.Tests/DingsBridgeTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tools/AquaSynthDingsHost/AquaDaemonClient.cs
Normal file
59
tools/AquaSynthDingsHost/AquaDaemonClient.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj
Normal file
14
tools/AquaSynthDingsHost/AquaSynthDingsHost.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<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>
|
||||||
77
tools/AquaSynthDingsHost/DingAudioPlayer.cs
Normal file
77
tools/AquaSynthDingsHost/DingAudioPlayer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tools/AquaSynthDingsHost/DingsPlaybackHost.cs
Normal file
106
tools/AquaSynthDingsHost/DingsPlaybackHost.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
tools/AquaSynthDingsHost/Program.cs
Normal file
7
tools/AquaSynthDingsHost/Program.cs
Normal 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);
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<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="ModelContextProtocol" Version="1.4.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||||
<PackageReference Include="NAudio" Version="2.2.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
|
using AquaSynth.Dings;
|
||||||
|
|
||||||
namespace AquaSynthDingsMcp;
|
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)
|
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.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 (!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.");
|
if (gain is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(gain), "Gain must be between 0 and 1.");
|
||||||
var playbackId = $"ding-{Guid.NewGuid():N}";
|
var response = await host.SendAsync(new(DingsProtocol.Version, DingsCommandKind.Play, dingEvent.Id, instrument.Id, gain), cancellationToken);
|
||||||
foreach (var note in dingEvent.Notes)
|
EnsureSuccess(response);
|
||||||
{
|
return new { playbackId = response.PlaybackId, eventId = dingEvent.Id, instrumentId = instrument.Id, response.Status, response.HostProcessId };
|
||||||
var frequency = instrument.RootFrequency * MathF.Pow(2, (float)note.Semitones / 12f);
|
|
||||||
var wav = await daemon.RenderNoteAsync(instrument, frequency, gain * note.Gain, cancellationToken);
|
|
||||||
player.Play(wav, note.DelayMilliseconds, 1f);
|
|
||||||
}
|
|
||||||
return new { playbackId, eventId = dingEvent.Id, instrumentId = instrument.Id, status = "playing" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StopAll() => player.StopAll();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
using AquaSynth.Dings;
|
||||||
|
|
||||||
namespace AquaSynthDingsMcp;
|
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();
|
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.")]
|
[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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
tools/AquaSynthDingsMcp/DingsHostClient.cs
Normal file
64
tools/AquaSynthDingsMcp/DingsHostClient.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,36 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using AquaSynthDingsMcp;
|
using AquaSynthDingsMcp;
|
||||||
|
using AquaSynth.Dings;
|
||||||
|
|
||||||
if (args is ["--play", var eventId, var instrumentId])
|
if (args is ["--play", var eventId, var instrumentId])
|
||||||
{
|
{
|
||||||
await using var daemon = new AquaDaemonClient();
|
var service = new DingService(new DingsHostClient());
|
||||||
using var audio = new DingAudioPlayer();
|
|
||||||
var service = new DingService(daemon, audio);
|
|
||||||
await service.PlayAsync(eventId, instrumentId, .7f, CancellationToken.None);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Services.AddSingleton<AquaDaemonClient>();
|
builder.Services.AddSingleton<IDingsHostClient, DingsHostClient>();
|
||||||
builder.Services.AddSingleton<DingAudioPlayer>();
|
|
||||||
builder.Services.AddSingleton<DingService>();
|
builder.Services.AddSingleton<DingService>();
|
||||||
builder.Services.AddMcpServer()
|
builder.Services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStdioServerTransport()
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,28 @@
|
||||||
# AquaSynth Dings MCP
|
# AquaSynth Dings MCP
|
||||||
|
|
||||||
Stdio MCP service for synthesized agent notifications. The event motif says
|
Stdio MCP bridge for synthesized agent notifications. Event motif says what
|
||||||
what happened; the curated instrument timbre says which agent spoke. The
|
happened; curated instrument timbre says which agent spoke.
|
||||||
service starts/calls `AquaSynthDaemon`, then owns speaker playback through one
|
|
||||||
persistent NAudio mixer.
|
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
|
```powershell
|
||||||
dotnet run --project tools/AquaSynthDingsMcp
|
dotnet run --project tools/AquaSynthDingsMcp
|
||||||
```
|
```
|
||||||
|
|
||||||
For a direct synthesis/playback smoke outside an MCP host:
|
Direct smokes and controls:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project tools/AquaSynthDingsMcp -- --play task.complete warm-bell
|
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.
|
MCP tools are `play_ding`, `list_ding_events`, `list_instruments`,
|
||||||
Set `AQUASYNTH_DAEMON_DLL` only to select a different built daemon. MCP tools are `play_ding`,
|
`stop_dings`, `get_volume`, `set_volume`, and `mute_dings`.
|
||||||
`list_ding_events`, `list_instruments`, and `stop_dings`.
|
|
||||||
|
|
||||||
Instrument availability is catalog-only. Additions must satisfy mechanical
|
Instrument availability is catalog-only. Additions must satisfy mechanical
|
||||||
curation checks and then survive listening/identification tests; see
|
curation checks and then survive listening/identification tests; see
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue