Add AquaSynth sample daemon
This commit is contained in:
parent
c67158116a
commit
ee6501558c
10 changed files with 778 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@ obj/
|
||||||
TestResults/
|
TestResults/
|
||||||
artifacts/
|
artifacts/
|
||||||
.voidbot/
|
.voidbot/
|
||||||
|
.aquasynth*/
|
||||||
target/
|
target/
|
||||||
.dotnet*/
|
.dotnet*/
|
||||||
.nuget/
|
.nuget/
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdSyrinxGolf", "tools\Bir
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IpaTrialWorker", "tools\IpaTrialWorker\IpaTrialWorker.csproj", "{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IpaTrialWorker", "tools\IpaTrialWorker\IpaTrialWorker.csproj", "{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AquaSynthDaemon", "tools\AquaSynthDaemon\AquaSynthDaemon.csproj", "{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -89,6 +91,18 @@ Global
|
||||||
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x64.Build.0 = Release|Any CPU
|
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x86.ActiveCfg = Release|Any CPU
|
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x86.Build.0 = Release|Any CPU
|
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
@ -99,5 +113,6 @@ Global
|
||||||
{D6CDBB4A-A783-4AA8-8715-E9E71A9BF9CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{D6CDBB4A-A783-4AA8-8715-E9E71A9BF9CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{A1914F24-9CE5-47DA-943E-E26D3AC4DFD3} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
{A1914F24-9CE5-47DA-943E-E26D3AC4DFD3} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
{9596767B-8FDC-49E5-8F70-0A1CECE35C5E} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
|
{A21990AB-5D3D-4A5B-98B7-F730A4091DA8} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,41 @@ Every command must commit typed receipts through the same derivation path used
|
||||||
by any CLI or worker. A dashboard, REPL, worker loop, or native host must not
|
by any CLI or worker. A dashboard, REPL, worker loop, or native host must not
|
||||||
invent a separate truth for compiled products or control state.
|
invent a separate truth for compiled products or control state.
|
||||||
|
|
||||||
|
## Runnable Daemon Slice
|
||||||
|
|
||||||
|
`tools/AquaSynthDaemon` is the current local daemon body. It exposes:
|
||||||
|
|
||||||
|
- `once`: accepts inline `.aqua` or `--script-file`, compiles through the native
|
||||||
|
Faust boundary, renders samples, writes `.f32` and `.wav` artifacts, and
|
||||||
|
commits typed `.cc` witnesses under the configured store root.
|
||||||
|
- `daemon`: reads JSON-lines command envelopes from stdin and writes JSON-lines
|
||||||
|
receipts to stdout. The JSON-lines stream is an edge transport for local
|
||||||
|
smoke and xenos callers; command/session/render authority lives in
|
||||||
|
`AquaSynthDaemonService` and its CultCache witnesses.
|
||||||
|
|
||||||
|
Current witness roots:
|
||||||
|
|
||||||
|
- `.aquasynth/compile/{receiptId}.cc`
|
||||||
|
- `.aquasynth/sessions/{sessionId}.cc`
|
||||||
|
- `.aquasynth/renders/{renderId}.cc`
|
||||||
|
- `.aquasynth/samples/{renderId}.f32`
|
||||||
|
- `.aquasynth/samples/{renderId}.wav`
|
||||||
|
- `.aquasynth/operator/operator-state.cc`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project tools\AquaSynthDaemon -- once `
|
||||||
|
--script-file patches\808\kick.aqua `
|
||||||
|
--patch-id patches.808.kick `
|
||||||
|
--faust-name patch_808_kick `
|
||||||
|
--duration 0.25
|
||||||
|
```
|
||||||
|
|
||||||
|
The service now makes `instrument.sample` duration authoritative for the render
|
||||||
|
window. Patch-estimated envelope duration remains useful for direct compiler
|
||||||
|
calls, but a daemon sample request owns the number of output frames.
|
||||||
|
|
||||||
## Forbidden Writers
|
## Forbidden Writers
|
||||||
|
|
||||||
- Weksa does not compile Faust or own instrument controls.
|
- Weksa does not compile Faust or own instrument controls.
|
||||||
|
|
|
||||||
450
src/AquaSynth.Faust/AquaSynthDaemonService.cs
Normal file
450
src/AquaSynth.Faust/AquaSynthDaemonService.cs
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using AquaSynth.Dsl;
|
||||||
|
using GameCult.Caching;
|
||||||
|
using GameCult.Caching.MessagePack;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace AquaSynth.Faust;
|
||||||
|
|
||||||
|
public static class AquaSynthDaemonSchemas
|
||||||
|
{
|
||||||
|
public const string PatchCompileCommand = "aquasynth.command.patch_compile.v1";
|
||||||
|
public const string InstrumentSampleCommand = "aquasynth.command.instrument_sample.v1";
|
||||||
|
public const string CompiledInstrumentSession = "aquasynth.compiled_instrument_session.v1";
|
||||||
|
public const string PatchCompileReceipt = "aquasynth.patch_compile_receipt.v1";
|
||||||
|
public const string RenderSampleReceipt = "aquasynth.render_sample_receipt.v1";
|
||||||
|
public const string OperatorState = "aquasynth.operator_state.v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AquaSynthDaemonOptions(
|
||||||
|
string StoreRoot,
|
||||||
|
int SampleRate = 44100,
|
||||||
|
float MinRenderSeconds = 0.05f,
|
||||||
|
float MaxRenderSeconds = 3.0f)
|
||||||
|
{
|
||||||
|
public static AquaSynthDaemonOptions Default =>
|
||||||
|
new(Path.Combine(Environment.CurrentDirectory, ".aquasynth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.command.patch_compile", AquaSynthDaemonSchemas.PatchCompileCommand)]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthPatchCompileCommand(
|
||||||
|
[property: Key(0), CultName] string CommandId,
|
||||||
|
[property: Key(1)] string PatchId,
|
||||||
|
[property: Key(2)] string FaustName,
|
||||||
|
[property: Key(3)] string Script,
|
||||||
|
[property: Key(4)] int Revision = 1,
|
||||||
|
[property: Key(5)] float? DurationSeconds = null);
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.command.instrument_sample", AquaSynthDaemonSchemas.InstrumentSampleCommand)]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthInstrumentSampleCommand(
|
||||||
|
[property: Key(0), CultName] string CommandId,
|
||||||
|
[property: Key(1)] string PatchId,
|
||||||
|
[property: Key(2)] string FaustName,
|
||||||
|
[property: Key(3)] string Script,
|
||||||
|
[property: Key(4)] float DurationSeconds = 0.25f,
|
||||||
|
[property: Key(5)] float Gain = 1.0f,
|
||||||
|
[property: Key(6)] Dictionary<string, float>? Controls = null,
|
||||||
|
[property: Key(7)] int Revision = 1);
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.compiled_instrument_session", AquaSynthDaemonSchemas.CompiledInstrumentSession)]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthCompiledInstrumentSession(
|
||||||
|
[property: Key(0), CultName] string SessionId,
|
||||||
|
[property: Key(1), CultIndex("patch")] string PatchId,
|
||||||
|
[property: Key(2)] string CompileKey,
|
||||||
|
[property: Key(3)] string FaustName,
|
||||||
|
[property: Key(4)] int Revision,
|
||||||
|
[property: Key(5)] int SampleRate,
|
||||||
|
[property: Key(6)] int InputCount,
|
||||||
|
[property: Key(7)] int OutputCount,
|
||||||
|
[property: Key(8)] int FrameCount,
|
||||||
|
[property: Key(9)] float DurationSeconds,
|
||||||
|
[property: Key(10)] string CreatedAtUtc,
|
||||||
|
[property: Key(11)] string[] ControlPaths,
|
||||||
|
[property: Key(12)] string[] ProbePaths);
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.patch_compile_receipt", AquaSynthDaemonSchemas.PatchCompileReceipt)]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthPatchCompileReceipt(
|
||||||
|
[property: Key(0), CultName] string ReceiptId,
|
||||||
|
[property: Key(1), CultIndex("command")] string CommandId,
|
||||||
|
[property: Key(2), CultIndex("patch")] string PatchId,
|
||||||
|
[property: Key(3)] string SessionId,
|
||||||
|
[property: Key(4)] string Status,
|
||||||
|
[property: Key(5)] string DecidedAtUtc,
|
||||||
|
[property: Key(6)] string CompileKey,
|
||||||
|
[property: Key(7)] double CompileMilliseconds,
|
||||||
|
[property: Key(8)] string[] ControlPaths,
|
||||||
|
[property: Key(9)] string[] ProbePaths,
|
||||||
|
[property: Key(10)] string FailureCode = "",
|
||||||
|
[property: Key(11)] string FailureMessage = "");
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.render_sample_receipt", AquaSynthDaemonSchemas.RenderSampleReceipt)]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthRenderSampleReceipt(
|
||||||
|
[property: Key(0), CultName] string RenderId,
|
||||||
|
[property: Key(1), CultIndex("command")] string CommandId,
|
||||||
|
[property: Key(2), CultIndex("patch")] string PatchId,
|
||||||
|
[property: Key(3)] string SessionId,
|
||||||
|
[property: Key(4)] string Status,
|
||||||
|
[property: Key(5)] string CompletedAtUtc,
|
||||||
|
[property: Key(6)] int SampleRate,
|
||||||
|
[property: Key(7)] int SampleCount,
|
||||||
|
[property: Key(8)] float DurationSeconds,
|
||||||
|
[property: Key(9)] float Peak,
|
||||||
|
[property: Key(10)] float Rms,
|
||||||
|
[property: Key(11)] string Float32Uri,
|
||||||
|
[property: Key(12)] string WavUri,
|
||||||
|
[property: Key(13)] string ContentHash,
|
||||||
|
[property: Key(14)] string FailureCode = "",
|
||||||
|
[property: Key(15)] string FailureMessage = "");
|
||||||
|
|
||||||
|
[CultDocument("aquasynth.operator_state", AquaSynthDaemonSchemas.OperatorState)]
|
||||||
|
[CultGlobal]
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed record AquaSynthOperatorState(
|
||||||
|
[property: Key(0)] string ServiceId,
|
||||||
|
[property: Key(1)] string UpdatedAtUtc,
|
||||||
|
[property: Key(2)] string StoreRoot,
|
||||||
|
[property: Key(3)] int CompileCount,
|
||||||
|
[property: Key(4)] int RenderCount,
|
||||||
|
[property: Key(5)] string LastCommandId,
|
||||||
|
[property: Key(6)] string LastStatus,
|
||||||
|
[property: Key(7)] string[] CultMeshKeys);
|
||||||
|
|
||||||
|
public sealed record AquaSynthSampleResult(
|
||||||
|
AquaSynthPatchCompileReceipt CompileReceipt,
|
||||||
|
AquaSynthRenderSampleReceipt RenderReceipt);
|
||||||
|
|
||||||
|
public sealed class AquaSynthDaemonService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AquaSynthDaemonOptions options;
|
||||||
|
private readonly AquaSynthPatchCompiler compiler;
|
||||||
|
private readonly Dictionary<string, AquaSynthCompiledPatch> sessions = new(StringComparer.Ordinal);
|
||||||
|
private int compileCount;
|
||||||
|
private int renderCount;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public AquaSynthDaemonService(AquaSynthDaemonOptions? options = null)
|
||||||
|
{
|
||||||
|
this.options = options ?? AquaSynthDaemonOptions.Default;
|
||||||
|
Directory.CreateDirectory(this.options.StoreRoot);
|
||||||
|
compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(
|
||||||
|
SampleRate: this.options.SampleRate,
|
||||||
|
MinRenderSeconds: this.options.MinRenderSeconds,
|
||||||
|
MaxRenderSeconds: this.options.MaxRenderSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AquaSynthPatchCompileReceipt> CompileAsync(AquaSynthPatchCompileCommand command)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
ValidateCompile(command);
|
||||||
|
|
||||||
|
var identity = new AquaSynthCompileIdentity(command.PatchId, command.FaustName, command.Script, command.Revision);
|
||||||
|
var compiled = command.DurationSeconds is { } requestedDuration
|
||||||
|
? TryCompileScriptForDuration(identity, requestedDuration, out var patch, out var error)
|
||||||
|
: compiler.TryCompileScript(identity, out patch, out error);
|
||||||
|
|
||||||
|
if (!compiled)
|
||||||
|
{
|
||||||
|
var failed = new AquaSynthPatchCompileReceipt(
|
||||||
|
ReceiptId(command.CommandId, "compile"),
|
||||||
|
command.CommandId,
|
||||||
|
command.PatchId,
|
||||||
|
"",
|
||||||
|
"failed",
|
||||||
|
Now(),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
"compile_failed",
|
||||||
|
error ?? "AquaSynth patch compile failed.");
|
||||||
|
await WriteReceiptAsync("compile", failed.ReceiptId, failed).ConfigureAwait(false);
|
||||||
|
await WriteOperatorStateAsync(command.CommandId, failed.Status).ConfigureAwait(false);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = SessionId(command.PatchId, patch!.Manifest.CompileKey);
|
||||||
|
if (sessions.Remove(sessionId, out var old))
|
||||||
|
{
|
||||||
|
old.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions[sessionId] = patch;
|
||||||
|
compileCount++;
|
||||||
|
|
||||||
|
var session = new AquaSynthCompiledInstrumentSession(
|
||||||
|
sessionId,
|
||||||
|
command.PatchId,
|
||||||
|
patch.Manifest.CompileKey,
|
||||||
|
patch.Manifest.FaustName,
|
||||||
|
patch.Manifest.Revision,
|
||||||
|
patch.Manifest.SampleRate,
|
||||||
|
patch.Manifest.InputCount,
|
||||||
|
patch.Manifest.OutputCount,
|
||||||
|
patch.Manifest.FrameCount,
|
||||||
|
patch.Manifest.DurationSeconds,
|
||||||
|
Now(),
|
||||||
|
[.. patch.ControlPaths],
|
||||||
|
[.. patch.ProbePaths]);
|
||||||
|
|
||||||
|
var receipt = new AquaSynthPatchCompileReceipt(
|
||||||
|
ReceiptId(command.CommandId, "compile"),
|
||||||
|
command.CommandId,
|
||||||
|
command.PatchId,
|
||||||
|
sessionId,
|
||||||
|
"succeeded",
|
||||||
|
Now(),
|
||||||
|
patch.Manifest.CompileKey,
|
||||||
|
patch.Manifest.CompileMilliseconds,
|
||||||
|
[.. patch.ControlPaths],
|
||||||
|
[.. patch.ProbePaths]);
|
||||||
|
|
||||||
|
await WriteReceiptAsync("sessions", sessionId, session).ConfigureAwait(false);
|
||||||
|
await WriteReceiptAsync("compile", receipt.ReceiptId, receipt).ConfigureAwait(false);
|
||||||
|
await WriteOperatorStateAsync(command.CommandId, receipt.Status).ConfigureAwait(false);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AquaSynthSampleResult> SampleAsync(AquaSynthInstrumentSampleCommand command)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
ValidateSample(command);
|
||||||
|
|
||||||
|
var compile = await CompileAsync(new AquaSynthPatchCompileCommand(
|
||||||
|
command.CommandId,
|
||||||
|
command.PatchId,
|
||||||
|
command.FaustName,
|
||||||
|
command.Script,
|
||||||
|
command.Revision,
|
||||||
|
command.DurationSeconds)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!string.Equals(compile.Status, "succeeded", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var failed = new AquaSynthRenderSampleReceipt(
|
||||||
|
RenderId(command.CommandId),
|
||||||
|
command.CommandId,
|
||||||
|
command.PatchId,
|
||||||
|
compile.SessionId,
|
||||||
|
"failed",
|
||||||
|
Now(),
|
||||||
|
options.SampleRate,
|
||||||
|
0,
|
||||||
|
command.DurationSeconds,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"compile_failed",
|
||||||
|
compile.FailureMessage);
|
||||||
|
await WriteReceiptAsync("renders", failed.RenderId, failed).ConfigureAwait(false);
|
||||||
|
await WriteOperatorStateAsync(command.CommandId, failed.Status).ConfigureAwait(false);
|
||||||
|
return new AquaSynthSampleResult(compile, failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = sessions[compile.SessionId];
|
||||||
|
var controls = NormalizeControls(command.Controls);
|
||||||
|
var samples = patch.Render(controls, command.Gain);
|
||||||
|
renderCount++;
|
||||||
|
|
||||||
|
var renderId = RenderId(command.CommandId);
|
||||||
|
var sampleDir = Path.Combine(options.StoreRoot, "samples");
|
||||||
|
Directory.CreateDirectory(sampleDir);
|
||||||
|
var floatPath = Path.Combine(sampleDir, $"{renderId}.f32");
|
||||||
|
var wavPath = Path.Combine(sampleDir, $"{renderId}.wav");
|
||||||
|
await WriteFloat32Async(floatPath, samples).ConfigureAwait(false);
|
||||||
|
await WriteWavAsync(wavPath, samples, patch.Manifest.SampleRate).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var receipt = new AquaSynthRenderSampleReceipt(
|
||||||
|
renderId,
|
||||||
|
command.CommandId,
|
||||||
|
command.PatchId,
|
||||||
|
compile.SessionId,
|
||||||
|
"succeeded",
|
||||||
|
Now(),
|
||||||
|
patch.Manifest.SampleRate,
|
||||||
|
samples.Length,
|
||||||
|
samples.Length / (float)patch.Manifest.SampleRate,
|
||||||
|
Peak(samples),
|
||||||
|
Rms(samples),
|
||||||
|
ToArtifactUri(floatPath),
|
||||||
|
ToArtifactUri(wavPath),
|
||||||
|
await Sha256Async(floatPath).ConfigureAwait(false));
|
||||||
|
|
||||||
|
await WriteReceiptAsync("renders", receipt.RenderId, receipt).ConfigureAwait(false);
|
||||||
|
await WriteOperatorStateAsync(command.CommandId, receipt.Status).ConfigureAwait(false);
|
||||||
|
return new AquaSynthSampleResult(compile, receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var patch in sessions.Values)
|
||||||
|
{
|
||||||
|
patch.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.Clear();
|
||||||
|
compiler.Dispose();
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteOperatorStateAsync(string commandId, string status)
|
||||||
|
{
|
||||||
|
var state = new AquaSynthOperatorState(
|
||||||
|
"aquasynth.service",
|
||||||
|
Now(),
|
||||||
|
options.StoreRoot,
|
||||||
|
compileCount,
|
||||||
|
renderCount,
|
||||||
|
commandId,
|
||||||
|
status,
|
||||||
|
[
|
||||||
|
"aquasynth.service/provider-advertisement",
|
||||||
|
"aquasynth.compile/jobs",
|
||||||
|
"aquasynth.instrument/sessions",
|
||||||
|
"aquasynth.render/samples",
|
||||||
|
"aquasynth.operator/status"
|
||||||
|
]);
|
||||||
|
await WriteReceiptAsync("operator", "operator-state", state, global: true).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryCompileScriptForDuration(
|
||||||
|
AquaSynthCompileIdentity identity,
|
||||||
|
float durationSeconds,
|
||||||
|
out AquaSynthCompiledPatch? patch,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
patch = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var faustName = AquaSynthNativeCompiler.SafeFaustName(identity.FaustName);
|
||||||
|
var source = FaustEmitter.EmitScript(identity.Script, new FaustExportOptions(faustName)).Source;
|
||||||
|
return compiler.TryCompileSource(identity, source, durationSeconds, out patch, out error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteReceiptAsync<T>(string family, string id, T document, bool global = false)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(options.StoreRoot, family, $"{SafeFileName(id)}.cc");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||||
|
using var cache = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { PullOnOpen = false });
|
||||||
|
var key = global ? new CultRecordKey("global") : new CultRecordKey(id);
|
||||||
|
await cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
||||||
|
await cache.FlushAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, float>? NormalizeControls(Dictionary<string, float>? controls) =>
|
||||||
|
controls is null || controls.Count == 0
|
||||||
|
? null
|
||||||
|
: controls.ToDictionary(pair => pair.Key.TrimStart('/'), pair => pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static void ValidateCompile(AquaSynthPatchCompileCommand command)
|
||||||
|
{
|
||||||
|
Require(command.CommandId, nameof(command.CommandId));
|
||||||
|
Require(command.PatchId, nameof(command.PatchId));
|
||||||
|
Require(command.FaustName, nameof(command.FaustName));
|
||||||
|
Require(command.Script, nameof(command.Script));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateSample(AquaSynthInstrumentSampleCommand command)
|
||||||
|
{
|
||||||
|
Require(command.CommandId, nameof(command.CommandId));
|
||||||
|
Require(command.PatchId, nameof(command.PatchId));
|
||||||
|
Require(command.FaustName, nameof(command.FaustName));
|
||||||
|
Require(command.Script, nameof(command.Script));
|
||||||
|
if (command.DurationSeconds <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(command.DurationSeconds), "Sample duration must be positive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Require(string value, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{name} must not be empty.", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteFloat32Async(string path, float[] samples)
|
||||||
|
{
|
||||||
|
await using var stream = File.Create(path);
|
||||||
|
var buffer = new byte[samples.Length * sizeof(float)];
|
||||||
|
Buffer.BlockCopy(samples, 0, buffer, 0, buffer.Length);
|
||||||
|
await stream.WriteAsync(buffer).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteWavAsync(string path, float[] samples, int sampleRate)
|
||||||
|
{
|
||||||
|
await using var stream = File.Create(path);
|
||||||
|
using var writer = new BinaryWriter(stream, Encoding.ASCII);
|
||||||
|
var dataBytes = samples.Length * sizeof(short);
|
||||||
|
writer.Write("RIFF"u8);
|
||||||
|
writer.Write(36 + dataBytes);
|
||||||
|
writer.Write("WAVE"u8);
|
||||||
|
writer.Write("fmt "u8);
|
||||||
|
writer.Write(16);
|
||||||
|
writer.Write((short)1);
|
||||||
|
writer.Write((short)1);
|
||||||
|
writer.Write(sampleRate);
|
||||||
|
writer.Write(sampleRate * sizeof(short));
|
||||||
|
writer.Write((short)sizeof(short));
|
||||||
|
writer.Write((short)16);
|
||||||
|
writer.Write("data"u8);
|
||||||
|
writer.Write(dataBytes);
|
||||||
|
foreach (var sample in samples)
|
||||||
|
{
|
||||||
|
writer.Write((short)Math.Clamp(sample * short.MaxValue, short.MinValue, short.MaxValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> Sha256Async(string path)
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
var hash = await SHA256.HashDataAsync(stream).ConfigureAwait(false);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Peak(IReadOnlyList<float> samples) =>
|
||||||
|
samples.Count == 0 ? 0 : samples.Max(sample => MathF.Abs(sample));
|
||||||
|
|
||||||
|
private static float Rms(IReadOnlyList<float> samples) =>
|
||||||
|
samples.Count == 0 ? 0 : MathF.Sqrt(samples.Sum(sample => sample * sample) / samples.Count);
|
||||||
|
|
||||||
|
private static string ReceiptId(string commandId, string kind) => $"{commandId}-{kind}";
|
||||||
|
|
||||||
|
private static string RenderId(string commandId) => $"{commandId}-render";
|
||||||
|
|
||||||
|
private static string SessionId(string patchId, string compileKey) => $"{SafeFileName(patchId)}-{compileKey[..Math.Min(12, compileKey.Length)]}";
|
||||||
|
|
||||||
|
private static string SafeFileName(string value)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
builder.Append(char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString().Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToArtifactUri(string path) => $"file:///{Path.GetFullPath(path).Replace('\\', '/')}";
|
||||||
|
|
||||||
|
private static string Now() => DateTimeOffset.UtcNow.ToString("O");
|
||||||
|
}
|
||||||
|
|
@ -277,3 +277,4 @@
|
||||||
{"timestamp":"2026-06-01T04:43:51+01:00","topic":"log-mel-steered-production-sugar","claim":"Music agents should steer patch revisions by directly comparing target and candidate log-mel spectrogram/features between attempts, then editing the lane owner that explains the visible mismatch. RMS, cosine, and motion metrics are warning lights, not optimization targets. Aqua DSL now includes reusable production sugar for subtractive drum grids, gated dust hats, bass response lanes, additive wash beds, and section-rise mix automation, and the Aqua DSP manual teaches those abstractions as inspectable owners rather than hidden instruments.","evidence":"src/AquaSynth.Core/PatchScript.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/music-production-knowledge/aqua-dsp-usage-manual.md"}
|
{"timestamp":"2026-06-01T04:43:51+01:00","topic":"log-mel-steered-production-sugar","claim":"Music agents should steer patch revisions by directly comparing target and candidate log-mel spectrogram/features between attempts, then editing the lane owner that explains the visible mismatch. RMS, cosine, and motion metrics are warning lights, not optimization targets. Aqua DSL now includes reusable production sugar for subtractive drum grids, gated dust hats, bass response lanes, additive wash beds, and section-rise mix automation, and the Aqua DSP manual teaches those abstractions as inspectable owners rather than hidden instruments.","evidence":"src/AquaSynth.Core/PatchScript.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/music-production-knowledge/aqua-dsp-usage-manual.md"}
|
||||||
{"timestamp":"2026-06-01T11:35:00+01:00","topic":"sequenced-drum-retrigger-ownership","claim":"Music agents must not treat sequenced gain as percussion articulation. One-shot voices age from song start, so gain gates can reopen dead envelopes and make 808-style drums sound like generic noise or weak human-music mush. Patterned drum/transient voices that need per-hit envelope, pitch fall, FM decay, filter ramp, or phaser motion must use `trigger=<step_seconds>` and should start from the stock 808 kick/snare/hat/clap shapes split into explicit body, click, skin, clap-tap, hat, open-hat, ghost, and skip layers with drive/fold/sample-hold texture.","evidence":"src/AquaSynth.Core/Model.cs; src/AquaSynth.Core/PatchScript.cs; src/AquaSynth.Core/FaustExport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/music-production-knowledge/aqua-dsp-usage-manual.md; tools/run-song-snippet-trial-swarm.ps1; patches/advanced/electro-drop-demo.aqua"}
|
{"timestamp":"2026-06-01T11:35:00+01:00","topic":"sequenced-drum-retrigger-ownership","claim":"Music agents must not treat sequenced gain as percussion articulation. One-shot voices age from song start, so gain gates can reopen dead envelopes and make 808-style drums sound like generic noise or weak human-music mush. Patterned drum/transient voices that need per-hit envelope, pitch fall, FM decay, filter ramp, or phaser motion must use `trigger=<step_seconds>` and should start from the stock 808 kick/snare/hat/clap shapes split into explicit body, click, skin, clap-tap, hat, open-hat, ghost, and skip layers with drive/fold/sample-hold texture.","evidence":"src/AquaSynth.Core/Model.cs; src/AquaSynth.Core/PatchScript.cs; src/AquaSynth.Core/FaustExport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/music-production-knowledge/aqua-dsp-usage-manual.md; tools/run-song-snippet-trial-swarm.ps1; patches/advanced/electro-drop-demo.aqua"}
|
||||||
{"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-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."}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ surfaces:
|
||||||
solution: AquaSynth.sln
|
solution: AquaSynth.sln
|
||||||
core_library: src/AquaSynth.Core
|
core_library: src/AquaSynth.Core
|
||||||
faust_library: src/AquaSynth.Faust
|
faust_library: src/AquaSynth.Faust
|
||||||
|
daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs
|
||||||
|
daemon_tool: tools/AquaSynthDaemon/AquaSynthDaemon.csproj
|
||||||
|
verse_service_contract: docs/verse-service-contract.md
|
||||||
patch_library: patches
|
patch_library: patches
|
||||||
tests: tests/AquaSynth.Dsl.Tests
|
tests: tests/AquaSynth.Dsl.Tests
|
||||||
reference_roadmap: docs/reference-synth-roadmap.md
|
reference_roadmap: docs/reference-synth-roadmap.md
|
||||||
|
|
@ -75,6 +78,8 @@ modules:
|
||||||
faust_emitter: src/AquaSynth.Core/FaustExport.cs
|
faust_emitter: src/AquaSynth.Core/FaustExport.cs
|
||||||
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
|
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
|
||||||
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs
|
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs
|
||||||
|
aquasynth_daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs
|
||||||
|
aquasynth_daemon_tool: tools/AquaSynthDaemon
|
||||||
faust_rendered_speech_loss: src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs
|
faust_rendered_speech_loss: src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs
|
||||||
cultcache_sound_claim_document: patches/aquasynth-patch-cultcache.cc
|
cultcache_sound_claim_document: patches/aquasynth-patch-cultcache.cc
|
||||||
cultcache_sound_claim_types: src/AquaSynth.Core/CultCachePatchDocument.cs
|
cultcache_sound_claim_types: src/AquaSynth.Core/CultCachePatchDocument.cs
|
||||||
|
|
@ -115,6 +120,7 @@ boundaries:
|
||||||
- Faust source generation in AquaSynth.Core
|
- 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
|
- 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
|
- live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof in AquaSynth.Faust
|
||||||
|
- AquaSynth daemon command ownership for patch.compile and instrument.sample: compile/session/render/operator typed receipts, requested-duration sample renders, .f32/.wav sample artifacts, and JSON-lines local transport through tools/AquaSynthDaemon
|
||||||
- IPA-to-phonetic-feature planning, articulatory gesture planning, and human/alien vocal-tract roadmap design
|
- 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
|
- 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
|
- packed-buffer native backprop/Adam as the shared AquaSynth gradient-descent primitive
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
project: AquaSynth
|
project: AquaSynth
|
||||||
spine_version: 1
|
spine_version: 1
|
||||||
updated: 2026-05-31T01:31:00+01:00
|
updated: 2026-06-15T01:02:24+01:00
|
||||||
|
|
||||||
prime_directive: >
|
prime_directive: >
|
||||||
Build coherent synth machinery. References exist to reveal missing structure,
|
Build coherent synth machinery. References exist to reveal missing structure,
|
||||||
|
|
@ -20,6 +20,8 @@ current_mechanism:
|
||||||
faust_emitter: src/AquaSynth.Core/FaustExport.cs
|
faust_emitter: src/AquaSynth.Core/FaustExport.cs
|
||||||
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
|
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
|
||||||
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs
|
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs
|
||||||
|
aquasynth_daemon_service: src/AquaSynth.Faust/AquaSynthDaemonService.cs
|
||||||
|
aquasynth_daemon_tool: tools/AquaSynthDaemon
|
||||||
operator_graph_model: src/AquaSynth.Core/Model.cs
|
operator_graph_model: src/AquaSynth.Core/Model.cs
|
||||||
analysis: src/AquaSynth.Core/AudioAnalysis.cs
|
analysis: src/AquaSynth.Core/AudioAnalysis.cs
|
||||||
built_ins: src/AquaSynth.Core/BuiltInScripts.cs
|
built_ins: src/AquaSynth.Core/BuiltInScripts.cs
|
||||||
|
|
@ -55,6 +57,7 @@ current_mechanism:
|
||||||
|
|
||||||
live_invariants:
|
live_invariants:
|
||||||
- AquaSynth owns its patch graph; external synths are references, not runtime dependencies.
|
- AquaSynth owns its patch graph; external synths are references, not runtime dependencies.
|
||||||
|
- AquaSynthDaemonService owns the local daemon command boundary for patch.compile and instrument.sample. It delegates DSP to AquaSynth.Faust, writes compile/session/render/operator .cc witnesses under the configured .aquasynth store, and writes .f32/.wav sample artifacts. The tools/AquaSynthDaemon JSON-lines stream is only edge transport; it must not become a second audio/session authority.
|
||||||
- Patch scripts lower into explicit graph records before Faust emission.
|
- Patch scripts lower into explicit graph records before Faust emission.
|
||||||
- Song-snippet parity is scene-audio pressure, not redistributed fixture data. The worker may derive local tempo/register/scale hints from private clips, but agents must express live rhythm and pitch motion through owned AquaSynth controls until native sequencing sugar has an explicit DSL/lowering owner.
|
- Song-snippet parity is scene-audio pressure, not redistributed fixture data. The worker may derive local tempo/register/scale hints from private clips, but agents must express live rhythm and pitch motion through owned AquaSynth controls until native sequencing sugar has an explicit DSL/lowering owner.
|
||||||
- Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields.
|
- Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields.
|
||||||
|
|
|
||||||
64
tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs
Normal file
64
tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
using AquaSynth.Faust;
|
||||||
|
|
||||||
|
namespace AquaSynth.Dsl.Tests;
|
||||||
|
|
||||||
|
public sealed class AquaSynthDaemonServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DaemonSampleCommandWritesFailedReceiptsForInvalidPatch()
|
||||||
|
{
|
||||||
|
var storeRoot = NewStoreRoot();
|
||||||
|
using var service = new AquaSynthDaemonService(new AquaSynthDaemonOptions(storeRoot));
|
||||||
|
|
||||||
|
var result = await service.SampleAsync(new AquaSynthInstrumentSampleCommand(
|
||||||
|
"invalid-sample",
|
||||||
|
"test.invalid",
|
||||||
|
"test_invalid",
|
||||||
|
"this is not aqua syntax",
|
||||||
|
DurationSeconds: 0.1f));
|
||||||
|
|
||||||
|
Assert.Equal("failed", result.CompileReceipt.Status);
|
||||||
|
Assert.Equal("failed", result.RenderReceipt.Status);
|
||||||
|
Assert.True(File.Exists(Path.Combine(storeRoot, "compile", "invalid-sample-compile.cc")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(storeRoot, "renders", "invalid-sample-render.cc")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(storeRoot, "operator", "operator-state.cc")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DaemonSampleCommandReturnsSamplesWhenNativeFaustIsAvailable()
|
||||||
|
{
|
||||||
|
var storeRoot = NewStoreRoot();
|
||||||
|
using var service = new AquaSynthDaemonService(new AquaSynthDaemonOptions(storeRoot));
|
||||||
|
|
||||||
|
var result = await service.SampleAsync(new AquaSynthInstrumentSampleCommand(
|
||||||
|
"sine-sample",
|
||||||
|
"test.sine",
|
||||||
|
"test_sine",
|
||||||
|
"voice wave=sine freq=440 gain=.2 attack=.001 sustain=.05 decay=.05",
|
||||||
|
DurationSeconds: 0.1f));
|
||||||
|
|
||||||
|
if (!string.Equals(result.RenderReceipt.Status, "succeeded", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Assert.Contains("Faust", result.CompileReceipt.FailureMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("succeeded", result.CompileReceipt.Status);
|
||||||
|
Assert.Equal(44100, result.RenderReceipt.SampleRate);
|
||||||
|
Assert.Equal(4410, result.RenderReceipt.SampleCount);
|
||||||
|
Assert.InRange(result.RenderReceipt.Peak, 0.001f, 1.0f);
|
||||||
|
Assert.True(File.Exists(UriToPath(result.RenderReceipt.Float32Uri)));
|
||||||
|
Assert.True(File.Exists(UriToPath(result.RenderReceipt.WavUri)));
|
||||||
|
Assert.Equal(result.RenderReceipt.SampleCount * sizeof(float), new FileInfo(UriToPath(result.RenderReceipt.Float32Uri)).Length);
|
||||||
|
Assert.True(File.Exists(Path.Combine(storeRoot, "sessions", $"{result.CompileReceipt.SessionId}.cc")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewStoreRoot()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), "aquasynth-daemon-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UriToPath(string uri) => new Uri(uri).LocalPath;
|
||||||
|
}
|
||||||
14
tools/AquaSynthDaemon/AquaSynthDaemon.csproj
Normal file
14
tools/AquaSynthDaemon/AquaSynthDaemon.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AquaSynth.Faust\AquaSynth.Faust.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
188
tools/AquaSynthDaemon/Program.cs
Normal file
188
tools/AquaSynthDaemon/Program.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using AquaSynth.Faust;
|
||||||
|
|
||||||
|
var result = await AquaSynthDaemonCli.RunAsync(args, Console.In, Console.Out, Console.Error);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
internal static class AquaSynthDaemonCli
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args, TextReader input, TextWriter output, TextWriter error)
|
||||||
|
{
|
||||||
|
if (args.Length == 0 || args.Contains("--help", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await WriteUsageAsync(output).ConfigureAwait(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mode = args[0].ToLowerInvariant();
|
||||||
|
var options = ParseOptions(args.Skip(1).ToArray());
|
||||||
|
var storeRoot = Value(options, "--store", Path.Combine(Environment.CurrentDirectory, ".aquasynth"));
|
||||||
|
var serviceOptions = new AquaSynthDaemonOptions(
|
||||||
|
storeRoot,
|
||||||
|
SampleRate: IntValue(options, "--sample-rate", 44100),
|
||||||
|
MinRenderSeconds: FloatValue(options, "--min-seconds", 0.05f),
|
||||||
|
MaxRenderSeconds: FloatValue(options, "--max-seconds", 3.0f));
|
||||||
|
|
||||||
|
using var service = new AquaSynthDaemonService(serviceOptions);
|
||||||
|
return mode switch
|
||||||
|
{
|
||||||
|
"once" => await RunOnceAsync(service, options, output).ConfigureAwait(false),
|
||||||
|
"daemon" => await RunDaemonAsync(service, input, output, error).ConfigureAwait(false),
|
||||||
|
_ => await UnknownModeAsync(mode, error).ConfigureAwait(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> RunOnceAsync(
|
||||||
|
AquaSynthDaemonService service,
|
||||||
|
IReadOnlyDictionary<string, string> options,
|
||||||
|
TextWriter output)
|
||||||
|
{
|
||||||
|
var script = options.TryGetValue("--script", out var inlineScript)
|
||||||
|
? inlineScript
|
||||||
|
: await File.ReadAllTextAsync(Value(options, "--script-file", "")).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var command = new AquaSynthInstrumentSampleCommand(
|
||||||
|
Value(options, "--command-id", $"cli-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"),
|
||||||
|
Value(options, "--patch-id", "cli.patch"),
|
||||||
|
Value(options, "--faust-name", "aquasynth_cli_patch"),
|
||||||
|
script,
|
||||||
|
FloatValue(options, "--duration", 0.25f),
|
||||||
|
FloatValue(options, "--gain", 1.0f),
|
||||||
|
ParseControls(Value(options, "--control", "")),
|
||||||
|
IntValue(options, "--revision", 1));
|
||||||
|
|
||||||
|
var result = await service.SampleAsync(command).ConfigureAwait(false);
|
||||||
|
await output.WriteLineAsync(JsonSerializer.Serialize(result, JsonOptions)).ConfigureAwait(false);
|
||||||
|
return string.Equals(result.RenderReceipt.Status, "succeeded", StringComparison.Ordinal) ? 0 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> RunDaemonAsync(
|
||||||
|
AquaSynthDaemonService service,
|
||||||
|
TextReader input,
|
||||||
|
TextWriter output,
|
||||||
|
TextWriter error)
|
||||||
|
{
|
||||||
|
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 service.CompileAsync(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 service.SampleAsync(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> UnknownModeAsync(string mode, TextWriter error)
|
||||||
|
{
|
||||||
|
await error.WriteLineAsync($"Unknown AquaSynth daemon mode '{mode}'.").ConfigureAwait(false);
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteUsageAsync(TextWriter output)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync("""
|
||||||
|
AquaSynthDaemon
|
||||||
|
|
||||||
|
once --script-file patch.aqua [--store .aquasynth] [--duration 0.25] [--gain 1]
|
||||||
|
daemon [--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}}
|
||||||
|
""").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ParseOptions(string[] args)
|
||||||
|
{
|
||||||
|
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var index = 0; index < args.Length; index++)
|
||||||
|
{
|
||||||
|
var key = args[index];
|
||||||
|
if (!key.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options[key] = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)
|
||||||
|
? args[++index]
|
||||||
|
: "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Value(IReadOnlyDictionary<string, string> options, string key, string fallback) =>
|
||||||
|
options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
|
||||||
|
|
||||||
|
private static int IntValue(IReadOnlyDictionary<string, string> options, string key, int fallback) =>
|
||||||
|
options.TryGetValue(key, out var value) && int.TryParse(value, out var parsed) ? parsed : fallback;
|
||||||
|
|
||||||
|
private static float FloatValue(IReadOnlyDictionary<string, string> options, string key, float fallback) =>
|
||||||
|
options.TryGetValue(key, out var value) && float.TryParse(value, out var parsed) ? parsed : fallback;
|
||||||
|
|
||||||
|
private static Dictionary<string, float>? ParseControls(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var controls = new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var assignment in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var parts = assignment.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length == 2 && float.TryParse(parts[1], out var parsed))
|
||||||
|
{
|
||||||
|
controls[parts[0]] = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DaemonCommandEnvelope(string Command, JsonElement Payload);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue