diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 4b0c453..5b60bdc 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -81,6 +81,9 @@ binary lifetime, live instrument handles, control sessions, and sampled outputs. - `instrument.open`: allocate or attach to a compiled instrument session. - `instrument.control`: set one or more stable semantic controls. - `instrument.sample`: sample a live or offline instrument output. +- `instrument.stream`: compile/open a streaming instrument session, apply + block-indexed automation controls, publish CultMesh-compatible audio/control + stream descriptors, and write per-block sample packet receipts. - `speech.realize`: consume a Weksa utterance handoff and produce synth-driver controls plus render receipts. - `provider.advertise`: publish `gamecult.eve.provider_advertisement.v1`. @@ -100,6 +103,11 @@ invent a separate truth for compiled products or control state. 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. +- `stream`: runs a bounded automation stream locally. It compiles once, opens a + native Faust streaming patch, processes fixed-size blocks with optional + per-block controls, publishes CultMesh `Audio` and `Tensor` descriptors inside + the service, writes block `.f32` packet bodies, and commits one typed stream + receipt. Current witness roots: @@ -108,6 +116,8 @@ Current witness roots: - `.aquasynth/renders/{renderId}.cc` - `.aquasynth/samples/{renderId}.f32` - `.aquasynth/samples/{renderId}.wav` +- `.aquasynth/streams/{streamRunId}.cc` +- `.aquasynth/streams/{streamRunId}/audio-{sequence}.f32` - `.aquasynth/operator/operator-state.cc` Example: @@ -124,6 +134,12 @@ 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. +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. + ## Forbidden Writers - Weksa does not compile Faust or own instrument controls. diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs index 1974f08..6a96ce1 100644 --- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs +++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs @@ -3,6 +3,7 @@ using System.Text; using AquaSynth.Dsl; using GameCult.Caching; using GameCult.Caching.MessagePack; +using GameCult.Mesh; using MessagePack; namespace AquaSynth.Faust; @@ -14,6 +15,8 @@ public static class AquaSynthDaemonSchemas 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 AutomationStreamCommand = "aquasynth.command.automation_stream.v1"; + public const string AutomationStreamReceipt = "aquasynth.automation_stream_receipt.v1"; public const string OperatorState = "aquasynth.operator_state.v1"; } @@ -49,6 +52,24 @@ public sealed record AquaSynthInstrumentSampleCommand( [property: Key(6)] Dictionary? Controls = null, [property: Key(7)] int Revision = 1); +[CultDocument("aquasynth.command.automation_stream", AquaSynthDaemonSchemas.AutomationStreamCommand)] +[MessagePackObject] +public sealed record AquaSynthAutomationStreamCommand( + [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 BlockSize = 128, + [property: Key(5)] int BlockCount = 8, + [property: Key(6)] AquaSynthAutomationControlFrame[]? ControlFrames = null, + [property: Key(7)] float Gain = 1.0f, + [property: Key(8)] int Revision = 1); + +[MessagePackObject] +public sealed record AquaSynthAutomationControlFrame( + [property: Key(0)] int Block, + [property: Key(1)] Dictionary Controls); + [CultDocument("aquasynth.compiled_instrument_session", AquaSynthDaemonSchemas.CompiledInstrumentSession)] [MessagePackObject] public sealed record AquaSynthCompiledInstrumentSession( @@ -102,6 +123,52 @@ public sealed record AquaSynthRenderSampleReceipt( [property: Key(14)] string FailureCode = "", [property: Key(15)] string FailureMessage = ""); +[CultDocument("aquasynth.automation_stream_receipt", AquaSynthDaemonSchemas.AutomationStreamReceipt)] +[MessagePackObject] +public sealed record AquaSynthAutomationStreamReceipt( + [property: Key(0), CultName] string StreamRunId, + [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)] string VerseId, + [property: Key(7)] string AudioStreamId, + [property: Key(8)] string ControlStreamId, + [property: Key(9)] int SampleRate, + [property: Key(10)] int BlockSize, + [property: Key(11)] int BlockCount, + [property: Key(12)] AquaSynthCultMeshStreamDescriptor[] Streams, + [property: Key(13)] AquaSynthAutomationPacketReceipt[] Packets, + [property: Key(14)] string FailureCode = "", + [property: Key(15)] string FailureMessage = ""); + +[MessagePackObject] +public sealed record AquaSynthCultMeshStreamDescriptor( + [property: Key(0)] string StreamId, + [property: Key(1)] string VerseId, + [property: Key(2)] string OwnerPeerId, + [property: Key(3)] string Kind, + [property: Key(4)] string SampleFormat, + [property: Key(5)] int SampleRate, + [property: Key(6)] int Channels, + [property: Key(7)] int FramesPerPacket, + [property: Key(8)] string[] PreferredTransports, + [property: Key(9)] string? MetadataSchemaId); + +[MessagePackObject] +public sealed record AquaSynthAutomationPacketReceipt( + [property: Key(0)] ulong Sequence, + [property: Key(1)] long TimestampNs, + [property: Key(2)] long DurationNs, + [property: Key(3)] int SampleCount, + [property: Key(4)] float Peak, + [property: Key(5)] float Rms, + [property: Key(6)] string Float32Uri, + [property: Key(7)] string ContentHash, + [property: Key(8)] string PageRef, + [property: Key(9)] int ControlCount); + [CultDocument("aquasynth.operator_state", AquaSynthDaemonSchemas.OperatorState)] [CultGlobal] [MessagePackObject] @@ -111,9 +178,10 @@ public sealed record AquaSynthOperatorState( [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); + [property: Key(5)] int StreamCount, + [property: Key(6)] string LastCommandId, + [property: Key(7)] string LastStatus, + [property: Key(8)] string[] CultMeshKeys); public sealed record AquaSynthSampleResult( AquaSynthPatchCompileReceipt CompileReceipt, @@ -126,6 +194,7 @@ public sealed class AquaSynthDaemonService : IDisposable private readonly Dictionary sessions = new(StringComparer.Ordinal); private int compileCount; private int renderCount; + private int streamCount; private bool disposed; public AquaSynthDaemonService(AquaSynthDaemonOptions? options = null) @@ -281,6 +350,142 @@ public sealed class AquaSynthDaemonService : IDisposable return new AquaSynthSampleResult(compile, receipt); } + public async Task StreamAutomationAsync(AquaSynthAutomationStreamCommand command) + { + ObjectDisposedException.ThrowIf(disposed, this); + ValidateStream(command); + + var durationSeconds = command.BlockSize * command.BlockCount / (float)options.SampleRate; + var compile = await CompileAsync(new AquaSynthPatchCompileCommand( + command.CommandId, + command.PatchId, + command.FaustName, + command.Script, + command.Revision, + durationSeconds)).ConfigureAwait(false); + + var runId = $"{command.CommandId}-stream"; + var verseId = "aquasynth.instrument"; + var audioStreamId = $"aquasynth.audio.{SafeFileName(command.CommandId)}"; + var controlStreamId = $"aquasynth.controls.{SafeFileName(command.CommandId)}"; + + if (!string.Equals(compile.Status, "succeeded", StringComparison.Ordinal)) + { + var failed = new AquaSynthAutomationStreamReceipt( + runId, + command.CommandId, + command.PatchId, + compile.SessionId, + "failed", + Now(), + verseId, + audioStreamId, + controlStreamId, + options.SampleRate, + command.BlockSize, + command.BlockCount, + [], + [], + "compile_failed", + compile.FailureMessage); + await WriteReceiptAsync("streams", failed.StreamRunId, failed).ConfigureAwait(false); + await WriteOperatorStateAsync(command.CommandId, failed.Status).ConfigureAwait(false); + return failed; + } + + var patch = sessions[compile.SessionId]; + var catalog = new CultMeshStreamCatalog(); + var audioDescriptor = CreateAudioStreamDescriptor(audioStreamId, verseId, command.BlockSize, patch.Manifest.SampleRate); + var controlDescriptor = CreateControlStreamDescriptor(controlStreamId, verseId, command.BlockSize, patch.Manifest.SampleRate); + catalog.Declare(audioDescriptor); + catalog.Declare(controlDescriptor); + + var controlsByBlock = (command.ControlFrames ?? []) + .GroupBy(frame => frame.Block) + .ToDictionary(group => group.Key, group => NormalizeControls(group.Last().Controls), EqualityComparer.Default); + + var streamDir = Path.Combine(options.StoreRoot, "streams", SafeFileName(runId)); + Directory.CreateDirectory(streamDir); + var packets = new List(); + using (var stream = patch.CreateStreamingPatch()) + { + for (var block = 0; block < command.BlockCount; block++) + { + controlsByBlock.TryGetValue(block, out var controls); + var outputs = new float[Math.Max(1, stream.OutputCount)][]; + for (var channel = 0; channel < outputs.Length; channel++) + { + outputs[channel] = new float[command.BlockSize]; + } + + stream.ProcessBlock([], outputs, command.BlockSize, controls); + var mono = MixMono(outputs, command.BlockSize, command.Gain); + var floatPath = Path.Combine(streamDir, $"audio-{block:D6}.f32"); + await WriteFloat32Async(floatPath, mono).ConfigureAwait(false); + + var timestampNs = block * command.BlockSize * 1_000_000_000L / patch.Manifest.SampleRate; + var durationNs = command.BlockSize * 1_000_000_000L / patch.Manifest.SampleRate; + var pageRef = ToArtifactUri(floatPath); + var audioHandle = new CultMeshStreamFrameHandle( + audioStreamId, + (ulong)block, + timestampNs, + CultMeshStreamBodyTransport.CultCachePage, + durationNs, + mono.Length * sizeof(float), + pageRef: pageRef, + metadata: new Dictionary + { + ["schema"] = AquaSynthDaemonSchemas.AutomationStreamReceipt, + ["patchId"] = command.PatchId, + ["sessionId"] = compile.SessionId + }); + catalog.PublishFrame(audioHandle); + catalog.PublishFrame(new CultMeshStreamFrameHandle( + controlStreamId, + (ulong)block, + timestampNs, + CultMeshStreamBodyTransport.InlineBytes, + durationNs, + controls?.Count ?? 0, + metadata: (controls ?? new Dictionary()) + .ToDictionary(pair => pair.Key, pair => pair.Value.ToString("R"), StringComparer.OrdinalIgnoreCase))); + + packets.Add(new AquaSynthAutomationPacketReceipt( + (ulong)block, + timestampNs, + durationNs, + mono.Length, + Peak(mono), + Rms(mono), + pageRef, + await Sha256Async(floatPath).ConfigureAwait(false), + audioHandle.PageRef ?? pageRef, + controls?.Count ?? 0)); + } + } + + streamCount++; + var receipt = new AquaSynthAutomationStreamReceipt( + runId, + command.CommandId, + command.PatchId, + compile.SessionId, + "succeeded", + Now(), + verseId, + audioStreamId, + controlStreamId, + patch.Manifest.SampleRate, + command.BlockSize, + command.BlockCount, + [ToReceiptDescriptor(audioDescriptor), ToReceiptDescriptor(controlDescriptor)], + [.. packets]); + await WriteReceiptAsync("streams", receipt.StreamRunId, receipt).ConfigureAwait(false); + await WriteOperatorStateAsync(command.CommandId, receipt.Status).ConfigureAwait(false); + return receipt; + } + public void Dispose() { if (disposed) @@ -306,6 +511,7 @@ public sealed class AquaSynthDaemonService : IDisposable options.StoreRoot, compileCount, renderCount, + streamCount, commandId, status, [ @@ -313,6 +519,7 @@ public sealed class AquaSynthDaemonService : IDisposable "aquasynth.compile/jobs", "aquasynth.instrument/sessions", "aquasynth.render/samples", + "aquasynth.instrument/automation-streams", "aquasynth.operator/status" ]); await WriteReceiptAsync("operator", "operator-state", state, global: true).ConfigureAwait(false); @@ -374,6 +581,23 @@ public sealed class AquaSynthDaemonService : IDisposable } } + private static void ValidateStream(AquaSynthAutomationStreamCommand 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.BlockSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(command.BlockSize), "Stream block size must be positive."); + } + + if (command.BlockCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(command.BlockCount), "Stream block count must be positive."); + } + } + private static void Require(string value, string name) { if (string.IsNullOrWhiteSpace(value)) @@ -390,6 +614,64 @@ public sealed class AquaSynthDaemonService : IDisposable await stream.WriteAsync(buffer).ConfigureAwait(false); } + private static float[] MixMono(IReadOnlyList outputs, int frameCount, float gain) + { + var mono = new float[frameCount]; + for (var frame = 0; frame < frameCount; frame++) + { + var sample = 0.0f; + for (var channel = 0; channel < outputs.Count; channel++) + { + sample += outputs[channel][frame]; + } + + mono[frame] = Math.Clamp(sample / Math.Max(1, outputs.Count) * gain, -1.0f, 1.0f); + } + + return mono; + } + + private static CultMeshStreamDescriptor CreateAudioStreamDescriptor(string streamId, string verseId, int blockSize, int sampleRate) => + new( + streamId, + verseId, + "aquasynth.service", + CultMeshStreamKind.Audio, + new CultMeshStreamClock("aquasynth.audio.clock", "native-faust", sampleRate, confidence: 1.0, evidenceKind: "native-faust-block"), + [CultMeshStreamBodyTransport.CultCachePage, CultMeshStreamBodyTransport.InlineBytes], + label: "AquaSynth rendered audio blocks", + audio: new CultMeshAudioStreamFormat(sampleRate, 1, "f32", blockSize), + requiredAccess: CultMeshStreamAccess.Read, + maxInFlightFrames: 4, + metadataSchemaId: AquaSynthDaemonSchemas.AutomationStreamReceipt); + + private static CultMeshStreamDescriptor CreateControlStreamDescriptor(string streamId, string verseId, int blockSize, int sampleRate) => + new( + streamId, + verseId, + "aquasynth.service", + CultMeshStreamKind.Tensor, + new CultMeshStreamClock("aquasynth.control.clock", "daemon-automation", sampleRate, confidence: 1.0, evidenceKind: "block-control-frame"), + [CultMeshStreamBodyTransport.InlineBytes, CultMeshStreamBodyTransport.CultCachePage], + label: "AquaSynth automation control blocks", + audio: null, + requiredAccess: CultMeshStreamAccess.Read, + maxInFlightFrames: 4, + metadataSchemaId: AquaSynthDaemonSchemas.AutomationStreamCommand); + + private static AquaSynthCultMeshStreamDescriptor ToReceiptDescriptor(CultMeshStreamDescriptor descriptor) => + new( + descriptor.StreamId, + descriptor.VerseId, + descriptor.OwnerPeerId, + descriptor.Kind.ToString(), + descriptor.Audio?.SampleFormat ?? "controls", + descriptor.Audio?.SampleRate ?? descriptor.Clock.SampleRate, + descriptor.Audio?.Channels ?? 1, + descriptor.Audio?.FramesPerPacket ?? 0, + descriptor.PreferredTransports.Select(transport => transport.ToString()).ToArray(), + descriptor.MetadataSchemaId); + private static async Task WriteWavAsync(string path, float[] samples, int sampleRate) { await using var stream = File.Create(path); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 40d05ed..b763d53 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -278,3 +278,4 @@ {"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=` 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=` 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"} diff --git a/state/map.yaml b/state/map.yaml index cd31d73..4a215a5 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -120,7 +120,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 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 + - 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 - IPA-to-phonetic-feature planning, articulatory gesture planning, and human/alien vocal-tract roadmap design - phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis - packed-buffer native backprop/Adam as the shared AquaSynth gradient-descent primitive diff --git a/state/spine.yaml b/state/spine.yaml index 4f56034..c055986 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -57,7 +57,7 @@ current_mechanism: live_invariants: - AquaSynth owns its patch graph; external synths are references, not runtime dependencies. - - AquaSynthDaemonService owns the local daemon command boundary for patch.compile 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. + - 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. - Patch scripts lower into explicit graph records before Faust emission. - Song-snippet parity is scene-audio pressure, not redistributed fixture data. The worker may derive local tempo/register/scale hints from private clips, but agents must express live rhythm and pitch motion through owned AquaSynth controls until native sequencing sugar has an explicit DSL/lowering owner. - Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields. diff --git a/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs index 96c5aff..fcf7647 100644 --- a/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs +++ b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs @@ -53,6 +53,49 @@ public sealed class AquaSynthDaemonServiceTests Assert.True(File.Exists(Path.Combine(storeRoot, "sessions", $"{result.CompileReceipt.SessionId}.cc"))); } + [Fact] + public async Task DaemonAutomationStreamPublishesCultMeshFrameReceiptsWhenNativeFaustIsAvailable() + { + var storeRoot = NewStoreRoot(); + using var service = new AquaSynthDaemonService(new AquaSynthDaemonOptions(storeRoot)); + + var receipt = await service.StreamAutomationAsync(new AquaSynthAutomationStreamCommand( + "stream-sine", + "test.stream.sine", + "test_stream_sine", + "param path=/macro/gain default=.2 min=0 max=1 step=.001; voice wave=sine freq=440 gain=@/macro/gain attack=.001 sustain=.1 decay=.05", + BlockSize: 128, + BlockCount: 3, + ControlFrames: + [ + new AquaSynthAutomationControlFrame(1, new Dictionary { ["/macro/gain"] = .05f }), + new AquaSynthAutomationControlFrame(2, new Dictionary { ["/macro/gain"] = .3f }) + ])); + + if (!string.Equals(receipt.Status, "succeeded", StringComparison.Ordinal)) + { + Assert.Contains("Faust", receipt.FailureMessage, StringComparison.OrdinalIgnoreCase); + return; + } + + Assert.Equal("aquasynth.instrument", receipt.VerseId); + Assert.Equal(2, receipt.Streams.Length); + Assert.Contains(receipt.Streams, stream => stream.StreamId == receipt.AudioStreamId && stream.Kind == "Audio"); + Assert.Contains(receipt.Streams, stream => stream.StreamId == receipt.ControlStreamId && stream.Kind == "Tensor"); + Assert.Equal(3, receipt.Packets.Length); + Assert.All(receipt.Packets, packet => + { + Assert.Equal(128, packet.SampleCount); + Assert.True(File.Exists(UriToPath(packet.Float32Uri))); + Assert.Equal(128 * sizeof(float), new FileInfo(UriToPath(packet.Float32Uri)).Length); + Assert.StartsWith("file:///", packet.PageRef, StringComparison.Ordinal); + }); + Assert.Equal(0, receipt.Packets[0].ControlCount); + Assert.Equal(1, receipt.Packets[1].ControlCount); + Assert.Equal(1, receipt.Packets[2].ControlCount); + Assert.True(File.Exists(Path.Combine(storeRoot, "streams", "stream-sine-stream.cc"))); + } + private static string NewStoreRoot() { var path = Path.Combine(Path.GetTempPath(), "aquasynth-daemon-tests", Guid.NewGuid().ToString("N")); diff --git a/tools/AquaSynthDaemon/Program.cs b/tools/AquaSynthDaemon/Program.cs index 1e1e658..cb7c18b 100644 --- a/tools/AquaSynthDaemon/Program.cs +++ b/tools/AquaSynthDaemon/Program.cs @@ -35,6 +35,7 @@ internal static class AquaSynthDaemonCli return mode switch { "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), _ => await UnknownModeAsync(mode, error).ConfigureAwait(false) }; @@ -64,6 +65,31 @@ internal static class AquaSynthDaemonCli return string.Equals(result.RenderReceipt.Status, "succeeded", StringComparison.Ordinal) ? 0 : 2; } + private static async Task RunStreamOnceAsync( + AquaSynthDaemonService service, + IReadOnlyDictionary options, + TextWriter output) + { + var script = options.TryGetValue("--script", out var inlineScript) + ? inlineScript + : await File.ReadAllTextAsync(Value(options, "--script-file", "")).ConfigureAwait(false); + + var command = new AquaSynthAutomationStreamCommand( + Value(options, "--command-id", $"stream-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"), + Value(options, "--patch-id", "cli.stream.patch"), + Value(options, "--faust-name", "aquasynth_cli_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)); + + var receipt = await service.StreamAutomationAsync(command).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 RunDaemonAsync( AquaSynthDaemonService service, TextReader input, @@ -97,6 +123,13 @@ internal static class AquaSynthDaemonCli var receipt = await service.SampleAsync(command).ConfigureAwait(false); await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); } + else if (string.Equals(envelope.Command, "instrument.stream", StringComparison.OrdinalIgnoreCase)) + { + var command = envelope.Payload.Deserialize(JsonOptions) + ?? throw new InvalidOperationException("instrument.stream payload was empty."); + var receipt = await service.StreamAutomationAsync(command).ConfigureAwait(false); + await output.WriteLineAsync(JsonSerializer.Serialize(receipt, JsonOptions)).ConfigureAwait(false); + } else { throw new InvalidOperationException($"Unknown command '{envelope.Command}'."); @@ -129,10 +162,12 @@ internal static class AquaSynthDaemonCli AquaSynthDaemon 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] 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}} + {"command":"instrument.stream","payload":{"commandId":"demo-stream","patchId":"demo.patch","faustName":"demo","script":"param path=/macro/gain default=.2 min=0 max=1 step=.001; voice wave=sine freq=440 gain=@/macro/gain","blockSize":128,"blockCount":4,"controlFrames":[{"block":1,"controls":{"/macro/gain":0.05}}]}} """).ConfigureAwait(false); } @@ -184,5 +219,31 @@ internal static class AquaSynthDaemonCli return controls; } + private static AquaSynthAutomationControlFrame[]? ParseControlFrames(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var frames = new List(); + foreach (var frameSpec in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = frameSpec.Split(':', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 || !int.TryParse(parts[0], out var block)) + { + continue; + } + + var controls = ParseControls(parts[1]); + if (controls is { Count: > 0 }) + { + frames.Add(new AquaSynthAutomationControlFrame(block, controls)); + } + } + + return frames.Count == 0 ? null : [.. frames]; + } + private sealed record DaemonCommandEnvelope(string Command, JsonElement Payload); }