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));
+ }
+ }
}