Scaffold C# Faust DSL bridge

This commit is contained in:
Meta Vens 2026-05-08 03:00:53 +01:00
commit bc97a6d302
9 changed files with 944 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
bin/
obj/
.vs/
.idea/
TestResults/
target/
*.user
*.suo

8
AquariumSynthCSharp.slnx Normal file
View 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
View 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.

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

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

View 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;
}

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

View 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>

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