Scaffold C# Faust DSL bridge
This commit is contained in:
commit
bc97a6d302
9 changed files with 944 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.idea/
|
||||
TestResults/
|
||||
target/
|
||||
*.user
|
||||
*.suo
|
||||
8
AquariumSynthCSharp.slnx
Normal file
8
AquariumSynthCSharp.slnx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
39
README.md
Normal file
39
README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Aquarium Synth CSharp
|
||||
|
||||
C# front-end for Aquarium Synth patch scripts. It parses the terse Aquarium DSL
|
||||
into a serializable patch graph, emits Faust `.dsp`, and can ask an installed
|
||||
Faust compiler to generate backend code such as C#, C++, C, or Rust.
|
||||
|
||||
This repo is the C# bridge for Aquarium/Vortice-land. Rust remains the reference
|
||||
lab; this project is the tool that lets the engine script Faust without dragging
|
||||
the Rust crate into the room by the ankle.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## Shape
|
||||
|
||||
- `PatchScript.Parse(script)` lowers terse script into `SynthPatch`.
|
||||
- `FaustEmitter.Emit(patch)` emits Faust source.
|
||||
- `FaustCompiler.ValidateAsync(source)` compile-checks with Faust when present.
|
||||
- `FaustCompiler.CompileAsync(source, options)` writes generated backend code.
|
||||
|
||||
Faust `2.85.5` supports `-lang csharp`, so the intended hot path is:
|
||||
|
||||
```csharp
|
||||
var patch = PatchScript.Parse("v w=saw f=55 g=.2 s=.5 d=.2");
|
||||
var export = FaustEmitter.Emit(patch, new FaustExportOptions("bass"));
|
||||
await FaustCompiler.CompileAsync(
|
||||
export.Source,
|
||||
new FaustCompileOptions(FaustTargetLanguage.CSharp, "Generated/Bass.cs"));
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
This is the first scaffolded slice, not the whole cathedral. It covers the graph
|
||||
surface needed by the current wobble/FM/formant scripts and validates generated
|
||||
Faust when the compiler is installed. Next job is tightening parity against the
|
||||
Rust crate and porting the remaining preset/macro conveniences.
|
||||
9
src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj
Normal file
9
src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
308
src/AquariumSynth.Dsl/FaustExport.cs
Normal file
308
src/AquariumSynth.Dsl/FaustExport.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace AquariumSynth.Dsl;
|
||||
|
||||
public sealed record FaustExportOptions(string Name = "aquarium_patch", bool Stereo = false);
|
||||
|
||||
public sealed record FaustExport(string Source, IReadOnlyList<string> Warnings);
|
||||
|
||||
public enum FaustTargetLanguage
|
||||
{
|
||||
C,
|
||||
Cpp,
|
||||
CSharp,
|
||||
Rust
|
||||
}
|
||||
|
||||
public sealed record FaustCompileOptions(FaustTargetLanguage Language, string OutputPath);
|
||||
|
||||
public sealed record FaustValidation(string Command, bool Success, int? StatusCode, string Stdout, string Stderr);
|
||||
|
||||
public static class FaustEmitter
|
||||
{
|
||||
public static FaustExport EmitScript(string script, FaustExportOptions? options = null) =>
|
||||
Emit(PatchScript.Parse(script), options ?? new FaustExportOptions());
|
||||
|
||||
public static FaustExport Emit(SynthPatch patch, FaustExportOptions? options = null)
|
||||
{
|
||||
options ??= new FaustExportOptions();
|
||||
if (patch.Voices.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch));
|
||||
|
||||
var warnings = new List<string>();
|
||||
var source = new StringBuilder();
|
||||
source.AppendLine("import(\"stdfaust.lib\");");
|
||||
source.AppendLine($"declare name \"{Escape(options.Name)}\";");
|
||||
source.AppendLine("declare options \"[nvoices:1]\";");
|
||||
source.AppendLine();
|
||||
source.AppendLine("time = ba.time / ma.SR;");
|
||||
if (patch.Repeat is { } repeat)
|
||||
{
|
||||
source.AppendLine($"age = time - floor(time / {F(repeat.IntervalSeconds)}) * {F(repeat.IntervalSeconds)};");
|
||||
}
|
||||
else
|
||||
{
|
||||
source.AppendLine("age = time;");
|
||||
}
|
||||
source.AppendLine("clip01(x) = min(1.0, max(0.0, x));");
|
||||
source.AppendLine("wrap01(x) = x - floor(x);");
|
||||
source.AppendLine("softclip(x) = ma.tanh(x * 1.35);");
|
||||
source.AppendLine("fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;");
|
||||
source.AppendLine("env(a,s,d,p) = select2(age < a, select2(age < a + s, select2(age < a + s + d, 0.0, 1.0 - (age - a - s) / max(0.0001, d)), 1.0 + (1.0 - (age - a) / max(0.0001, s)) * 2.0 * p), age / max(0.0001, a));");
|
||||
source.AppendLine("lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));");
|
||||
source.AppendLine("lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);");
|
||||
source.AppendLine("lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);");
|
||||
source.AppendLine("lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));");
|
||||
source.AppendLine();
|
||||
|
||||
foreach (var (target, name) in ModTargets)
|
||||
{
|
||||
source.AppendLine($"patch_mod_{name} = {ModExpressionForTarget(patch.Controls, target)};");
|
||||
}
|
||||
source.AppendLine();
|
||||
|
||||
var voices = new List<string>();
|
||||
for (var i = 0; i < patch.Voices.Count; i++)
|
||||
{
|
||||
var name = $"voice_{i}";
|
||||
EmitVoice(source, patch, patch.Voices[i], name, warnings);
|
||||
voices.Add(name);
|
||||
}
|
||||
|
||||
var final = $"({string.Join(" + ", voices)}) * {F(patch.Gain)}";
|
||||
if (patch.SoftClip) final = $"softclip({final})";
|
||||
source.AppendLine(options.Stereo ? $"process = {final} <: _,_;" : $"process = {final};");
|
||||
return new FaustExport(source.ToString(), warnings);
|
||||
}
|
||||
|
||||
private static void EmitVoice(StringBuilder source, SynthPatch patch, Voice voice, string name, List<string> warnings)
|
||||
{
|
||||
if (voice.Filter.LowPassResonance != 0)
|
||||
{
|
||||
warnings.Add($"{name}: low-pass resonance is approximated with Faust resonlp");
|
||||
}
|
||||
|
||||
var pitch = ModExpressionForTarget(voice.Modulators, ModTarget.Pitch);
|
||||
var duty = ModExpressionForTarget(voice.Modulators, ModTarget.Duty);
|
||||
var gain = ModExpressionForTarget(voice.Modulators, ModTarget.Gain);
|
||||
var noise = ModExpressionForTarget(voice.Modulators, ModTarget.Noise);
|
||||
var drive = ModExpressionForTarget(voice.Modulators, ModTarget.Drive);
|
||||
var fold = ModExpressionForTarget(voice.Modulators, ModTarget.Fold);
|
||||
var fmIndexMod = ModExpressionForTarget(voice.Modulators, ModTarget.FmIndex);
|
||||
var formant = ModExpressionForTarget(voice.Modulators, ModTarget.FormantMix);
|
||||
var lpfMod = ModExpressionForTarget(voice.Modulators, ModTarget.LowPass);
|
||||
var hpfMod = ModExpressionForTarget(voice.Modulators, ModTarget.HighPass);
|
||||
|
||||
var baseFreq = $"max({F(voice.Pitch.MinFrequencyHz)}, {F(voice.Oscillator.FrequencyHz)} * pow(2.0, {F(voice.Pitch.RampPerSecond)} * age + 0.5 * {F(voice.Pitch.DeltaRampPerSecond)} * age * age))";
|
||||
var vibrato = voice.Pitch.VibratoDepth != 0 && voice.Pitch.VibratoHz > 0
|
||||
? $" * (1.0 + select2(age < {F(voice.Pitch.VibratoDelaySeconds)}, 0.0, sin(2.0 * ma.PI * (age - {F(voice.Pitch.VibratoDelaySeconds)}) * {F(voice.Pitch.VibratoHz)}) * {F(voice.Pitch.VibratoDepth)}))"
|
||||
: "";
|
||||
var arpeggio = voice.Arpeggio is null
|
||||
? "1.0"
|
||||
: $"select2(age < {F(voice.Arpeggio.DelaySeconds)}, {F(voice.Arpeggio.Multiplier)}, 1.0)";
|
||||
var frequency = $"(({baseFreq}){vibrato}) * {arpeggio} * pow(2.0, patch_mod_pitch + {pitch})";
|
||||
var dutyExpression = $"clip01({F(voice.Oscillator.Duty)} + {F(voice.Duty.RampPerSecond)} * age + patch_mod_duty + {duty})";
|
||||
var fmIndex = $"max(0.0, {F(voice.Fm.Index)} + patch_mod_fm_index + {fmIndexMod}) * {FmDecay(voice.Fm.IndexDecaySeconds)}";
|
||||
var oscillator = OscillatorExpression(patch, voice, frequency, dutyExpression, fmIndex);
|
||||
var envelope = $"env({F(voice.Envelope.AttackSeconds)}, {F(voice.Envelope.SustainSeconds)}, {F(voice.Envelope.DecaySeconds)}, {F(voice.Envelope.Punch)})";
|
||||
var tremolo = voice.Color.TremoloDepth > 0 && voice.Color.TremoloHz > 0
|
||||
? $" * (1.0 - {F(Math.Clamp(voice.Color.TremoloDepth, 0, 1))} * (0.5 + 0.5 * lfo_sin({F(voice.Color.TremoloHz)}, 0.0)))"
|
||||
: "";
|
||||
var noiseMix = $"clip01({F(voice.Color.NoiseMix)} + patch_mod_noise + {noise})";
|
||||
var driveExpression = $"clip01({F(voice.Color.Drive)} + patch_mod_drive + {drive})";
|
||||
var foldExpression = $"clip01({F(voice.Color.Fold)} + patch_mod_fold + {fold})";
|
||||
var formantMix = $"clip01({F(voice.Color.FormantMix)} + patch_mod_formant_mix + {formant})";
|
||||
var lpf = $"clip01({F(voice.Filter.LowPass)} * (1.0 + {F(voice.Filter.LowPassRamp)} * age * 1.8) + patch_mod_lpf + {lpfMod})";
|
||||
var hpf = $"clip01({F(voice.Filter.HighPass)} * (1.0 + {F(voice.Filter.HighPassRamp)} * age * 2.0) + patch_mod_hpf + {hpfMod})";
|
||||
var lowpass = voice.Filter.LowPassResonance > 0
|
||||
? $"fi.resonlp(max(20.0, {lpf} * 18000.0), {F(0.7f + Math.Clamp(voice.Filter.LowPassResonance, 0, 1) * 18)}, 1.0)"
|
||||
: $"fi.lowpass(1, max(20.0, {lpf} * 18000.0))";
|
||||
|
||||
source.AppendLine($"{name}_freq = {frequency};");
|
||||
source.AppendLine($"{name}_osc = {oscillator};");
|
||||
source.AppendLine($"{name}_colored = ({name}_osc * (1.0 - {noiseMix}) + no.noise * {noiseMix});");
|
||||
source.AppendLine($"{name}_driven = ma.tanh({name}_colored * (1.0 + {driveExpression} * 12.0)) / ma.tanh(1.0 + {driveExpression} * 12.0);");
|
||||
source.AppendLine($"{name}_folded = {name}_driven * (1.0 - {foldExpression}) + fold({name}_driven * (1.0 + {foldExpression} * 3.5)) * {foldExpression};");
|
||||
source.AppendLine($"{name}_filtered = {name}_folded : {lowpass} : fi.highpass(1, max(20.0, {hpf} * 8000.0));");
|
||||
if (voice.Phaser.OffsetSeconds != 0 || voice.Phaser.RampSecondsPerSecond != 0)
|
||||
{
|
||||
var delay = $"min(2047.0, max(0.0, abs({F(voice.Phaser.OffsetSeconds)} + {F(voice.Phaser.RampSecondsPerSecond)} * age) * ma.SR))";
|
||||
source.AppendLine($"{name}_phased = {name}_filtered + ({name}_filtered : de.fdelay(2048, {delay}));");
|
||||
}
|
||||
else
|
||||
{
|
||||
source.AppendLine($"{name}_phased = {name}_filtered;");
|
||||
}
|
||||
source.AppendLine($"{name}_formants = {FormantExpression(name, voice)};");
|
||||
source.AppendLine($"{name} = (({name}_phased * (1.0 - {formantMix}) + {name}_formants * {formantMix}) * {envelope}{tremolo} * max(0.0, 1.0 + patch_mod_gain + {gain}) * {F(voice.Gain)});");
|
||||
source.AppendLine();
|
||||
}
|
||||
|
||||
private static string OscillatorExpression(SynthPatch patch, Voice voice, string frequency, string duty, string fmIndex)
|
||||
{
|
||||
var hasFmMod = patch.Controls.Any(control => control.Modulator.Target == ModTarget.FmIndex) ||
|
||||
voice.Modulators.Any(modulator => modulator.Target == ModTarget.FmIndex);
|
||||
var phaseMod = voice.Fm.Index > 0 || voice.Fm.IndexDecaySeconds > 0 || hasFmMod
|
||||
? $" + sin(2.0 * ma.PI * os.phasor(1.0, {frequency} * {F(Math.Max(voice.Fm.Ratio, 0))})) * ({fmIndex}) / ma.PI"
|
||||
: "";
|
||||
var phase = $"wrap01(os.phasor(1.0, {frequency}) + {F(voice.Oscillator.Phase)}{phaseMod})";
|
||||
return voice.Oscillator.Waveform switch
|
||||
{
|
||||
Waveform.Sine => $"sin(2.0 * ma.PI * {phase})",
|
||||
Waveform.Square => $"select2({phase} < {duty}, -0.5, 0.5)",
|
||||
Waveform.Sawtooth => $"2.0 * {phase} - 1.0",
|
||||
Waveform.Triangle => $"1.0 - 4.0 * abs({phase} - 0.5)",
|
||||
Waveform.Noise => "no.noise",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormantExpression(string name, Voice voice)
|
||||
{
|
||||
if (voice.Formants.Count == 0) return $"{name}_phased";
|
||||
var gainSum = Math.Max(voice.Formants.Sum(formant => Math.Abs(formant.Gain)), 0.001f);
|
||||
var parts = voice.Formants.Select(formant =>
|
||||
{
|
||||
var q = Math.Clamp(formant.FrequencyHz / Math.Max(formant.BandwidthHz, 10), 0.2f, 40);
|
||||
return $"({name}_phased : fi.resonbp({F(formant.FrequencyHz)}, {F(q)}, 1.0)) * {F(formant.Gain)}";
|
||||
});
|
||||
return $"({string.Join(" + ", parts)}) / {F(gainSum)}";
|
||||
}
|
||||
|
||||
private static string ModExpressionForTarget(IEnumerable<ControlLane> controls, ModTarget target)
|
||||
{
|
||||
var expressions = controls.Where(control => control.Modulator.Target == target)
|
||||
.Select(control => ModExpression(control.Modulator))
|
||||
.ToList();
|
||||
return expressions.Count == 0 ? "0.0" : string.Join(" + ", expressions);
|
||||
}
|
||||
|
||||
private static string ModExpressionForTarget(IEnumerable<Modulator> modulators, ModTarget target)
|
||||
{
|
||||
var expressions = modulators.Where(modulator => modulator.Target == target)
|
||||
.Select(ModExpression)
|
||||
.ToList();
|
||||
return expressions.Count == 0 ? "0.0" : string.Join(" + ", expressions);
|
||||
}
|
||||
|
||||
private static string ModExpression(Modulator modulator)
|
||||
{
|
||||
var wave = modulator.Waveform switch
|
||||
{
|
||||
ModWaveform.Sine => "lfo_sin",
|
||||
ModWaveform.Triangle => "lfo_tri",
|
||||
ModWaveform.Square => "lfo_sq",
|
||||
ModWaveform.SampleHold => "lfo_hold",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
return $"{F(modulator.Bias)} + {F(modulator.Depth)} * {wave}({F(modulator.FrequencyHz)}, {F(modulator.Phase)})";
|
||||
}
|
||||
|
||||
private static readonly (ModTarget Target, string Name)[] ModTargets =
|
||||
[
|
||||
(ModTarget.Gain, "gain"),
|
||||
(ModTarget.Pitch, "pitch"),
|
||||
(ModTarget.Duty, "duty"),
|
||||
(ModTarget.LowPass, "lpf"),
|
||||
(ModTarget.HighPass, "hpf"),
|
||||
(ModTarget.Noise, "noise"),
|
||||
(ModTarget.Drive, "drive"),
|
||||
(ModTarget.Fold, "fold"),
|
||||
(ModTarget.FormantMix, "formant_mix"),
|
||||
(ModTarget.FmIndex, "fm_index")
|
||||
];
|
||||
|
||||
private static string F(float value) =>
|
||||
float.IsFinite(value) ? value.ToString("0.########", CultureInfo.InvariantCulture) : "0.0";
|
||||
|
||||
private static string F(double value) =>
|
||||
double.IsFinite(value) ? value.ToString("0.########", CultureInfo.InvariantCulture) : "0.0";
|
||||
|
||||
private static string FmDecay(float seconds) => seconds > 0 ? $"exp(-age / {F(Math.Max(seconds, 0.0001f))})" : "1.0";
|
||||
|
||||
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
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(), $"aquarium-synth-{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<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);
|
||||
}
|
||||
|
||||
private static string Language(FaustTargetLanguage language) => language switch
|
||||
{
|
||||
FaustTargetLanguage.C => "c",
|
||||
FaustTargetLanguage.Cpp => "cpp",
|
||||
FaustTargetLanguage.CSharp => "csharp",
|
||||
FaustTargetLanguage.Rust => "rust",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null)
|
||||
};
|
||||
|
||||
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(cancellationToken);
|
||||
var stderr = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
return (process.ExitCode, await stdout, await stderr);
|
||||
}
|
||||
}
|
||||
117
src/AquariumSynth.Dsl/Model.cs
Normal file
117
src/AquariumSynth.Dsl/Model.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
namespace AquariumSynth.Dsl;
|
||||
|
||||
public enum Waveform
|
||||
{
|
||||
Sine,
|
||||
Square,
|
||||
Sawtooth,
|
||||
Triangle,
|
||||
Noise
|
||||
}
|
||||
|
||||
public enum ModWaveform
|
||||
{
|
||||
Sine,
|
||||
Triangle,
|
||||
Square,
|
||||
SampleHold
|
||||
}
|
||||
|
||||
public enum ModTarget
|
||||
{
|
||||
Gain,
|
||||
Pitch,
|
||||
Duty,
|
||||
LowPass,
|
||||
HighPass,
|
||||
Noise,
|
||||
Drive,
|
||||
Fold,
|
||||
FormantMix,
|
||||
FmIndex
|
||||
}
|
||||
|
||||
public sealed record Envelope(
|
||||
float AttackSeconds = 0,
|
||||
float SustainSeconds = 0.1f,
|
||||
float DecaySeconds = 0.1f,
|
||||
float Punch = 0)
|
||||
{
|
||||
public float DurationSeconds => AttackSeconds + SustainSeconds + DecaySeconds;
|
||||
}
|
||||
|
||||
public sealed record Oscillator(
|
||||
Waveform Waveform = Waveform.Sine,
|
||||
float FrequencyHz = 440,
|
||||
float Duty = 0.5f,
|
||||
float Phase = 0);
|
||||
|
||||
public sealed record PitchMotion(
|
||||
float MinFrequencyHz = 20,
|
||||
float RampPerSecond = 0,
|
||||
float DeltaRampPerSecond = 0,
|
||||
float VibratoDepth = 0,
|
||||
float VibratoHz = 0,
|
||||
float VibratoDelaySeconds = 0);
|
||||
|
||||
public sealed record DutyMotion(float RampPerSecond = 0);
|
||||
|
||||
public sealed record Filter(
|
||||
float LowPass = 1,
|
||||
float LowPassRamp = 0,
|
||||
float LowPassResonance = 0,
|
||||
float HighPass = 0,
|
||||
float HighPassRamp = 0);
|
||||
|
||||
public sealed record Phaser(float OffsetSeconds = 0, float RampSecondsPerSecond = 0);
|
||||
|
||||
public sealed record Arpeggio(float DelaySeconds, float Multiplier);
|
||||
|
||||
public sealed record FrequencyModulation(float Ratio = 1, float Index = 0, float IndexDecaySeconds = 0);
|
||||
|
||||
public sealed record VoiceColor(
|
||||
float NoiseMix = 0,
|
||||
float Drive = 0,
|
||||
float Fold = 0,
|
||||
float TremoloDepth = 0,
|
||||
float TremoloHz = 0,
|
||||
float FormantMix = 0);
|
||||
|
||||
public sealed record Formant(float FrequencyHz, float BandwidthHz, float Gain);
|
||||
|
||||
public sealed record Modulator(
|
||||
ModTarget Target,
|
||||
ModWaveform Waveform = ModWaveform.Sine,
|
||||
float FrequencyHz = 1,
|
||||
float Depth = 0,
|
||||
float Phase = 0,
|
||||
float Bias = 0);
|
||||
|
||||
public sealed record ControlLane(string Name, Modulator Modulator);
|
||||
|
||||
public sealed record Repeat(float IntervalSeconds);
|
||||
|
||||
public sealed record Voice
|
||||
{
|
||||
public Oscillator Oscillator { get; init; } = new();
|
||||
public Envelope Envelope { get; init; } = new();
|
||||
public PitchMotion Pitch { get; init; } = new();
|
||||
public DutyMotion Duty { get; init; } = new();
|
||||
public Filter Filter { get; init; } = new();
|
||||
public Phaser Phaser { get; init; } = new();
|
||||
public Arpeggio? Arpeggio { get; init; }
|
||||
public FrequencyModulation Fm { get; init; } = new();
|
||||
public VoiceColor Color { get; init; } = new();
|
||||
public IReadOnlyList<Formant> Formants { get; init; } = Array.Empty<Formant>();
|
||||
public IReadOnlyList<Modulator> Modulators { get; init; } = Array.Empty<Modulator>();
|
||||
public float Gain { get; init; } = 0.2f;
|
||||
}
|
||||
|
||||
public sealed record SynthPatch
|
||||
{
|
||||
public IReadOnlyList<Voice> Voices { get; init; } = Array.Empty<Voice>();
|
||||
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
|
||||
public Repeat? Repeat { get; init; }
|
||||
public float Gain { get; init; } = 1;
|
||||
public bool SoftClip { get; init; } = true;
|
||||
}
|
||||
355
src/AquariumSynth.Dsl/PatchScript.cs
Normal file
355
src/AquariumSynth.Dsl/PatchScript.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace AquariumSynth.Dsl;
|
||||
|
||||
public sealed class PatchScriptException(int line, string message) : Exception($"line {line}: {message}")
|
||||
{
|
||||
public int Line { get; } = line;
|
||||
}
|
||||
|
||||
public static class PatchScript
|
||||
{
|
||||
public static SynthPatch Parse(string script)
|
||||
{
|
||||
var compiler = new Compiler();
|
||||
var line = 1;
|
||||
foreach (var statement in Statements(script))
|
||||
{
|
||||
compiler.Apply(statement.Text, statement.Line);
|
||||
line = statement.Line;
|
||||
}
|
||||
|
||||
if (compiler.Voices.Count == 0)
|
||||
{
|
||||
throw new PatchScriptException(line, "patch script produced no voices");
|
||||
}
|
||||
|
||||
return compiler.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Text, int Line)> Statements(string script)
|
||||
{
|
||||
var lineNumber = 1;
|
||||
foreach (var rawLine in script.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var uncommented = rawLine.Split('#', 2)[0];
|
||||
foreach (var part in uncommented.Split(';'))
|
||||
{
|
||||
var statement = part.Trim();
|
||||
if (statement.Length > 0)
|
||||
{
|
||||
yield return (statement, lineNumber);
|
||||
}
|
||||
}
|
||||
lineNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Compiler
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _templates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<ControlLane> _controls = [];
|
||||
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public List<Voice> Voices { get; } = [];
|
||||
private Repeat? Repeat { get; set; }
|
||||
private float Gain { get; set; } = 1;
|
||||
private bool SoftClip { get; set; } = true;
|
||||
|
||||
public SynthPatch Build() => new()
|
||||
{
|
||||
Voices = Voices,
|
||||
Controls = _controls,
|
||||
Repeat = Repeat,
|
||||
Gain = Gain,
|
||||
SoftClip = SoftClip
|
||||
};
|
||||
|
||||
public void Apply(string statement, int line)
|
||||
{
|
||||
var parts = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
var command = CanonicalCommand(parts[0]);
|
||||
var fields = ParseFields(parts.Skip(1), line);
|
||||
switch (command)
|
||||
{
|
||||
case "patch":
|
||||
ApplyPatch(fields, line);
|
||||
break;
|
||||
case "defaults":
|
||||
Merge(_defaults, fields);
|
||||
break;
|
||||
case "template":
|
||||
var name = Required(fields, "name", line);
|
||||
_templates[name] = Without(fields, "name");
|
||||
break;
|
||||
case "voice":
|
||||
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), line));
|
||||
break;
|
||||
case "mod":
|
||||
AddModBus(fields, line);
|
||||
break;
|
||||
default:
|
||||
throw new PatchScriptException(line, $"unknown command `{parts[0]}`");
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
|
||||
{
|
||||
var expanded = new Dictionary<string, string>(_defaults, StringComparer.OrdinalIgnoreCase);
|
||||
if (TryGetAny(fields, ["use", "u"], out var templateName))
|
||||
{
|
||||
if (!_templates.TryGetValue(templateName, out var template))
|
||||
{
|
||||
throw new PatchScriptException(line, $"unknown template `{templateName}`");
|
||||
}
|
||||
Merge(expanded, template);
|
||||
}
|
||||
Merge(expanded, Without(fields, "use", "u"));
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private void ApplyPatch(IReadOnlyDictionary<string, string> fields, int line)
|
||||
{
|
||||
if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseFloat(gain, line);
|
||||
if (TryGetAny(fields, ["soft_clip", "clip"], out var softClip)) SoftClip = ParseBool(softClip, line);
|
||||
if (TryGetAny(fields, ["repeat", "r", "rp"], out var repeat))
|
||||
{
|
||||
var interval = ParseFloat(repeat, line);
|
||||
Repeat = interval > 0 ? new Repeat(interval) : null;
|
||||
}
|
||||
}
|
||||
|
||||
private Voice ParseVoice(IReadOnlyDictionary<string, string> fields, int line)
|
||||
{
|
||||
var waveform = TryGetAny(fields, ["wave", "w"], out var wave) ? ParseWaveform(wave, line) : Waveform.Sine;
|
||||
var formants = TryGetAny(fields, ["formants", "fs"], out var formantSpec)
|
||||
? ParseFormants(formantSpec, line)
|
||||
: [];
|
||||
|
||||
var modulators = TryGetAny(fields, ["mods"], out var mods)
|
||||
? ParseVoiceModulators(mods, line)
|
||||
: [];
|
||||
|
||||
Arpeggio? arpeggio = null;
|
||||
var hasArpDelay = TryGetAny(fields, ["arp_delay", "ad"], out var arpDelay);
|
||||
var hasArpMult = TryGetAny(fields, ["arp_mult", "am"], out var arpMult);
|
||||
if (hasArpDelay || hasArpMult)
|
||||
{
|
||||
arpeggio = new Arpeggio(
|
||||
ParseFloat(arpDelay ?? throw new PatchScriptException(line, "arpeggio needs arp_delay"), line),
|
||||
ParseFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line));
|
||||
}
|
||||
|
||||
return new Voice
|
||||
{
|
||||
Oscillator = new Oscillator(
|
||||
waveform,
|
||||
GetFloat(fields, line, 440, "freq", "frequency", "f"),
|
||||
GetFloat(fields, line, 0.5f, "duty", "du"),
|
||||
GetFloat(fields, line, 0, "phase")),
|
||||
Envelope = new Envelope(
|
||||
GetFloat(fields, line, 0, "attack", "a"),
|
||||
GetFloat(fields, line, 0.1f, "sustain", "s"),
|
||||
GetFloat(fields, line, 0.1f, "decay", "d"),
|
||||
GetFloat(fields, line, 0, "punch", "pu")),
|
||||
Pitch = new PitchMotion(
|
||||
GetFloat(fields, line, 20, "min_freq", "min"),
|
||||
GetFloat(fields, line, 0, "pitch_ramp", "pr"),
|
||||
GetFloat(fields, line, 0, "pitch_delta", "pd"),
|
||||
GetFloat(fields, line, 0, "vibrato", "vi"),
|
||||
GetFloat(fields, line, 0, "vibrato_hz", "vh"),
|
||||
GetFloat(fields, line, 0, "vibrato_delay", "vd")),
|
||||
Duty = new DutyMotion(GetFloat(fields, line, 0, "duty_ramp", "dur")),
|
||||
Filter = new Filter(
|
||||
GetFloat(fields, line, 1, "lpf", "l"),
|
||||
GetFloat(fields, line, 0, "lpf_ramp", "lr"),
|
||||
GetFloat(fields, line, 0, "resonance", "res"),
|
||||
GetFloat(fields, line, 0, "hpf", "h"),
|
||||
GetFloat(fields, line, 0, "hpf_ramp", "hr")),
|
||||
Phaser = new Phaser(
|
||||
GetFloat(fields, line, 0, "phaser", "ph"),
|
||||
GetFloat(fields, line, 0, "phaser_ramp", "phr")),
|
||||
Arpeggio = arpeggio,
|
||||
Fm = new FrequencyModulation(
|
||||
GetFloat(fields, line, 1, "fm", "fmr"),
|
||||
GetFloat(fields, line, 0, "fm_index", "fmi"),
|
||||
GetFloat(fields, line, 0, "fm_decay", "fmd")),
|
||||
Color = new VoiceColor(
|
||||
GetFloat(fields, line, 0, "noise", "nz"),
|
||||
GetFloat(fields, line, 0, "drive", "drv"),
|
||||
GetFloat(fields, line, 0, "fold", "fl"),
|
||||
GetFloat(fields, line, 0, "tremolo", "tr"),
|
||||
GetFloat(fields, line, 0, "tremolo_hz", "th"),
|
||||
GetFloat(fields, line, 0, "formant_mix", "fmix")),
|
||||
Formants = formants,
|
||||
Modulators = modulators,
|
||||
Gain = GetFloat(fields, line, 0.2f, "gain", "g")
|
||||
};
|
||||
}
|
||||
|
||||
private void AddModBus(IReadOnlyDictionary<string, string> fields, int line)
|
||||
{
|
||||
var name = GetAny(fields, ["name", "n"], "mod");
|
||||
var wave = TryGetAny(fields, ["wave", "w"], out var waveform)
|
||||
? ParseModWaveform(waveform, line)
|
||||
: ModWaveform.Sine;
|
||||
var hz = GetFloat(fields, line, 1, "hz", "rate");
|
||||
var phase = GetFloat(fields, line, 0, "phase");
|
||||
|
||||
foreach (var (key, target) in ModTargets)
|
||||
{
|
||||
if (!TryGetAny(fields, key, out var depthText)) continue;
|
||||
var depth = ParseFloat(depthText, line);
|
||||
_controls.Add(new ControlLane($"{name}_{key[0]}", new Modulator(target, wave, hz, depth, phase)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly (string[] Keys, ModTarget Target)[] ModTargets =
|
||||
[
|
||||
(["gain", "g"], ModTarget.Gain),
|
||||
(["pitch", "p"], ModTarget.Pitch),
|
||||
(["duty", "du"], ModTarget.Duty),
|
||||
(["lpf", "l"], ModTarget.LowPass),
|
||||
(["hpf", "h"], ModTarget.HighPass),
|
||||
(["noise", "nz"], ModTarget.Noise),
|
||||
(["drive", "drv"], ModTarget.Drive),
|
||||
(["fold", "fl"], ModTarget.Fold),
|
||||
(["formant_mix", "fmix"], ModTarget.FormantMix),
|
||||
(["fm_index", "fmi"], ModTarget.FmIndex)
|
||||
];
|
||||
|
||||
private static Dictionary<string, string> ParseFields(IEnumerable<string> tokens, int line)
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var split = token.Split('=', 2);
|
||||
if (split.Length != 2 || split[0].Length == 0)
|
||||
{
|
||||
throw new PatchScriptException(line, $"bad field `{token}`");
|
||||
}
|
||||
fields[CanonicalField(split[0])] = split[1];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static List<Formant> ParseFormants(string value, int line) =>
|
||||
value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part =>
|
||||
{
|
||||
var pieces = part.Split(':');
|
||||
if (pieces.Length != 3) throw new PatchScriptException(line, $"bad formant `{part}`");
|
||||
return new Formant(ParseFloat(pieces[0], line), ParseFloat(pieces[1], line), ParseFloat(pieces[2], line));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static List<Modulator> ParseVoiceModulators(string value, int line) =>
|
||||
value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part =>
|
||||
{
|
||||
var pieces = part.Split(':');
|
||||
if (pieces.Length != 4) throw new PatchScriptException(line, $"bad modulator `{part}`");
|
||||
return new Modulator(
|
||||
ParseModTarget(pieces[0], line),
|
||||
ParseModWaveform(pieces[1], line),
|
||||
ParseFloat(pieces[2], line),
|
||||
ParseFloat(pieces[3], line));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static string CanonicalCommand(string command) => command.ToLowerInvariant() switch
|
||||
{
|
||||
"p" or "patch" => "patch",
|
||||
"d" or "defaults" => "defaults",
|
||||
"def" or "t" or "template" => "template",
|
||||
"v" or "voice" => "voice",
|
||||
"mod" or "wob" or "wobble" or "bus" => "mod",
|
||||
_ => command
|
||||
};
|
||||
|
||||
private static string CanonicalField(string field) => field.ToLowerInvariant() switch
|
||||
{
|
||||
"n" => "name",
|
||||
"u" => "use",
|
||||
"w" => "wave",
|
||||
"f" => "freq",
|
||||
"g" => "gain",
|
||||
"a" => "attack",
|
||||
"s" => "sustain",
|
||||
"d" => "decay",
|
||||
_ => field
|
||||
};
|
||||
|
||||
private static Waveform ParseWaveform(string value, int line) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"sin" or "sine" => Waveform.Sine,
|
||||
"sq" or "square" => Waveform.Square,
|
||||
"saw" or "sawtooth" => Waveform.Sawtooth,
|
||||
"tri" or "triangle" => Waveform.Triangle,
|
||||
"n" or "noise" => Waveform.Noise,
|
||||
_ => throw new PatchScriptException(line, $"unknown waveform `{value}`")
|
||||
};
|
||||
|
||||
private static ModWaveform ParseModWaveform(string value, int line) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"sin" or "sine" => ModWaveform.Sine,
|
||||
"tri" or "triangle" => ModWaveform.Triangle,
|
||||
"sq" or "square" => ModWaveform.Square,
|
||||
"hold" or "sample_hold" => ModWaveform.SampleHold,
|
||||
_ => throw new PatchScriptException(line, $"unknown mod waveform `{value}`")
|
||||
};
|
||||
|
||||
private static ModTarget ParseModTarget(string value, int line)
|
||||
{
|
||||
foreach (var (keys, target) in ModTargets)
|
||||
{
|
||||
if (keys.Contains(value, StringComparer.OrdinalIgnoreCase)) return target;
|
||||
}
|
||||
if (value.Equals("formant", StringComparison.OrdinalIgnoreCase)) return ModTarget.FormantMix;
|
||||
throw new PatchScriptException(line, $"unknown mod target `{value}`");
|
||||
}
|
||||
|
||||
private static string Required(IReadOnlyDictionary<string, string> fields, string key, int line) =>
|
||||
fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`");
|
||||
|
||||
private static string GetAny(IReadOnlyDictionary<string, string> fields, string[] keys, string fallback) =>
|
||||
TryGetAny(fields, keys, out var value) ? value : fallback;
|
||||
|
||||
private static bool TryGetAny(IReadOnlyDictionary<string, string> fields, string[] keys, out string value)
|
||||
{
|
||||
foreach (var key in keys.Select(CanonicalField))
|
||||
{
|
||||
if (fields.TryGetValue(key, out value!)) return true;
|
||||
}
|
||||
value = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static float GetFloat(IReadOnlyDictionary<string, string> fields, int line, float fallback, params string[] keys) =>
|
||||
TryGetAny(fields, keys, out var value) ? ParseFloat(value, line) : fallback;
|
||||
|
||||
private static float ParseFloat(string value, int line) =>
|
||||
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: throw new PatchScriptException(line, $"bad number `{value}`");
|
||||
|
||||
private static bool ParseBool(string value, int line) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "yes" or "on" => true,
|
||||
"false" or "0" or "no" or "off" => false,
|
||||
_ => throw new PatchScriptException(line, $"bad bool `{value}`")
|
||||
};
|
||||
|
||||
private static void Merge(IDictionary<string, string> target, IReadOnlyDictionary<string, string> source)
|
||||
{
|
||||
foreach (var (key, value) in source) target[key] = value;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Without(IReadOnlyDictionary<string, string> fields, params string[] keys)
|
||||
{
|
||||
var excluded = keys.Select(CanonicalField).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return fields.Where(pair => !excluded.Contains(pair.Key))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
25
tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj
Normal file
25
tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AquariumSynth.Dsl\AquariumSynth.Dsl.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
75
tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs
Normal file
75
tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using AquariumSynth.Dsl;
|
||||
|
||||
namespace AquariumSynth.Dsl.Tests;
|
||||
|
||||
public sealed class PatchScriptTests
|
||||
{
|
||||
private const string WobbleTalker =
|
||||
"d w=saw f=55 g=.18 s=.8 d=.25 l=.34 h=.02 drv=.3 fl=.08 fm=2 fmi=.8 fmd=.7 fs=520:90:.7,1250:170:1,2600:320:.45 fmix=.35;" +
|
||||
"mod n=wob hz=4 w=tri g=.42 l=.48 fmix=.38 fmi=1.6 drv=.2 fl=.14;" +
|
||||
"v;" +
|
||||
"v f=110 g=.08 du=.42";
|
||||
|
||||
[Fact]
|
||||
public void ParserExpandsDefaultsModBusAndVoices()
|
||||
{
|
||||
var patch = PatchScript.Parse(WobbleTalker);
|
||||
|
||||
Assert.Equal(2, patch.Voices.Count);
|
||||
Assert.Equal(6, patch.Controls.Count);
|
||||
Assert.Equal(Waveform.Sawtooth, patch.Voices[0].Oscillator.Waveform);
|
||||
Assert.Equal(3, patch.Voices[0].Formants.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FaustEmitterProducesWobbleSource()
|
||||
{
|
||||
var export = FaustEmitter.EmitScript(WobbleTalker, new FaustExportOptions("wobble_talker", Stereo: true));
|
||||
|
||||
Assert.Contains("declare name \"wobble_talker\";", export.Source);
|
||||
Assert.Contains("patch_mod_fm_index", export.Source);
|
||||
Assert.Contains("fi.resonbp", export.Source);
|
||||
Assert.Contains("process =", export.Source);
|
||||
Assert.Contains("<: _,_;", export.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaustCompilerValidatesGeneratedSourceWhenInstalled()
|
||||
{
|
||||
var export = FaustEmitter.EmitScript(WobbleTalker);
|
||||
var validation = await FaustCompiler.ValidateAsync(export.Source);
|
||||
|
||||
if (validation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(validation.Success, validation.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaustCompilerCanEmitCSharpWhenInstalled()
|
||||
{
|
||||
var export = FaustEmitter.EmitScript("v w=sin f=440 g=.2 s=.1 d=.2");
|
||||
var output = Path.Combine(Path.GetTempPath(), $"aquarium-synth-{Guid.NewGuid():N}.cs");
|
||||
try
|
||||
{
|
||||
var validation = await FaustCompiler.CompileAsync(
|
||||
export.Source,
|
||||
new FaustCompileOptions(FaustTargetLanguage.CSharp, output));
|
||||
|
||||
if (validation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(validation.Success, validation.Stderr);
|
||||
Assert.True(File.Exists(output));
|
||||
Assert.Contains("class", await File.ReadAllTextAsync(output));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(output)) File.Delete(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue