Add AquaSynth local CultNet daemon
This commit is contained in:
parent
8c5f6d604a
commit
6e93513e8e
8 changed files with 484 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AquaSynth.Core\AquaSynth.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
289
src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs
Normal file
289
src/AquaSynth.Faust/AquaSynthCultNetDaemon.cs
Normal 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>());
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue