commit bc97a6d30275f1cba64c5b95a1aa1df2a099fd2d Author: Meta Vens Date: Fri May 8 03:00:53 2026 +0100 Scaffold C# Faust DSL bridge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d378094 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +obj/ +.vs/ +.idea/ +TestResults/ +target/ +*.user +*.suo diff --git a/AquariumSynthCSharp.slnx b/AquariumSynthCSharp.slnx new file mode 100644 index 0000000..d482968 --- /dev/null +++ b/AquariumSynthCSharp.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff6479b --- /dev/null +++ b/README.md @@ -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. diff --git a/src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj b/src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs new file mode 100644 index 0000000..7d22001 --- /dev/null +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -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 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(); + 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(); + 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 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 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 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 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 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 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); + } +} diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs new file mode 100644 index 0000000..f397866 --- /dev/null +++ b/src/AquariumSynth.Dsl/Model.cs @@ -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 Formants { get; init; } = Array.Empty(); + public IReadOnlyList Modulators { get; init; } = Array.Empty(); + public float Gain { get; init; } = 0.2f; +} + +public sealed record SynthPatch +{ + public IReadOnlyList Voices { get; init; } = Array.Empty(); + public IReadOnlyList Controls { get; init; } = Array.Empty(); + public Repeat? Repeat { get; init; } + public float Gain { get; init; } = 1; + public bool SoftClip { get; init; } = true; +} diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs new file mode 100644 index 0000000..dc5581a --- /dev/null +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -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> _templates = new(StringComparer.OrdinalIgnoreCase); + private readonly List _controls = []; + private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); + + public List 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 ExpandVoiceFields(Dictionary fields, int line) + { + var expanded = new Dictionary(_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 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 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 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 ParseFields(IEnumerable tokens, int line) + { + var fields = new Dictionary(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 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 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 fields, string key, int line) => + fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`"); + + private static string GetAny(IReadOnlyDictionary fields, string[] keys, string fallback) => + TryGetAny(fields, keys, out var value) ? value : fallback; + + private static bool TryGetAny(IReadOnlyDictionary 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 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 target, IReadOnlyDictionary source) + { + foreach (var (key, value) in source) target[key] = value; + } + + private static Dictionary Without(IReadOnlyDictionary 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); + } +} diff --git a/tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj b/tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj new file mode 100644 index 0000000..aa67ad9 --- /dev/null +++ b/tests/AquariumSynth.Dsl.Tests/AquariumSynth.Dsl.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs new file mode 100644 index 0000000..ebeb9d6 --- /dev/null +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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); + } + } +}