From 6e93513e8efc137034f48eccb03465392f53cff6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 15 Jun 2026 01:45:01 +0100 Subject: [PATCH] Add AquaSynth local CultNet daemon --- docs/verse-service-contract.md | 17 +- src/AquaSynth.Faust/AquaSynth.Faust.csproj | 1 + src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs | 289 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 4 +- state/spine.yaml | 2 +- .../AquaSynthDaemonServiceTests.cs | 31 ++ tools/AquaSynthDaemon/Program.cs | 144 +++++++++ 8 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 5b60bdc..9ce26e3 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -108,6 +108,13 @@ invent a separate truth for compiled products or control state. per-block controls, publishes CultMesh `Audio` and `Tensor` descriptors inside the service, writes block `.f32` packet bodies, and commits one typed stream receipt. +- `cultnet-host`: starts a local CultNet host/database for AquaSynth command and + receipt documents, publishes `aquasynth.cultnet_provider.v1`, and watches + typed command records until the process is stopped. +- `cultnet-once`, `cultnet-stream`, and `cultnet-daemon`: submit the same sample, + stream, and JSON-lines command envelopes through the local CultNet database + path, then wait for typed receipt records. These modes are operator edges; the + database watcher is the command transport owner. Current witness roots: @@ -119,6 +126,8 @@ Current witness roots: - `.aquasynth/streams/{streamRunId}.cc` - `.aquasynth/streams/{streamRunId}/audio-{sequence}.f32` - `.aquasynth/operator/operator-state.cc` +- `.aquasynth/cultnet/aquasynth-daemon.ccmp`: local CultNet/CultCache database + for command documents, provider state, and receipt documents. Example: @@ -136,9 +145,11 @@ calls, but a daemon sample request owns the number of output frames. The bounded automation stream is the first realtime-shaped CultMesh path. It uses CultMesh stream descriptors and frame handles with `CultCachePage` packet -bodies, but it does not yet host a long-lived network peer or shared-memory -ring. Transport listeners must delegate to `AquaSynthDaemonService`; they must -not become separate audio/session owners. +bodies. The local CultNet host now gives AquaSynth a long-lived typed command +surface for `patch.compile`, `instrument.sample`, and `instrument.stream`; +transport listeners delegate to `AquaSynthDaemonService` and must not become +separate audio/session owners. Shared-memory rings and remote process +supervision remain future transport work, not command authority. ## Forbidden Writers diff --git a/src/AquaSynth.Faust/AquaSynth.Faust.csproj b/src/AquaSynth.Faust/AquaSynth.Faust.csproj index 6741643..a9602cb 100644 --- a/src/AquaSynth.Faust/AquaSynth.Faust.csproj +++ b/src/AquaSynth.Faust/AquaSynth.Faust.csproj @@ -17,6 +17,7 @@ + diff --git a/src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs b/src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs new file mode 100644 index 0000000..4142005 --- /dev/null +++ b/src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs @@ -0,0 +1,289 @@ +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; +using R3; + +namespace AquaSynth.Faust; + +public sealed record AquaSynthCultNetDaemonOptions( + string StoreRoot, + string RuntimeId = "aquasynth.daemon", + string? CachePath = null) +{ + public string ResolvedCachePath => + string.IsNullOrWhiteSpace(CachePath) + ? Path.Combine(StoreRoot, "cultnet", "aquasynth-daemon.ccmp") + : CachePath!; +} + +[CultDocument("aquasynth.cultnet_provider", "aquasynth.cultnet_provider.v1")] +[CultGlobal] +[MessagePackObject] +public sealed record AquaSynthCultNetProviderState( + [property: Key(0)] string ProviderId, + [property: Key(1)] string RuntimeId, + [property: Key(2)] string UpdatedAtUtc, + [property: Key(3)] string StoreRoot, + [property: Key(4)] string CachePath, + [property: Key(5)] string[] CommandSchemas, + [property: Key(6)] string[] ReceiptSchemas, + [property: Key(7)] string[] OperatorKeys); + +public sealed class AquaSynthCultNetDaemon : IAsyncDisposable +{ + private readonly AquaSynthCultNetDaemonOptions options; + private readonly AquaSynthDaemonService service; + private readonly SemaphoreSlim commandGate = new(1, 1); + private readonly HashSet handledCommands = new(StringComparer.Ordinal); + private readonly List subscriptions = []; + private CultNetHost? host; + + private AquaSynthCultNetDaemon( + AquaSynthCultNetDaemonOptions options, + AquaSynthDaemonService service) + { + this.options = options; + this.service = service; + } + + public CultNetDatabase Database => + host?.Database ?? throw new InvalidOperationException("AquaSynth CultNet daemon has not been started."); + + public static async Task StartAsync( + AquaSynthCultNetDaemonOptions options, + AquaSynthDaemonService? service = null) + { + Directory.CreateDirectory(Path.GetDirectoryName(options.ResolvedCachePath)!); + var daemon = new AquaSynthCultNetDaemon( + options, + service ?? new AquaSynthDaemonService(new AquaSynthDaemonOptions(options.StoreRoot))); + daemon.host = await CultNetLocal.StartHostAsync( + options.ResolvedCachePath, + new CultNetHostOptions + { + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = options.RuntimeId, + DocumentRegistry = AquaSynthCultNetDocuments.CreateRegistry() + } + }).ConfigureAwait(false); + daemon.Subscribe(); + await daemon.PublishProviderStateAsync().ConfigureAwait(false); + return daemon; + } + + public Task SubmitCompileAsync( + AquaSynthPatchCompileCommand command, + TimeSpan? timeout = null) + { + var key = CompileReceiptKey(command.CommandId); + return SubmitAndWaitAsync( + command, + CommandKey(command.CommandId), + key, + timeout); + } + + public Task SubmitSampleAsync( + AquaSynthInstrumentSampleCommand command, + TimeSpan? timeout = null) + { + var key = RenderReceiptKey(command.CommandId); + return SubmitAndWaitAsync( + command, + CommandKey(command.CommandId), + key, + timeout); + } + + public Task SubmitStreamAsync( + AquaSynthAutomationStreamCommand command, + TimeSpan? timeout = null) + { + var key = StreamReceiptKey(command.CommandId); + return SubmitAndWaitAsync( + command, + CommandKey(command.CommandId), + key, + timeout); + } + + public async ValueTask DisposeAsync() + { + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + + subscriptions.Clear(); + commandGate.Dispose(); + host?.Dispose(); + service.Dispose(); + await Task.CompletedTask.ConfigureAwait(false); + } + + private async Task SubmitAndWaitAsync( + TCommand command, + CultRecordKey commandKey, + CultRecordKey receiptKey, + TimeSpan? timeout) + where TCommand : class + where TReceipt : class + { + var database = Database; + if (await database.GetAsync(receiptKey).ConfigureAwait(false) is { } existing) + { + return existing; + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = database + .WatchRecord(receiptKey) + .Subscribe(change => + { + if (change.Document is { } document) + { + completion.TrySetResult(document); + } + }); + + await database.PutAsync(commandKey, command).ConfigureAwait(false); + + var wait = timeout ?? TimeSpan.FromSeconds(30); + var completed = await Task.WhenAny(completion.Task, Task.Delay(wait)).ConfigureAwait(false); + if (!ReferenceEquals(completed, completion.Task)) + { + throw new TimeoutException($"AquaSynth CultNet command '{commandKey.Value}' did not publish receipt '{receiptKey.Value}' within {wait}."); + } + + return await completion.Task.ConfigureAwait(false); + } + + private void Subscribe() + { + var database = Database; + subscriptions.Add(database.Watch().Subscribe(change => + { + if (change.Document is { } command && ShouldHandle(nameof(AquaSynthPatchCompileCommand), command.CommandId)) + { + _ = Task.Run(() => HandleCompileAsync(command)); + } + })); + subscriptions.Add(database.Watch().Subscribe(change => + { + if (change.Document is { } command && ShouldHandle(nameof(AquaSynthInstrumentSampleCommand), command.CommandId)) + { + _ = Task.Run(() => HandleSampleAsync(command)); + } + })); + subscriptions.Add(database.Watch().Subscribe(change => + { + if (change.Document is { } command && ShouldHandle(nameof(AquaSynthAutomationStreamCommand), command.CommandId)) + { + _ = Task.Run(() => HandleStreamAsync(command)); + } + })); + } + + private bool ShouldHandle(string commandType, string commandId) + { + lock (handledCommands) + { + return handledCommands.Add($"{commandType}:{commandId}"); + } + } + + private async Task HandleCompileAsync(AquaSynthPatchCompileCommand command) + { + await commandGate.WaitAsync().ConfigureAwait(false); + try + { + var receipt = await service.CompileAsync(command).ConfigureAwait(false); + await Database.PutAsync(CompileReceiptKey(command.CommandId), receipt).ConfigureAwait(false); + await PublishProviderStateAsync().ConfigureAwait(false); + } + finally + { + commandGate.Release(); + } + } + + private async Task HandleSampleAsync(AquaSynthInstrumentSampleCommand command) + { + await commandGate.WaitAsync().ConfigureAwait(false); + try + { + var result = await service.SampleAsync(command).ConfigureAwait(false); + await Database.PutAsync(CompileReceiptKey(command.CommandId), result.CompileReceipt).ConfigureAwait(false); + await Database.PutAsync(RenderReceiptKey(command.CommandId), result.RenderReceipt).ConfigureAwait(false); + await PublishProviderStateAsync().ConfigureAwait(false); + } + finally + { + commandGate.Release(); + } + } + + private async Task HandleStreamAsync(AquaSynthAutomationStreamCommand command) + { + await commandGate.WaitAsync().ConfigureAwait(false); + try + { + var receipt = await service.StreamAutomationAsync(command).ConfigureAwait(false); + await Database.PutAsync(StreamReceiptKey(command.CommandId), receipt).ConfigureAwait(false); + await PublishProviderStateAsync().ConfigureAwait(false); + } + finally + { + commandGate.Release(); + } + } + + private Task PublishProviderStateAsync() + { + var state = new AquaSynthCultNetProviderState( + "aquasynth.service", + options.RuntimeId, + DateTimeOffset.UtcNow.ToString("O"), + options.StoreRoot, + options.ResolvedCachePath, + [ + AquaSynthDaemonSchemas.PatchCompileCommand, + AquaSynthDaemonSchemas.InstrumentSampleCommand, + AquaSynthDaemonSchemas.AutomationStreamCommand + ], + [ + AquaSynthDaemonSchemas.PatchCompileReceipt, + AquaSynthDaemonSchemas.RenderSampleReceipt, + AquaSynthDaemonSchemas.AutomationStreamReceipt + ], + [ + "global:aquasynth.cultnet_provider", + "global:aquasynth.operator_state" + ]); + return Database.PutAsync(new CultRecordKey("global:aquasynth.cultnet_provider"), state); + } + + public static CultRecordKey CommandKey(string commandId) => new($"commands/{commandId}"); + + public static CultRecordKey CompileReceiptKey(string commandId) => new($"{commandId}-compile"); + + public static CultRecordKey RenderReceiptKey(string commandId) => new($"{commandId}-render"); + + public static CultRecordKey StreamReceiptKey(string commandId) => new($"{commandId}-stream"); +} + +public static class AquaSynthCultNetDocuments +{ + public static CultNetDocumentRegistry CreateRegistry() => + new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()) + .Register(CultNetDocumentBinding.ForDocument()); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index b763d53..44456a5 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -279,3 +279,4 @@ {"timestamp":"2026-06-01T12:30:30+01:00","topic":"music-knowledge-cultcache-retrigger-fed","claim":"The retriggered 808 percussion lesson has been fed into a live music-production CultCache knowledge store and indexed through the Qdrant/Ollama music retrieval path. A vector `music-search` query for 808 percussion, sequenced gain, retrigger, pitch ramp, and dead envelopes returned the AquaSynth DSP usage manual as the top hit with the exact `trigger=` guidance and 808 layering doctrine visible in the retrieved packet.","evidence":"artifacts/parity/music-knowledge/20260601T123002994-retrigger/music-production-knowledge.cc; artifacts/parity/music-knowledge/20260601T123002994-retrigger/music-vector-index.md; artifacts/parity/music-knowledge/20260601T123002994-retrigger/music-search-retrigger.md"} {"timestamp":"2026-06-15T01:02:24+01:00","topic":"aquasynth-daemon-sample-duration-ownership","claim":"AquaSynth daemon instrument.sample requests must own render duration; direct CompileScript envelope estimates are not sufficient for sample-command frame counts. Use AquaSynthDaemonService duration-aware source compilation and verify requested frames at the receipt layer.","evidence":"JSON-lines smoke returned 2205 samples for 0.05s after duration ownership moved into daemon compile path."} {"timestamp":"2026-06-15T01:34:00+01:00","topic":"aquasynth-daemon-bounded-automation-stream","claim":"A bounded realtime-shaped automation stream now belongs to AquaSynthDaemonService, not the CLI or a future transport listener. The service compiles once, opens a native Faust streaming patch, applies per-block controls, emits CultMesh-compatible audio/control descriptors and frame handles, writes per-block float32 packet bodies, and persists one stream receipt.","evidence":"src/AquaSynth.Faust/AquaSynthDaemonService.cs; tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs; tools/AquaSynthDaemon/Program.cs; docs/verse-service-contract.md"} +{"timestamp":"2026-06-15T01:42:00+01:00","topic":"aquasynth-local-cultnet-daemon","claim":"AquaSynth now has a local CultNet daemon boundary: typed patch/sample/stream command documents are written to the CultNet database, AquaSynthCultNetDaemon watches and serializes them through AquaSynthDaemonService, and typed compile/render/stream/provider receipts are published back to the database. The service remains the render/session owner; CultNet is transport and publication authority.","evidence":"src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs; tools/AquaSynthDaemon/Program.cs; tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs; docs/verse-service-contract.md; CLI smokes: cultnet-once returned 2205 sample frames for 0.05s and cultnet-stream returned 2 packet receipts with control frame count 1 on block 1."} diff --git a/state/map.yaml b/state/map.yaml index 4a215a5..ba9068a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -15,6 +15,7 @@ surfaces: core_library: src/AquaSynth.Core faust_library: src/AquaSynth.Faust daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs + cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj verse_service_contract: docs/verse-service-contract.md patch_library: patches @@ -79,6 +80,7 @@ modules: faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs aquasynth_daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs + aquasynth_cultnet_daemon: src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs aquasynth_daemon_tool: tools/AquaSynthDaemon faust_rendered_speech_loss: src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs cultcache_sound_claim_document: patches/aquasynth-patch-cultcache.cc @@ -120,7 +122,7 @@ boundaries: - Faust source generation in AquaSynth.Core - target-code generation, compile-product contracts, dynamic patch compilation policy, cache identity, generated artifact manifests, and Faust toolchain selection in AquaSynth.Faust - live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof in AquaSynth.Faust - - AquaSynth daemon command ownership for patch.compile, instrument.sample, and bounded instrument.stream automation: compile/session/render/stream/operator typed receipts, requested-duration sample renders, CultMesh-compatible audio/control stream descriptors, per-block .f32 packet artifacts, and JSON-lines local transport through tools/AquaSynthDaemon + - AquaSynth daemon command ownership for patch.compile, instrument.sample, and bounded instrument.stream automation: compile/session/render/stream/operator typed receipts, requested-duration sample renders, CultMesh-compatible audio/control stream descriptors, per-block .f32 packet artifacts, JSON-lines local transport through tools/AquaSynthDaemon, and a local CultNet host/database that watches typed command documents and publishes typed receipt/provider documents without becoming a second render/session authority - IPA-to-phonetic-feature planning, articulatory gesture planning, and human/alien vocal-tract roadmap design - phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis - packed-buffer native backprop/Adam as the shared AquaSynth gradient-descent primitive diff --git a/state/spine.yaml b/state/spine.yaml index c055986..b792837 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -57,7 +57,7 @@ current_mechanism: live_invariants: - AquaSynth owns its patch graph; external synths are references, not runtime dependencies. - - AquaSynthDaemonService owns the local daemon command boundary for patch.compile, instrument.sample, and bounded instrument.stream automation. It delegates DSP to AquaSynth.Faust, writes compile/session/render/stream/operator .cc witnesses under the configured .aquasynth store, writes .f32/.wav sample artifacts, and emits CultMesh-compatible audio/control stream descriptors plus per-block packet receipts. The tools/AquaSynthDaemon JSON-lines stream is only edge transport; it must not become a second audio/session authority. + - AquaSynthDaemonService owns compile/render/stream execution for patch.compile, instrument.sample, and bounded instrument.stream automation. It delegates DSP to AquaSynth.Faust, writes compile/session/render/stream/operator .cc witnesses under the configured .aquasynth store, writes .f32/.wav sample artifacts, 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. - 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/AquaSynthDaemonServiceTests.cs b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs index fcf7647..caaed8e 100644 --- a/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs +++ b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs @@ -1,4 +1,5 @@ using AquaSynth.Faust; +using GameCult.Caching; namespace AquaSynth.Dsl.Tests; @@ -96,6 +97,36 @@ public sealed class AquaSynthDaemonServiceTests Assert.True(File.Exists(Path.Combine(storeRoot, "streams", "stream-sine-stream.cc"))); } + [Fact] + public async Task CultNetDaemonConsumesCommandDocumentsAndPublishesReceiptDocuments() + { + var storeRoot = NewStoreRoot(); + await using var daemon = await AquaSynthCultNetDaemon.StartAsync(new AquaSynthCultNetDaemonOptions(storeRoot)); + + var receipt = await daemon.SubmitSampleAsync(new AquaSynthInstrumentSampleCommand( + "cultnet-invalid-sample", + "test.cultnet.invalid", + "test_cultnet_invalid", + "this is not aqua syntax", + DurationSeconds: 0.1f)); + + Assert.Equal("failed", receipt.Status); + Assert.Equal("cultnet-invalid-sample", receipt.CommandId); + Assert.NotNull(await daemon.Database.GetAsync( + AquaSynthCultNetDaemon.CommandKey("cultnet-invalid-sample"))); + Assert.NotNull(await daemon.Database.GetAsync( + AquaSynthCultNetDaemon.CompileReceiptKey("cultnet-invalid-sample"))); + Assert.NotNull(await daemon.Database.GetAsync( + AquaSynthCultNetDaemon.RenderReceiptKey("cultnet-invalid-sample"))); + + var provider = await daemon.Database.GetAsync( + new CultRecordKey("global:aquasynth.cultnet_provider")); + Assert.NotNull(provider); + Assert.Contains(AquaSynthDaemonSchemas.InstrumentSampleCommand, provider.CommandSchemas); + Assert.StartsWith(storeRoot, provider.CachePath, StringComparison.OrdinalIgnoreCase); + Assert.True(Directory.Exists(Path.GetDirectoryName(provider.CachePath))); + } + private static string NewStoreRoot() { var path = Path.Combine(Path.GetTempPath(), "aquasynth-daemon-tests", Guid.NewGuid().ToString("N")); diff --git a/tools/AquaSynthDaemon/Program.cs b/tools/AquaSynthDaemon/Program.cs index cb7c18b..58822da 100644 --- a/tools/AquaSynthDaemon/Program.cs +++ b/tools/AquaSynthDaemon/Program.cs @@ -37,6 +37,10 @@ internal static class AquaSynthDaemonCli "once" => await RunOnceAsync(service, options, output).ConfigureAwait(false), "stream" => await RunStreamOnceAsync(service, options, output).ConfigureAwait(false), "daemon" => await RunDaemonAsync(service, input, output, error).ConfigureAwait(false), + "cultnet-once" => await RunCultNetOnceAsync(service, storeRoot, options, output).ConfigureAwait(false), + "cultnet-stream" => await RunCultNetStreamOnceAsync(service, storeRoot, options, output).ConfigureAwait(false), + "cultnet-daemon" => await RunCultNetDaemonAsync(service, storeRoot, input, output, error).ConfigureAwait(false), + "cultnet-host" => await RunCultNetHostAsync(service, storeRoot, output).ConfigureAwait(false), _ => await UnknownModeAsync(mode, error).ConfigureAwait(false) }; } @@ -150,6 +154,142 @@ internal static class AquaSynthDaemonCli return 0; } + private static async Task RunCultNetOnceAsync( + AquaSynthDaemonService service, + string storeRoot, + IReadOnlyDictionary options, + TextWriter output) + { + var script = options.TryGetValue("--script", out var inlineScript) + ? inlineScript + : await File.ReadAllTextAsync(Value(options, "--script-file", "")).ConfigureAwait(false); + + await using var cultNet = await AquaSynthCultNetDaemon.StartAsync( + new AquaSynthCultNetDaemonOptions(storeRoot), + service).ConfigureAwait(false); + var receipt = await cultNet.SubmitSampleAsync(new AquaSynthInstrumentSampleCommand( + Value(options, "--command-id", $"cultnet-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"), + Value(options, "--patch-id", "cultnet.patch"), + Value(options, "--faust-name", "aquasynth_cultnet_patch"), + script, + FloatValue(options, "--duration", 0.25f), + FloatValue(options, "--gain", 1.0f), + ParseControls(Value(options, "--control", "")), + IntValue(options, "--revision", 1))).ConfigureAwait(false); + + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + return string.Equals(receipt.Status, "succeeded", StringComparison.Ordinal) ? 0 : 2; + } + + private static async Task RunCultNetStreamOnceAsync( + AquaSynthDaemonService service, + string storeRoot, + IReadOnlyDictionary options, + TextWriter output) + { + var script = options.TryGetValue("--script", out var inlineScript) + ? inlineScript + : await File.ReadAllTextAsync(Value(options, "--script-file", "")).ConfigureAwait(false); + + await using var cultNet = await AquaSynthCultNetDaemon.StartAsync( + new AquaSynthCultNetDaemonOptions(storeRoot), + service).ConfigureAwait(false); + var receipt = await cultNet.SubmitStreamAsync(new AquaSynthAutomationStreamCommand( + Value(options, "--command-id", $"cultnet-stream-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"), + Value(options, "--patch-id", "cultnet.stream.patch"), + Value(options, "--faust-name", "aquasynth_cultnet_stream"), + script, + IntValue(options, "--block-size", 128), + IntValue(options, "--blocks", 8), + ParseControlFrames(Value(options, "--control-frame", "")), + FloatValue(options, "--gain", 1.0f), + IntValue(options, "--revision", 1))).ConfigureAwait(false); + + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + return string.Equals(receipt.Status, "succeeded", StringComparison.Ordinal) ? 0 : 2; + } + + private static async Task RunCultNetDaemonAsync( + AquaSynthDaemonService service, + string storeRoot, + TextReader input, + TextWriter output, + TextWriter error) + { + await using var cultNet = await AquaSynthCultNetDaemon.StartAsync( + new AquaSynthCultNetDaemonOptions(storeRoot), + service).ConfigureAwait(false); + + string? line; + while ((line = await input.ReadLineAsync().ConfigureAwait(false)) is not null) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + line = line.TrimStart('\uFEFF').Trim(); + var envelope = JsonSerializer.Deserialize(line, JsonOptions) + ?? throw new InvalidOperationException("Command line did not contain a JSON object."); + if (string.Equals(envelope.Command, "patch.compile", StringComparison.OrdinalIgnoreCase)) + { + var command = envelope.Payload.Deserialize(JsonOptions) + ?? throw new InvalidOperationException("patch.compile payload was empty."); + var receipt = await cultNet.SubmitCompileAsync(command).ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + } + else if (string.Equals(envelope.Command, "instrument.sample", StringComparison.OrdinalIgnoreCase)) + { + var command = envelope.Payload.Deserialize(JsonOptions) + ?? throw new InvalidOperationException("instrument.sample payload was empty."); + var receipt = await cultNet.SubmitSampleAsync(command).ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + } + else if (string.Equals(envelope.Command, "instrument.stream", StringComparison.OrdinalIgnoreCase)) + { + var command = envelope.Payload.Deserialize(JsonOptions) + ?? throw new InvalidOperationException("instrument.stream payload was empty."); + var receipt = await cultNet.SubmitStreamAsync(command).ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException($"Unknown command '{envelope.Command}'."); + } + } + catch (Exception ex) + { + await error.WriteLineAsync(ex.Message).ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(new + { + status = "failed", + failureCode = "command_failed", + failureMessage = ex.Message + }, JsonOptions)).ConfigureAwait(false); + } + } + + return 0; + } + + private static async Task RunCultNetHostAsync( + AquaSynthDaemonService service, + string storeRoot, + TextWriter output) + { + await using var cultNet = await AquaSynthCultNetDaemon.StartAsync( + new AquaSynthCultNetDaemonOptions(storeRoot), + service).ConfigureAwait(false); + var provider = await cultNet.Database + .GetAsync(new GameCult.Caching.CultRecordKey("global:aquasynth.cultnet_provider")) + .ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(provider, JsonOptions)).ConfigureAwait(false); + await Task.Delay(Timeout.InfiniteTimeSpan).ConfigureAwait(false); + return 0; + } + private static async Task UnknownModeAsync(string mode, TextWriter error) { await error.WriteLineAsync($"Unknown AquaSynth daemon mode '{mode}'.").ConfigureAwait(false); @@ -164,6 +304,10 @@ internal static class AquaSynthDaemonCli once --script-file patch.aqua [--store .aquasynth] [--duration 0.25] [--gain 1] stream --script-file patch.aqua [--store .aquasynth] [--block-size 128] [--blocks 8] daemon [--store .aquasynth] + cultnet-once --script-file patch.aqua [--store .aquasynth] [--duration 0.25] [--gain 1] + cultnet-stream --script-file patch.aqua [--store .aquasynth] [--block-size 128] [--blocks 8] + cultnet-daemon [--store .aquasynth] + cultnet-host [--store .aquasynth] JSON-lines daemon commands: {"command":"instrument.sample","payload":{"commandId":"demo","patchId":"demo.patch","faustName":"demo","script":"voice wave=sine freq=440 gain=.2","durationSeconds":0.1}}