Own native Faust render runtime
This commit is contained in:
parent
2d13e87aa3
commit
47106690b0
9 changed files with 616 additions and 2 deletions
|
|
@ -55,6 +55,9 @@ external reference synth sources, and SysEx banks do not ship in
|
|||
- `FaustCompiler.ValidateAsync(source)` compile-checks with Faust when present.
|
||||
- `FaustCompiler.CompileAsync(source, options)` writes generated backend code
|
||||
through an installed or resolved Faust compiler.
|
||||
- `AquaSynthNativeCompiler` loads a Faust native toolchain, compiles `.aqua`
|
||||
scripts into hosted DSP factories, emits manifests, and renders buffers for
|
||||
engine hosts.
|
||||
- `BuiltInScripts.ReferenceScripts()` carries readable SFXR, BFXR-flavored,
|
||||
808, FM bell, wobble bass, and advanced layered patches. They are stable
|
||||
references for testing and for judging whether the DSL can express useful
|
||||
|
|
@ -87,6 +90,12 @@ host the finished artifact, bind controls, and schedule audio. Native Faust
|
|||
targets are the preferred shipping path when the platform allows them; C# output
|
||||
is convenient authoring machinery, not the ceiling.
|
||||
|
||||
`AquaSynthNativeCompiler` is the first runtime-facing version of that boundary:
|
||||
it owns native `libfaust` loading, script parsing, Faust emission, compile keys,
|
||||
DSP source artifacts, manifests, and sample rendering. Engine callers keep their
|
||||
device and scheduling code, and ask AquaSynth for compiled/renderable synth
|
||||
products.
|
||||
|
||||
See [`docs/faust-toolchain-boundary.md`](docs/faust-toolchain-boundary.md).
|
||||
|
||||
## Status
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ AquaSynth owns:
|
|||
- parameter and bus manifests
|
||||
- diagnostics and compile provenance
|
||||
- dynamic patch compilation outside the realtime callback
|
||||
- native Faust factory lifetime and offline/sample-buffer rendering for hosts
|
||||
|
||||
Aquarium owns:
|
||||
|
||||
|
|
@ -63,6 +64,11 @@ ordinary patch controls move through the compiled DSP parameter API.
|
|||
- Consumer runtimes should normally bundle compiled DSP artifacts plus manifests,
|
||||
not the compiler itself, unless live user patch authoring is a product feature.
|
||||
|
||||
Current API foothold: `AquaSynthNativeCompiler` loads `libfaust`, compiles
|
||||
`.aqua` scripts into native Faust factories, writes optional `.dsp` artifacts,
|
||||
returns `AquaSynthNativeManifest`, and renders mono sample buffers for hosts that
|
||||
need triggered one-shot playback.
|
||||
|
||||
## Invariants
|
||||
|
||||
- AquaSynth remains the authority for what a patch means.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<Version>0.1.0-aquasynth.1</Version>
|
||||
<Authors>GameCult</Authors>
|
||||
<Company>GameCult</Company>
|
||||
<Description>AquaSynth patch DSL model, parser, analysis helpers, and Faust source emitter.</Description>
|
||||
<Description>AquaSynth patch DSL model, parser, analysis helpers, Faust source emitter, and native Faust compile/render runtime.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageIcon>icon-package.png</PackageIcon>
|
||||
<RepositoryUrl>https://github.com/GameCult/AquaSynth</RepositoryUrl>
|
||||
|
|
|
|||
550
src/AquaSynth.Dsl/NativeFaustRuntime.cs
Normal file
550
src/AquaSynth.Dsl/NativeFaustRuntime.cs
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AquaSynth.Dsl;
|
||||
|
||||
public sealed record AquaSynthNativeOptions(
|
||||
int SampleRate = 44100,
|
||||
string? FaustHome = null,
|
||||
string? DspSourceDirectory = null,
|
||||
float MinRenderSeconds = 0.05f,
|
||||
float MaxRenderSeconds = 3.0f);
|
||||
|
||||
public sealed record AquaSynthCompileIdentity(
|
||||
string Id,
|
||||
string FaustName,
|
||||
string Script,
|
||||
int Revision = 0)
|
||||
{
|
||||
public string CompileKey => AquaSynthNativeCompiler.CompileKey(this);
|
||||
}
|
||||
|
||||
public sealed record AquaSynthNativeManifest(
|
||||
string Id,
|
||||
string FaustName,
|
||||
string CompileKey,
|
||||
int Revision,
|
||||
int SampleRate,
|
||||
int OutputCount,
|
||||
int FrameCount,
|
||||
float DurationSeconds,
|
||||
double CompileMilliseconds,
|
||||
string FaustVersion,
|
||||
string FaustHome,
|
||||
string? DspSourcePath);
|
||||
|
||||
public sealed class AquaSynthCompiledPatch : IDisposable
|
||||
{
|
||||
private readonly FaustNativeToolchain toolchain;
|
||||
private readonly IntPtr factory;
|
||||
private bool disposed;
|
||||
|
||||
internal AquaSynthCompiledPatch(FaustNativeToolchain toolchain, IntPtr factory, AquaSynthNativeManifest manifest)
|
||||
{
|
||||
this.toolchain = toolchain;
|
||||
this.factory = factory;
|
||||
Manifest = manifest;
|
||||
}
|
||||
|
||||
public AquaSynthNativeManifest Manifest { get; }
|
||||
|
||||
public float[] Render(float gain = 1.0f)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
var dsp = toolchain.CreateInstance(factory);
|
||||
try
|
||||
{
|
||||
toolchain.InitInstance(dsp, Manifest.SampleRate);
|
||||
var outputs = new float[Manifest.OutputCount][];
|
||||
var outputPointers = new IntPtr[Manifest.OutputCount];
|
||||
var handles = new GCHandle[Manifest.OutputCount];
|
||||
try
|
||||
{
|
||||
for (var channel = 0; channel < Manifest.OutputCount; channel++)
|
||||
{
|
||||
outputs[channel] = new float[Manifest.FrameCount];
|
||||
handles[channel] = GCHandle.Alloc(outputs[channel], GCHandleType.Pinned);
|
||||
outputPointers[channel] = handles[channel].AddrOfPinnedObject();
|
||||
}
|
||||
|
||||
var pointersHandle = GCHandle.Alloc(outputPointers, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
toolchain.Compute(dsp, Manifest.FrameCount, IntPtr.Zero, pointersHandle.AddrOfPinnedObject());
|
||||
}
|
||||
finally
|
||||
{
|
||||
pointersHandle.Free();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
if (handle.IsAllocated)
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mono = new float[Manifest.FrameCount];
|
||||
for (var index = 0; index < Manifest.FrameCount; index++)
|
||||
{
|
||||
var sample = 0.0f;
|
||||
for (var channel = 0; channel < Manifest.OutputCount; channel++)
|
||||
{
|
||||
sample += outputs[channel][index];
|
||||
}
|
||||
|
||||
mono[index] = Math.Clamp(sample / Manifest.OutputCount * gain, -1.0f, 1.0f);
|
||||
}
|
||||
|
||||
return mono;
|
||||
}
|
||||
finally
|
||||
{
|
||||
toolchain.DeleteInstance(dsp);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
toolchain.DeleteFactory(factory);
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AquaSynthNativeCompiler : IDisposable
|
||||
{
|
||||
private readonly FaustNativeToolchain toolchain;
|
||||
private readonly AquaSynthNativeOptions options;
|
||||
private bool disposed;
|
||||
|
||||
private AquaSynthNativeCompiler(FaustNativeToolchain toolchain, AquaSynthNativeOptions options)
|
||||
{
|
||||
this.toolchain = toolchain;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public string FaustVersion => toolchain.Version;
|
||||
|
||||
public string FaustHome => toolchain.Home;
|
||||
|
||||
public static bool TryLoad(out AquaSynthNativeCompiler? compiler, out string? error, AquaSynthNativeOptions? options = null)
|
||||
{
|
||||
options ??= new AquaSynthNativeOptions();
|
||||
compiler = null;
|
||||
if (!FaustNativeToolchain.TryLoad(options.FaustHome, out var toolchain, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
compiler = new AquaSynthNativeCompiler(toolchain!, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryRenderScript(
|
||||
string name,
|
||||
string script,
|
||||
float gain,
|
||||
out float[] samples,
|
||||
out string? error,
|
||||
AquaSynthNativeOptions? options = null)
|
||||
{
|
||||
samples = [];
|
||||
if (!TryLoad(out var compiler, out error, options))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (compiler)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var patch = compiler!.CompileScript(new AquaSynthCompileIdentity(name, name, script));
|
||||
samples = patch.Render(gain);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AquaSynthCompiledPatch CompileScript(AquaSynthCompileIdentity identity)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
var patch = PatchScript.Parse(identity.Script);
|
||||
var faustName = SafeFaustName(identity.FaustName);
|
||||
var export = FaustEmitter.Emit(patch, new FaustExportOptions(faustName));
|
||||
var duration = EstimateDuration(patch, options);
|
||||
var sourcePath = WriteDspSource(identity.Id, export.Source, options.DspSourceDirectory);
|
||||
return CompileSource(identity, faustName, export.Source, duration, sourcePath);
|
||||
}
|
||||
|
||||
public AquaSynthCompiledPatch CompileSource(
|
||||
AquaSynthCompileIdentity identity,
|
||||
string faustName,
|
||||
string source,
|
||||
float durationSeconds,
|
||||
string? dspSourcePath = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var args = new NativeStringArray(["-I", toolchain.SharePath, "-single", "-ftz", "2", "-vec", "-lv", "1"]);
|
||||
using var nativeName = new NativeUtf8String(faustName);
|
||||
using var nativeSource = new NativeUtf8String(source);
|
||||
using var target = new NativeUtf8String("");
|
||||
var errorBuffer = Marshal.AllocHGlobal(FaustNativeToolchain.ErrorBufferBytes);
|
||||
try
|
||||
{
|
||||
Span<byte> empty = stackalloc byte[FaustNativeToolchain.ErrorBufferBytes];
|
||||
Marshal.Copy(empty.ToArray(), 0, errorBuffer, FaustNativeToolchain.ErrorBufferBytes);
|
||||
var factory = toolchain.CreateFactoryFromString(
|
||||
nativeName.Pointer,
|
||||
nativeSource.Pointer,
|
||||
args.Count,
|
||||
args.Pointer,
|
||||
target.Pointer,
|
||||
errorBuffer,
|
||||
-1);
|
||||
if (factory == IntPtr.Zero)
|
||||
{
|
||||
var message = Marshal.PtrToStringUTF8(errorBuffer) ?? "unknown Faust compile failure";
|
||||
throw new InvalidOperationException(message.Trim());
|
||||
}
|
||||
|
||||
var probe = toolchain.CreateInstance(factory);
|
||||
if (probe == IntPtr.Zero)
|
||||
{
|
||||
toolchain.DeleteFactory(factory);
|
||||
throw new InvalidOperationException("Faust compiled but failed to create a DSP instance.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var outputs = Math.Max(toolchain.GetNumOutputs(probe), 1);
|
||||
var frames = Math.Max(1, (int)MathF.Ceiling(durationSeconds * options.SampleRate));
|
||||
stopwatch.Stop();
|
||||
var manifest = new AquaSynthNativeManifest(
|
||||
identity.Id,
|
||||
faustName,
|
||||
CompileKey(identity),
|
||||
identity.Revision,
|
||||
options.SampleRate,
|
||||
outputs,
|
||||
frames,
|
||||
durationSeconds,
|
||||
stopwatch.Elapsed.TotalMilliseconds,
|
||||
toolchain.Version,
|
||||
toolchain.Home,
|
||||
dspSourcePath);
|
||||
return new AquaSynthCompiledPatch(toolchain, factory, manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
toolchain.DeleteInstance(probe);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(errorBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
public static string CompileKey(AquaSynthCompileIdentity identity)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{identity.Revision}\n{identity.FaustName}\n{identity.Script}"));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
public static string SafeFaustName(string value)
|
||||
{
|
||||
var chars = value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray();
|
||||
var name = new string(chars).Trim('_');
|
||||
return string.IsNullOrWhiteSpace(name) ? "aquasynth_patch" : name;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
toolchain.Dispose();
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
private static float EstimateDuration(SynthPatch patch, AquaSynthNativeOptions options)
|
||||
{
|
||||
var duration = patch.Voices.Count == 0
|
||||
? options.MinRenderSeconds
|
||||
: patch.Voices.Max(voice => voice.Envelope.DurationSeconds + 0.08f);
|
||||
return Math.Clamp(duration, options.MinRenderSeconds, options.MaxRenderSeconds);
|
||||
}
|
||||
|
||||
private static string? WriteDspSource(string patchId, string source, string? outputRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
var path = Path.Combine(outputRoot, $"{SafeFileName(patchId)}.dsp");
|
||||
File.WriteAllText(path, source);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string SafeFileName(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
|
||||
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
|
||||
var name = new string(chars).Trim();
|
||||
return string.IsNullOrWhiteSpace(name) ? "aquasynth_patch" : name;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AquaSynthFaustToolchain
|
||||
{
|
||||
public static string? Find(string? preferredHome = null)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
preferredHome,
|
||||
Environment.GetEnvironmentVariable("AQUASYNTH_FAUST_HOME"),
|
||||
Environment.GetEnvironmentVariable("AQUARIUM_FAUST_HOME"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Tools", "Faust"),
|
||||
@"C:\Program Files\Faust"
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var root = Path.GetFullPath(candidate);
|
||||
if (File.Exists(Path.Combine(root, "lib", "faust.dll")) &&
|
||||
Directory.Exists(Path.Combine(root, "share", "faust")))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FaustNativeToolchain : IDisposable
|
||||
{
|
||||
public const int ErrorBufferBytes = 4096;
|
||||
|
||||
private readonly IntPtr library;
|
||||
private readonly DeleteFactoryFn deleteFactory;
|
||||
private readonly CreateInstanceFn createInstance;
|
||||
private readonly DeleteInstanceFn deleteInstance;
|
||||
private readonly InitInstanceFn initInstance;
|
||||
private readonly ComputeInstanceFn computeInstance;
|
||||
private readonly GetNumOutputsFn getNumOutputs;
|
||||
private readonly StartFactoriesFn startFactories;
|
||||
private readonly StopFactoriesFn stopFactories;
|
||||
private bool disposed;
|
||||
|
||||
private FaustNativeToolchain(IntPtr library, string home, string version, string sharePath)
|
||||
{
|
||||
this.library = library;
|
||||
Home = home;
|
||||
Version = version;
|
||||
SharePath = sharePath;
|
||||
CreateFactoryFromString = Export<CreateFactoryFromStringFn>("createCDSPFactoryFromString");
|
||||
deleteFactory = Export<DeleteFactoryFn>("deleteCDSPFactory");
|
||||
createInstance = Export<CreateInstanceFn>("createCDSPInstance");
|
||||
deleteInstance = Export<DeleteInstanceFn>("deleteCDSPInstance");
|
||||
initInstance = Export<InitInstanceFn>("initCDSPInstance");
|
||||
computeInstance = Export<ComputeInstanceFn>("computeCDSPInstance");
|
||||
getNumOutputs = Export<GetNumOutputsFn>("getNumOutputsCDSPInstance");
|
||||
startFactories = Export<StartFactoriesFn>("startMTDSPFactories");
|
||||
stopFactories = Export<StopFactoriesFn>("stopMTDSPFactories");
|
||||
_ = startFactories();
|
||||
}
|
||||
|
||||
public string Home { get; }
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public string SharePath { get; }
|
||||
|
||||
public CreateFactoryFromStringFn CreateFactoryFromString { get; }
|
||||
|
||||
public static bool TryLoad(string? preferredHome, out FaustNativeToolchain? toolchain, out string? error)
|
||||
{
|
||||
toolchain = null;
|
||||
error = null;
|
||||
var home = AquaSynthFaustToolchain.Find(preferredHome);
|
||||
if (home is null)
|
||||
{
|
||||
error = "Faust toolchain not found. Expected Tools\\Faust beside the app, AQUASYNTH_FAUST_HOME, AQUARIUM_FAUST_HOME, or C:\\Program Files\\Faust.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var dllPath = Path.Combine(home, "lib", "faust.dll");
|
||||
if (!File.Exists(dllPath))
|
||||
{
|
||||
error = $"Faust DLL not found at {dllPath}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var binPath = Path.Combine(home, "bin");
|
||||
var libPath = Path.Combine(home, "lib");
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
Environment.SetEnvironmentVariable("PATH", $"{libPath};{binPath};{path}");
|
||||
|
||||
try
|
||||
{
|
||||
var library = NativeLibrary.Load(dllPath);
|
||||
var getVersion = Marshal.GetDelegateForFunctionPointer<GetVersion>(NativeLibrary.GetExport(library, "getCLibFaustVersion"));
|
||||
var version = Marshal.PtrToStringUTF8(getVersion()) ?? "unknown";
|
||||
toolchain = new FaustNativeToolchain(library, home, version, Path.Combine(home, "share", "faust"));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IntPtr CreateInstance(IntPtr factory)
|
||||
{
|
||||
var dsp = createInstance(factory);
|
||||
if (dsp == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("failed to create Faust DSP instance");
|
||||
}
|
||||
|
||||
return dsp;
|
||||
}
|
||||
|
||||
public void DeleteFactory(IntPtr factory) => _ = deleteFactory(factory);
|
||||
|
||||
public void DeleteInstance(IntPtr dsp) => deleteInstance(dsp);
|
||||
|
||||
public void InitInstance(IntPtr dsp, int sampleRate) => initInstance(dsp, sampleRate);
|
||||
|
||||
public void Compute(IntPtr dsp, int count, IntPtr inputs, IntPtr outputs) => computeInstance(dsp, count, inputs, outputs);
|
||||
|
||||
public int GetNumOutputs(IntPtr dsp) => getNumOutputs(dsp);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stopFactories();
|
||||
NativeLibrary.Free(library);
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
private T Export<T>(string name)
|
||||
where T : Delegate
|
||||
{
|
||||
return Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(library, name));
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate IntPtr GetVersion();
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate IntPtr CreateFactoryFromStringFn(IntPtr name, IntPtr content, int argc, IntPtr argv, IntPtr target, IntPtr error, int optLevel);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate bool DeleteFactoryFn(IntPtr factory);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate IntPtr CreateInstanceFn(IntPtr factory);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void DeleteInstanceFn(IntPtr dsp);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void InitInstanceFn(IntPtr dsp, int sampleRate);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void ComputeInstanceFn(IntPtr dsp, int count, IntPtr inputs, IntPtr outputs);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int GetNumOutputsFn(IntPtr dsp);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate bool StartFactoriesFn();
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void StopFactoriesFn();
|
||||
}
|
||||
|
||||
internal sealed class NativeUtf8String : IDisposable
|
||||
{
|
||||
public NativeUtf8String(string value)
|
||||
{
|
||||
Pointer = Marshal.StringToCoTaskMemUTF8(value);
|
||||
}
|
||||
|
||||
public IntPtr Pointer { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Pointer != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(Pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NativeStringArray : IDisposable
|
||||
{
|
||||
private readonly NativeUtf8String[] strings;
|
||||
private readonly GCHandle handle;
|
||||
|
||||
public NativeStringArray(IReadOnlyList<string> values)
|
||||
{
|
||||
strings = values.Select(value => new NativeUtf8String(value)).ToArray();
|
||||
var pointers = strings.Select(value => value.Pointer).ToArray();
|
||||
Count = pointers.Length;
|
||||
handle = GCHandle.Alloc(pointers, GCHandleType.Pinned);
|
||||
Pointer = handle.AddrOfPinnedObject();
|
||||
}
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public IntPtr Pointer { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (handle.IsAllocated)
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
|
||||
foreach (var value in strings)
|
||||
{
|
||||
value.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,3 +95,4 @@
|
|||
{"date":"2026-05-16","topic":"zyn-pad-state-variable-filter","lesson":"Zyn PAD state-variable low-pass/high-pass filters are not a new Aqua authority. Category 2 type 0/1 maps cleanly onto existing `lpf`/`hpf` cutoff, Q, and order fields because Zyn `SVFilter` exposes low/high outputs and cascades `stages + 1`. Category 2 type 2/3 are band-pass/notch and must remain unknown until AquaSynth has a real band/notch filter owner.","evidence":"Mapped PAD filter `cat=2 type=0/1` in `ZynInstrumentReader`, moved those coverage buckets to handled, and added `RebuildMapsZynStateVariablePadLowPassToAquaFilterAuthority`. Focused ZynInstrumentTests passed 14/14."}
|
||||
{"date":"2026-05-16","topic":"native-bandpass-notch","lesson":"Band-pass and notch are ordinary AquaSynth filter authorities, not Zyn weirdness. `Filter` now owns `bpf`/`bpf_q`/`bpf_order` and `notch`/`notch_q`/`notch_order`; Zyn PAD analog and state-variable BPF/notch filters lower into those fields. Formants remain vowel/body color and PAD spectrum remains source-table construction.","evidence":"Added parser/model/Faust support for native BPF/notch, mapped Zyn PAD `cat=0 type=4/5` and `cat=2 type=2/3`, and added PatchScript plus ZynInstrument tests. Focused PatchScript/ZynInstrument tests passed 71/71."}
|
||||
{"date":"2026-05-17","topic":"faust-toolchain-boundary","lesson":"AquaSynth should own patch-to-DSP compilation products, including Faust toolchain selection, target options, cache identity, manifests, diagnostics, provenance, and dynamic patch compilation outside the realtime callback. Aquarium Engine should request or load finished artifacts and host them, not own the meaning of patch compilation.","evidence":"User confirmed Aquarium currently owns dynamic patch compilation but that this belongs in AquaSynth so Aquarium can plug into it. Added `docs/faust-toolchain-boundary.md`, reframed README C# output as authoring/test lane, and updated doctrine/state to split Aqua compile authority from Aquarium realtime hosting."}
|
||||
{"date":"2026-05-17","topic":"native-faust-runtime-ownership","lesson":"AquaSynth now owns Aquarium's former native Faust audio-rendering internals: toolchain discovery/loading, script parsing, Faust emission, compile identity, DSP source artifact writing, native Faust factory lifetime, manifests, duration estimation, and offline sample-buffer rendering. Engine hosts should schedule and play completed products instead of knowing synth internals.","evidence":"Added `AquaSynthNativeCompiler`, `AquaSynthCompiledPatch`, `AquaSynthNativeManifest`, and native Faust smoke tests. Aquarium's `AquariumSynthHost` was cut down to scheduling/debounce/status/cache/WASAPI playback and now consumes `AquaSynth.Dsl`."}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ boundaries:
|
|||
- script scoring
|
||||
- Faust source generation, target-code generation, compile-product contracts, and candidate rendering
|
||||
- dynamic patch compilation policy, cache identity, generated artifact manifests, and Faust toolchain selection
|
||||
- native Faust factory lifetime and offline sample-buffer rendering for engine hosts
|
||||
dev_only:
|
||||
- public-domain DX7 SysEx fixtures
|
||||
- pinned GPL ZynAddSubFX source for parity tests
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
"Faust toolchain selection and compile-product contracts",
|
||||
"dynamic patch compilation policy outside the realtime callback",
|
||||
"generated DSP artifact manifests for engine hosting",
|
||||
"native Faust factory lifetime and offline sample-buffer rendering for engine hosts",
|
||||
"patch script parser",
|
||||
"SFXR atoms and parameter mapping",
|
||||
"analysis/comparison metrics",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ live_invariants:
|
|||
- Notes own frequency, gate duration, and host/MIDI source; envelopes own ADSR level shape. Do not put note duration or SFXR punch back into the general envelope model.
|
||||
- Faust-managed MIDI polyphony is preferred: emit one voice graph with standard `freq`, `gain`, and `gate` controls plus `[midi:on][nvoices:n]` instead of inflating AquaSynth graphs for local allocation.
|
||||
- Generated Faust should remain inspectable and deterministic.
|
||||
- AquaSynth owns the Faust toolchain/product boundary: target selection, compile options, cache keys, generated artifact layout, parameter/bus manifests, diagnostics, provenance, and dynamic patch compilation outside the realtime callback. Aquarium Engine hosts finished artifacts; it should not own patch-to-DSP compilation semantics.
|
||||
- AquaSynth owns the Faust toolchain/product boundary: target selection, compile options, cache keys, generated artifact layout, parameter/bus manifests, diagnostics, provenance, native Faust factory lifetime, offline sample-buffer rendering, and dynamic patch compilation outside the realtime callback. Aquarium Engine hosts finished artifacts; it should not own patch-to-DSP compilation semantics.
|
||||
- AquaSynth Faust output may be rendered through Faust-generated C# for candidate audio comparison; reference parity still needs a real external render or captured licensed fixture.
|
||||
- Reference-patch correctness and synth parity tests live in AquaSynth, not AquaSynth.
|
||||
- Exposed patch parameters are runtime controls, not compile-time constants.
|
||||
|
|
|
|||
46
tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs
Normal file
46
tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using AquaSynth.Dsl;
|
||||
|
||||
namespace AquaSynth.Dsl.Tests;
|
||||
|
||||
public sealed class NativeFaustRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompileKeyTracksRevisionNameAndScript()
|
||||
{
|
||||
var first = new AquaSynthCompileIdentity("patch", "tone", "voice wave=sine freq=440 gain=.1", 1);
|
||||
var same = new AquaSynthCompileIdentity("patch", "tone", "voice wave=sine freq=440 gain=.1", 1);
|
||||
var changed = new AquaSynthCompileIdentity("patch", "tone", "voice wave=sine freq=441 gain=.1", 1);
|
||||
|
||||
Assert.Equal(first.CompileKey, same.CompileKey);
|
||||
Assert.NotEqual(first.CompileKey, changed.CompileKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeFaustRendererRendersAudiblePatchWhenToolchainIsAvailable()
|
||||
{
|
||||
const string script = """
|
||||
voice
|
||||
wave=sine
|
||||
freq=440
|
||||
gain=0.2
|
||||
attack=0.001
|
||||
sustain=0.06
|
||||
decay=0.12
|
||||
""";
|
||||
|
||||
if (!AquaSynthNativeCompiler.TryRenderScript("native_smoke", script, 1.0f, out var samples, 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 render failed: {error}");
|
||||
}
|
||||
|
||||
Assert.True(samples.Length > 2048, $"Rendered too few samples: {samples.Length}.");
|
||||
Assert.Contains(samples, sample => MathF.Abs(sample) > 0.001f);
|
||||
Assert.InRange(samples.Max(sample => MathF.Abs(sample)), 0.001f, 1.0f);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue