AquaSynth/src/AquaSynth.Faust/FaustCompiler.cs
2026-05-29 01:57:38 +01:00

302 lines
12 KiB
C#

using System.Diagnostics;
using System.Globalization;
using AquaSynth.Dsl;
namespace AquaSynth.Faust;
public enum FaustTargetLanguage
{
C,
Cpp,
CSharp,
Rust,
WebAssembly,
WebAssemblyText
}
public sealed record FaustCompileOptions(FaustTargetLanguage Language, string OutputPath);
public sealed record FaustValidation(string Command, bool Success, int? StatusCode, string Stdout, string Stderr);
public sealed record FaustWebAssemblyCompileOptions(
string OutputDirectory,
string BaseName = "aquasynth_patch",
bool Stereo = false);
public sealed record FaustWebAssemblyManifest(
string Name,
string DspPath,
string WasmPath,
string JsonPath,
string FaustPath,
bool Success,
int? StatusCode,
string Stdout,
string Stderr);
public sealed record FaustRenderOptions(int SampleRate = 44100, float DurationSeconds = 1);
public sealed record FaustRender(float[] Samples, int SampleRate, string Command, string Stdout, string Stderr);
public static class FaustCompiler
{
public static string? FindFaust()
{
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
var names = OperatingSystem.IsWindows() ? new[] { "faust.exe", "faust" } : ["faust"];
foreach (var dir in path.Split(Path.PathSeparator))
{
foreach (var name in names)
{
var candidate = Path.Combine(dir, name);
if (File.Exists(candidate)) return candidate;
}
}
var windowsDefault = @"C:\Program Files\Faust\bin\faust.exe";
return File.Exists(windowsDefault) ? windowsDefault : null;
}
public static async Task<FaustValidation?> CompileAsync(
string source,
FaustCompileOptions options,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
faustPath ??= FindFaust();
if (faustPath is null) return null;
var sourcePath = Path.Combine(Path.GetTempPath(), $"aquasynth-{Guid.NewGuid():N}.dsp");
await File.WriteAllTextAsync(sourcePath, source, cancellationToken);
try
{
var result = await RunAsync(
faustPath,
["-lang", Language(options.Language), "-o", options.OutputPath, sourcePath],
cancellationToken);
return new FaustValidation(faustPath, result.ExitCode == 0, result.ExitCode, result.Stdout, result.Stderr);
}
finally
{
File.Delete(sourcePath);
}
}
public static async Task<FaustWebAssemblyManifest?> CompileWebAssemblyAsync(
SynthPatch patch,
FaustWebAssemblyCompileOptions options,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
var export = FaustEmitter.Emit(patch, new FaustExportOptions(options.BaseName, options.Stereo));
return await CompileWebAssemblySourceAsync(export.Source, options, faustPath, cancellationToken);
}
public static async Task<FaustWebAssemblyManifest?> CompileWebAssemblyScriptAsync(
string script,
FaustWebAssemblyCompileOptions options,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
var export = FaustEmitter.EmitScript(script, new FaustExportOptions(options.BaseName, options.Stereo));
return await CompileWebAssemblySourceAsync(export.Source, options, faustPath, cancellationToken);
}
public static async Task<FaustWebAssemblyManifest?> CompileWebAssemblySourceAsync(
string source,
FaustWebAssemblyCompileOptions options,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
faustPath ??= FindFaust();
if (faustPath is null) return null;
Directory.CreateDirectory(options.OutputDirectory);
var baseName = SafeArtifactName(options.BaseName);
var dspPath = Path.Combine(options.OutputDirectory, $"{baseName}.dsp");
var wasmPath = Path.Combine(options.OutputDirectory, $"{baseName}.wasm");
var jsonPath = Path.Combine(options.OutputDirectory, $"{baseName}.json");
await File.WriteAllTextAsync(dspPath, source, cancellationToken);
var result = await RunAsync(
faustPath,
[
"-lang", Language(FaustTargetLanguage.WebAssembly),
"-json",
"-o", wasmPath,
dspPath
],
cancellationToken);
return new FaustWebAssemblyManifest(
options.BaseName,
dspPath,
wasmPath,
jsonPath,
faustPath,
result.ExitCode == 0 && File.Exists(wasmPath),
result.ExitCode,
result.Stdout,
result.Stderr);
}
public static async Task<FaustValidation?> ValidateAsync(
string source,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
var output = OperatingSystem.IsWindows() ? "NUL" : "/dev/null";
return await CompileAsync(source, new FaustCompileOptions(FaustTargetLanguage.Cpp, output), faustPath, cancellationToken);
}
public static async Task<FaustRender?> RenderAsync(
string source,
FaustRenderOptions? options = null,
string? faustPath = null,
CancellationToken cancellationToken = default)
{
options ??= new FaustRenderOptions();
faustPath ??= FindFaust();
if (faustPath is null) return null;
var frames = Math.Max(1, (int)MathF.Round(options.SampleRate * Math.Max(options.DurationSeconds, 1f / options.SampleRate)));
var tempDir = Path.Combine(Path.GetTempPath(), $"aquasynth-render-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var sourcePath = Path.Combine(tempDir, "render.dsp");
var generatedPath = Path.Combine(tempDir, "RenderDsp.cs");
var outputPath = Path.Combine(tempDir, "render.f32");
await File.WriteAllTextAsync(sourcePath, source, cancellationToken);
var faust = await RunAsync(
faustPath,
["-lang", "csharp", "-double", "-cn", "RenderDsp", "-o", generatedPath, sourcePath],
cancellationToken);
if (faust.ExitCode != 0)
{
return new FaustRender([], options.SampleRate, faustPath, faust.Stdout, faust.Stderr);
}
var archDir = await FaustArchDirAsync(faustPath, cancellationToken);
var basePath = archDir is null ? null : Path.Combine(archDir, "CSharpFaustBase.cs");
if (basePath is null || !File.Exists(basePath))
{
return new FaustRender([], options.SampleRate, faustPath, faust.Stdout, $"could not find CSharpFaustBase.cs under Faust archdir `{archDir}`");
}
File.Copy(basePath, Path.Combine(tempDir, "CSharpFaustBase.cs"));
await File.WriteAllTextAsync(Path.Combine(tempDir, "Render.csproj"), RenderProjectSource(), cancellationToken);
await File.WriteAllTextAsync(Path.Combine(tempDir, "Program.cs"), RenderProgramSource(), cancellationToken);
var dotnet = await RunAsync(
"dotnet",
["run", "--project", Path.Combine(tempDir, "Render.csproj"), "--", outputPath, options.SampleRate.ToString(CultureInfo.InvariantCulture), frames.ToString(CultureInfo.InvariantCulture)],
cancellationToken);
if (dotnet.ExitCode != 0 || !File.Exists(outputPath))
{
return new FaustRender([], options.SampleRate, "dotnet", dotnet.Stdout, dotnet.Stderr);
}
var bytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
var samples = new float[bytes.Length / sizeof(float)];
Buffer.BlockCopy(bytes, 0, samples, 0, samples.Length * sizeof(float));
return new FaustRender(samples, options.SampleRate, "dotnet", dotnet.Stdout, dotnet.Stderr);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static string Language(FaustTargetLanguage language) => language switch
{
FaustTargetLanguage.C => "c",
FaustTargetLanguage.Cpp => "cpp",
FaustTargetLanguage.CSharp => "csharp",
FaustTargetLanguage.Rust => "rust",
FaustTargetLanguage.WebAssembly => "wasm",
FaustTargetLanguage.WebAssemblyText => "wast",
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null)
};
private static string SafeArtifactName(string value)
{
var sanitized = new string(value.Select(character =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '_').ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "aquasynth_patch" : sanitized;
}
private static async Task<string?> FaustArchDirAsync(string faustPath, CancellationToken cancellationToken)
{
var result = await RunAsync(faustPath, ["-archdir"], cancellationToken);
return result.ExitCode == 0 ? result.Stdout.Trim() : null;
}
private static string RenderProjectSource() =>
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
""";
private static string RenderProgramSource() =>
"""
using System.Globalization;
var outputPath = args[0];
var sampleRate = int.Parse(args[1], CultureInfo.InvariantCulture);
var frames = int.Parse(args[2], CultureInfo.InvariantCulture);
var dsp = new RenderDsp();
dsp.Init(sampleRate);
var inputs = new double[dsp.GetNumInputs()][];
for (var i = 0; i < inputs.Length; i++) inputs[i] = new double[frames];
var outputs = new double[dsp.GetNumOutputs()][];
for (var i = 0; i < outputs.Length; i++) outputs[i] = new double[frames];
dsp.Compute(frames, inputs, outputs);
await using var stream = File.Create(outputPath);
await using var writer = new BinaryWriter(stream);
var output = outputs.Length == 0 ? Array.Empty<double>() : outputs[0];
for (var i = 0; i < frames; i++) writer.Write((float)output[i]);
""";
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken)
{
var start = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new InvalidOperationException($"failed to start `{fileName}`");
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException) when (!process.HasExited)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync(CancellationToken.None);
throw;
}
return (process.ExitCode, await stdout, await stderr);
}
}