Add AquaSynth local CultNet daemon

This commit is contained in:
Meta Vens 2026-06-15 01:45:01 +01:00
commit 6e93513e8e
8 changed files with 484 additions and 5 deletions

View file

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

View file

@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\AquaSynth.Core\AquaSynth.Core.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -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<string> handledCommands = new(StringComparer.Ordinal);
private readonly List<IDisposable> 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<AquaSynthCultNetDaemon> 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<AquaSynthPatchCompileReceipt> SubmitCompileAsync(
AquaSynthPatchCompileCommand command,
TimeSpan? timeout = null)
{
var key = CompileReceiptKey(command.CommandId);
return SubmitAndWaitAsync<AquaSynthPatchCompileCommand, AquaSynthPatchCompileReceipt>(
command,
CommandKey(command.CommandId),
key,
timeout);
}
public Task<AquaSynthRenderSampleReceipt> SubmitSampleAsync(
AquaSynthInstrumentSampleCommand command,
TimeSpan? timeout = null)
{
var key = RenderReceiptKey(command.CommandId);
return SubmitAndWaitAsync<AquaSynthInstrumentSampleCommand, AquaSynthRenderSampleReceipt>(
command,
CommandKey(command.CommandId),
key,
timeout);
}
public Task<AquaSynthAutomationStreamReceipt> SubmitStreamAsync(
AquaSynthAutomationStreamCommand command,
TimeSpan? timeout = null)
{
var key = StreamReceiptKey(command.CommandId);
return SubmitAndWaitAsync<AquaSynthAutomationStreamCommand, AquaSynthAutomationStreamReceipt>(
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<TReceipt> SubmitAndWaitAsync<TCommand, TReceipt>(
TCommand command,
CultRecordKey commandKey,
CultRecordKey receiptKey,
TimeSpan? timeout)
where TCommand : class
where TReceipt : class
{
var database = Database;
if (await database.GetAsync<TReceipt>(receiptKey).ConfigureAwait(false) is { } existing)
{
return existing;
}
var completion = new TaskCompletionSource<TReceipt>(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = database
.WatchRecord<TReceipt>(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<AquaSynthPatchCompileCommand>().Subscribe(change =>
{
if (change.Document is { } command && ShouldHandle(nameof(AquaSynthPatchCompileCommand), command.CommandId))
{
_ = Task.Run(() => HandleCompileAsync(command));
}
}));
subscriptions.Add(database.Watch<AquaSynthInstrumentSampleCommand>().Subscribe(change =>
{
if (change.Document is { } command && ShouldHandle(nameof(AquaSynthInstrumentSampleCommand), command.CommandId))
{
_ = Task.Run(() => HandleSampleAsync(command));
}
}));
subscriptions.Add(database.Watch<AquaSynthAutomationStreamCommand>().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<AquaSynthPatchCompileCommand>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthInstrumentSampleCommand>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthAutomationStreamCommand>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthCompiledInstrumentSession>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthPatchCompileReceipt>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthRenderSampleReceipt>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthAutomationStreamReceipt>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthOperatorState>())
.Register(CultNetDocumentBinding.ForDocument<AquaSynthCultNetProviderState>());
}

View file

@ -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=<step_seconds>` 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."}

View file

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

View file

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

View file

@ -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<AquaSynthInstrumentSampleCommand>(
AquaSynthCultNetDaemon.CommandKey("cultnet-invalid-sample")));
Assert.NotNull(await daemon.Database.GetAsync<AquaSynthPatchCompileReceipt>(
AquaSynthCultNetDaemon.CompileReceiptKey("cultnet-invalid-sample")));
Assert.NotNull(await daemon.Database.GetAsync<AquaSynthRenderSampleReceipt>(
AquaSynthCultNetDaemon.RenderReceiptKey("cultnet-invalid-sample")));
var provider = await daemon.Database.GetAsync<AquaSynthCultNetProviderState>(
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"));

View file

@ -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<int> RunCultNetOnceAsync(
AquaSynthDaemonService service,
string storeRoot,
IReadOnlyDictionary<string, string> 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<int> RunCultNetStreamOnceAsync(
AquaSynthDaemonService service,
string storeRoot,
IReadOnlyDictionary<string, string> 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<int> 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<DaemonCommandEnvelope>(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<AquaSynthPatchCompileCommand>(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<AquaSynthInstrumentSampleCommand>(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<AquaSynthAutomationStreamCommand>(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<int> 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<AquaSynthCultNetProviderState>(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<int> 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}}