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