From e1673aa2a2ad17a2dc06260ba2e7b5496276a89b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 27 May 2026 18:54:58 +0100 Subject: [PATCH] Add native Faust streaming sessions --- src/AquaSynth.Faust/AquaSynth.Faust.csproj | 4 +- src/AquaSynth.Faust/NativeFaustRuntime.cs | 174 +++++++++++++++++- state/map.yaml | 2 +- .../NativeFaustRuntimeTests.cs | 43 +++++ 4 files changed, 218 insertions(+), 5 deletions(-) diff --git a/src/AquaSynth.Faust/AquaSynth.Faust.csproj b/src/AquaSynth.Faust/AquaSynth.Faust.csproj index c638f96..6741643 100644 --- a/src/AquaSynth.Faust/AquaSynth.Faust.csproj +++ b/src/AquaSynth.Faust/AquaSynth.Faust.csproj @@ -6,10 +6,10 @@ enable AquaSynth.Faust AquaSynth.Faust - 0.1.0-alpha.3 + 0.1.0-alpha.4 GameCult GameCult - Native Faust toolchain loading, compile manifests, DSP factory lifetime, and offline rendering for AquaSynth patches. + Native Faust toolchain loading, compile manifests, DSP factory lifetime, offline rendering, and streaming DSP sessions for AquaSynth patches. README.md icon-package.png https://github.com/GameCult/AquaSynth diff --git a/src/AquaSynth.Faust/NativeFaustRuntime.cs b/src/AquaSynth.Faust/NativeFaustRuntime.cs index 5c14913..ce5ef38 100644 --- a/src/AquaSynth.Faust/NativeFaustRuntime.cs +++ b/src/AquaSynth.Faust/NativeFaustRuntime.cs @@ -28,6 +28,7 @@ public sealed record AquaSynthNativeManifest( string CompileKey, int Revision, int SampleRate, + int InputCount, int OutputCount, int FrameCount, float DurationSeconds, @@ -60,6 +61,12 @@ public sealed class AquaSynthCompiledPatch : IDisposable public float[] Render(float gain = 1.0f) => Render(null, gain); + public AquaSynthStreamingPatch CreateStreamingPatch() + { + ObjectDisposedException.ThrowIf(disposed, this); + return new AquaSynthStreamingPatch(toolchain, factory, Manifest); + } + public float[] Render(IReadOnlyDictionary? controls, float gain = 1.0f) { ObjectDisposedException.ThrowIf(disposed, this); @@ -137,6 +144,154 @@ public sealed class AquaSynthCompiledPatch : IDisposable } } +public sealed class AquaSynthStreamingPatch : IDisposable +{ + private readonly FaustNativeToolchain toolchain; + private readonly IntPtr dsp; + private readonly Dictionary controlZones; + private bool disposed; + + internal AquaSynthStreamingPatch( + FaustNativeToolchain toolchain, + IntPtr factory, + AquaSynthNativeManifest manifest) + { + this.toolchain = toolchain; + Manifest = manifest; + dsp = toolchain.CreateInstance(factory); + try + { + toolchain.InitInstance(dsp, Manifest.SampleRate); + controlZones = toolchain.ControlZones(dsp); + } + catch + { + toolchain.DeleteInstance(dsp); + throw; + } + } + + public AquaSynthNativeManifest Manifest { get; } + + public int InputCount => Manifest.InputCount; + + public int OutputCount => Manifest.OutputCount; + + public void SetControls(IReadOnlyDictionary controls) + { + ObjectDisposedException.ThrowIf(disposed, this); + toolchain.SetControls(dsp, controls, controlZones); + } + + public void ProcessBlock(int frameCount, IntPtr inputChannelPointers, IntPtr outputChannelPointers, IReadOnlyDictionary? controls = null) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (frameCount <= 0) + { + return; + } + + if (controls is not null) + { + SetControls(controls); + } + + toolchain.Compute(dsp, frameCount, inputChannelPointers, outputChannelPointers); + } + + public void ProcessBlock(float[][] inputs, float[][] outputs, int frameCount, IReadOnlyDictionary? controls = null) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (frameCount <= 0) + { + return; + } + + ValidateManagedBlock(inputs, InputCount, frameCount, nameof(inputs)); + ValidateManagedBlock(outputs, OutputCount, frameCount, nameof(outputs)); + + var inputPointers = new IntPtr[Math.Max(InputCount, 1)]; + var outputPointers = new IntPtr[Math.Max(OutputCount, 1)]; + var inputHandles = new GCHandle[InputCount]; + var outputHandles = new GCHandle[OutputCount]; + try + { + for (var channel = 0; channel < InputCount; channel++) + { + inputHandles[channel] = GCHandle.Alloc(inputs[channel], GCHandleType.Pinned); + inputPointers[channel] = inputHandles[channel].AddrOfPinnedObject(); + } + + for (var channel = 0; channel < OutputCount; channel++) + { + outputHandles[channel] = GCHandle.Alloc(outputs[channel], GCHandleType.Pinned); + outputPointers[channel] = outputHandles[channel].AddrOfPinnedObject(); + } + + var inputPointerHandle = GCHandle.Alloc(inputPointers, GCHandleType.Pinned); + var outputPointerHandle = GCHandle.Alloc(outputPointers, GCHandleType.Pinned); + try + { + ProcessBlock( + frameCount, + InputCount == 0 ? IntPtr.Zero : inputPointerHandle.AddrOfPinnedObject(), + outputPointerHandle.AddrOfPinnedObject(), + controls); + } + finally + { + inputPointerHandle.Free(); + outputPointerHandle.Free(); + } + } + finally + { + foreach (var handle in inputHandles) + { + if (handle.IsAllocated) + { + handle.Free(); + } + } + + foreach (var handle in outputHandles) + { + if (handle.IsAllocated) + { + handle.Free(); + } + } + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + toolchain.DeleteInstance(dsp); + disposed = true; + } + + private static void ValidateManagedBlock(float[][] channels, int expectedChannels, int frameCount, string parameterName) + { + if (channels.Length < expectedChannels) + { + throw new ArgumentException($"Expected at least {expectedChannels} channels.", parameterName); + } + + for (var channel = 0; channel < expectedChannels; channel++) + { + if (channels[channel].Length < frameCount) + { + throw new ArgumentException($"Channel {channel} has fewer than {frameCount} frames.", parameterName); + } + } + } +} + public sealed class AquaSynthPatchCompiler : IDisposable { private readonly AquaSynthNativeOptions options; @@ -354,6 +509,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable try { + var inputs = Math.Max(toolchain.GetNumInputs(probe), 0); var outputs = Math.Max(toolchain.GetNumOutputs(probe), 1); var frames = Math.Max(1, (int)MathF.Ceiling(durationSeconds * options.SampleRate)); var controlPaths = toolchain.ControlPaths(probe); @@ -364,6 +520,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable CompileKey(identity), identity.Revision, options.SampleRate, + inputs, outputs, frames, durationSeconds, @@ -480,6 +637,7 @@ internal sealed class FaustNativeToolchain : IDisposable private readonly DeleteInstanceFn deleteInstance; private readonly InitInstanceFn initInstance; private readonly ComputeInstanceFn computeInstance; + private readonly GetNumInputsFn getNumInputs; private readonly GetNumOutputsFn getNumOutputs; private readonly BuildUserInterfaceFn buildUserInterface; private readonly StartFactoriesFn startFactories; @@ -498,6 +656,7 @@ internal sealed class FaustNativeToolchain : IDisposable deleteInstance = Export("deleteCDSPInstance"); initInstance = Export("initCDSPInstance"); computeInstance = Export("computeCDSPInstance"); + getNumInputs = Export("getNumInputsCDSPInstance"); getNumOutputs = Export("getNumOutputsCDSPInstance"); buildUserInterface = Export("buildUserInterfaceCDSPInstance"); startFactories = Export("startMTDSPFactories"); @@ -570,14 +729,22 @@ internal sealed class FaustNativeToolchain : IDisposable public void Compute(IntPtr dsp, int count, IntPtr inputs, IntPtr outputs) => computeInstance(dsp, count, inputs, outputs); + public int GetNumInputs(IntPtr dsp) => getNumInputs(dsp); + public int GetNumOutputs(IntPtr dsp) => getNumOutputs(dsp); public IReadOnlyList ControlPaths(IntPtr dsp) => - BuildControlMap(dsp).Keys.Order(StringComparer.Ordinal).ToArray(); + ControlZones(dsp).Keys.Order(StringComparer.Ordinal).ToArray(); + + public Dictionary ControlZones(IntPtr dsp) => BuildControlMap(dsp); public void SetControls(IntPtr dsp, IReadOnlyDictionary controls) { - var map = BuildControlMap(dsp); + SetControls(dsp, controls, ControlZones(dsp)); + } + + public void SetControls(IntPtr dsp, IReadOnlyDictionary controls, IReadOnlyDictionary map) + { foreach (var (path, value) in controls) { if (!map.TryGetValue(path, out var zone) && @@ -645,6 +812,9 @@ internal sealed class FaustNativeToolchain : IDisposable [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void ComputeInstanceFn(IntPtr dsp, int count, IntPtr inputs, IntPtr outputs); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int GetNumInputsFn(IntPtr dsp); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int GetNumOutputsFn(IntPtr dsp); diff --git a/state/map.yaml b/state/map.yaml index 3bda345..37f2eba 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -34,7 +34,7 @@ surfaces: zyn_reference_build_patches: tools/zyn-reference/patches zyn_project_fixtures: tests/AquaSynth.Dsl.Tests/Fixtures/ZynAddSubFX/ProjectAuthored core_package: AquaSynth.Core 0.1.0-alpha.3 - faust_package: AquaSynth.Faust 0.1.0-alpha.3 + faust_package: AquaSynth.Faust 0.1.0-alpha.4 modules: model: src/AquaSynth.Core/Model.cs parser: src/AquaSynth.Core/PatchScript.cs diff --git a/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs b/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs index 8dd0177..29c90bc 100644 --- a/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs +++ b/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs @@ -49,4 +49,47 @@ public sealed class NativeFaustRuntimeTests Assert.InRange(samples.Max(sample => MathF.Abs(sample)), 0.001f, 1.0f); } } + + [Fact] + public void NativeFaustStreamingPatchProcessesInputBlocksWhenToolchainIsAvailable() + { + const string source = """ + import("stdfaust.lib"); + gain = hslider("gain", 1.0, 0.0, 2.0, 0.001); + process = _ * gain; + """; + + using var compiler = new AquaSynthPatchCompiler(); + if (!compiler.TryCompileSource( + new AquaSynthCompileIdentity("native_streaming_smoke", "native_streaming_smoke", source), + source, + 0.05f, + out var patch, + out var error)) + { + if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true || + error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true) + { + return; + } + + Assert.Fail($"AquaSynth native Faust streaming compile failed: {error}"); + } + + using (patch) + using (var stream = patch!.CreateStreamingPatch()) + { + Assert.Equal(1, stream.InputCount); + Assert.Equal(1, stream.OutputCount); + + var input = new[] { Enumerable.Repeat(0.25f, 128).ToArray() }; + var output = new[] { new float[128] }; + stream.ProcessBlock(input, output, 128, new Dictionary + { + ["gain"] = 0.5f + }); + + Assert.All(output[0], sample => Assert.InRange(sample, 0.124f, 0.126f)); + } + } }