diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs index d8ddbe2..c63e5c2 100644 --- a/src/AquariumSynth.Dsl/FaustExport.cs +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -31,6 +31,7 @@ public static class FaustEmitter if (patch.Voices.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch)); var warnings = new List(); + var parameters = new ParameterMap(patch, warnings); var source = new StringBuilder(); source.AppendLine("import(\"stdfaust.lib\");"); source.AppendLine($"declare name \"{Escape(options.Name)}\";"); @@ -39,7 +40,8 @@ public static class FaustEmitter source.AppendLine("time = ba.time / ma.SR;"); if (patch.Repeat is { } repeat) { - source.AppendLine($"age = time - floor(time / {F(repeat.IntervalSeconds)}) * {F(repeat.IntervalSeconds)};"); + var interval = parameters.Expression("/patch/repeat", repeat.IntervalSeconds); + source.AppendLine($"age = time - floor(time / {interval}) * {interval};"); } else { @@ -56,7 +58,7 @@ public static class FaustEmitter source.AppendLine("lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));"); source.AppendLine(); - EmitParameterControls(source, patch, warnings); + EmitParameterControls(source, patch, parameters); if (patch.Parameters.Count > 0) { source.AppendLine(); @@ -72,33 +74,40 @@ public static class FaustEmitter for (var i = 0; i < patch.Voices.Count; i++) { var name = $"voice_{i}"; - EmitVoice(source, patch, patch.Voices[i], name, warnings); + EmitVoice(source, patch, patch.Voices[i], i, name, parameters, warnings); voices.Add(name); } - var final = $"({string.Join(" + ", voices)}) * {F(patch.Gain)}"; + var final = $"({string.Join(" + ", voices)}) * {parameters.Expression("/patch/gain", patch.Gain)}"; if (patch.SoftClip) final = $"softclip({final})"; - if (patch.Parameters.Count > 0) + var unbound = parameters.UnboundParameterIds().ToList(); + if (unbound.Count > 0) { - final = $"({final}) + 0.0 * ({string.Join(" + ", patch.Parameters.Select((_, i) => ParameterIdentifier(i)))})"; + final = $"({final}) + 0.0 * ({string.Join(" + ", unbound)})"; } source.AppendLine(options.Stereo ? $"process = {final} <: _,_;" : $"process = {final};"); return new FaustExport(source.ToString(), warnings); } - private static void EmitParameterControls(StringBuilder source, SynthPatch patch, List warnings) + private static void EmitParameterControls(StringBuilder source, SynthPatch patch, ParameterMap parameters) { for (var i = 0; i < patch.Parameters.Count; i++) { var parameter = patch.Parameters[i]; - warnings.Add($"parameter {parameter.Path}: declared as a runtime control; target binding is not implemented yet"); source.AppendLine($"{ParameterIdentifier(i)} = hslider(\"{Escape(parameter.Path)}\", {F(parameter.Default)}, {F(parameter.Min)}, {F(parameter.Max)}, {F(parameter.Step)}) : si.smoo;"); } } - private static void EmitVoice(StringBuilder source, SynthPatch patch, Voice voice, string name, List warnings) + private static void EmitVoice( + StringBuilder source, + SynthPatch patch, + Voice voice, + int voiceIndex, + string name, + ParameterMap parameters, + List warnings) { - if (voice.Filter.LowPassResonance != 0) + if (voice.Filter.LowPassResonance != 0 || parameters.IsBound(VoiceField(voiceIndex, "filter/resonance"))) { warnings.Add($"{name}: low-pass resonance is approximated with Faust resonlp"); } @@ -114,29 +123,46 @@ public static class FaustEmitter 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 minFreq = parameters.Expression(VoiceField(voiceIndex, "pitch/min_freq"), voice.Pitch.MinFrequencyHz); + var oscFreq = parameters.Expression(VoiceField(voiceIndex, "osc/freq"), voice.Oscillator.FrequencyHz); + var pitchRamp = parameters.Expression(VoiceField(voiceIndex, "pitch/ramp"), voice.Pitch.RampPerSecond); + var pitchDelta = parameters.Expression(VoiceField(voiceIndex, "pitch/delta"), voice.Pitch.DeltaRampPerSecond); + var vibratoDepth = parameters.Expression(VoiceField(voiceIndex, "pitch/vibrato"), voice.Pitch.VibratoDepth); + var vibratoHz = parameters.Expression(VoiceField(voiceIndex, "pitch/vibrato_hz"), voice.Pitch.VibratoHz); + var vibratoDelay = parameters.Expression(VoiceField(voiceIndex, "pitch/vibrato_delay"), voice.Pitch.VibratoDelaySeconds); + var baseFreq = $"max({minFreq}, {oscFreq} * pow(2.0, {pitchRamp} * age + 0.5 * {pitchDelta} * age * age))"; + var hasVibrato = voice.Pitch.VibratoDepth != 0 && voice.Pitch.VibratoHz > 0 || + parameters.IsBound(VoiceField(voiceIndex, "pitch/vibrato")) || + parameters.IsBound(VoiceField(voiceIndex, "pitch/vibrato_hz")); + var vibrato = hasVibrato + ? $" * (1.0 + select2(age < {vibratoDelay}, 0.0, sin(2.0 * ma.PI * (age - {vibratoDelay}) * {vibratoHz}) * {vibratoDepth}))" : ""; var arpeggio = voice.Arpeggio is null ? "1.0" - : $"select2(age < {F(voice.Arpeggio.DelaySeconds)}, {F(voice.Arpeggio.Multiplier)}, 1.0)"; + : $"select2(age < {parameters.Expression(VoiceField(voiceIndex, "arpeggio/delay"), voice.Arpeggio.DelaySeconds)}, {parameters.Expression(VoiceField(voiceIndex, "arpeggio/multiplier"), 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 dutyExpression = $"clip01({parameters.Expression(VoiceField(voiceIndex, "osc/duty"), voice.Oscillator.Duty)} + {parameters.Expression(VoiceField(voiceIndex, "duty/ramp"), voice.Duty.RampPerSecond)} * age + patch_mod_duty + {duty})"; + var fmIndex = $"max(0.0, {parameters.Expression(VoiceField(voiceIndex, "fm/index"), voice.Fm.Index)} + patch_mod_fm_index + {fmIndexMod}) * {FmDecay(parameters.Expression(VoiceField(voiceIndex, "fm/decay"), voice.Fm.IndexDecaySeconds), voice.Fm.IndexDecaySeconds, parameters.IsBound(VoiceField(voiceIndex, "fm/decay")))}"; + var oscillator = OscillatorExpression(patch, voice, voiceIndex, frequency, dutyExpression, fmIndex, parameters); + var envelope = $"env({parameters.Expression(VoiceField(voiceIndex, "env/attack"), voice.Envelope.AttackSeconds)}, {parameters.Expression(VoiceField(voiceIndex, "env/sustain"), voice.Envelope.SustainSeconds)}, {parameters.Expression(VoiceField(voiceIndex, "env/decay"), voice.Envelope.DecaySeconds)}, {parameters.Expression(VoiceField(voiceIndex, "env/punch"), voice.Envelope.Punch)})"; + var tremoloDepth = parameters.Expression(VoiceField(voiceIndex, "color/tremolo"), Math.Clamp(voice.Color.TremoloDepth, 0, 1)); + var tremoloHz = parameters.Expression(VoiceField(voiceIndex, "color/tremolo_hz"), voice.Color.TremoloHz); + var hasTremolo = voice.Color.TremoloDepth > 0 && voice.Color.TremoloHz > 0 || + parameters.IsBound(VoiceField(voiceIndex, "color/tremolo")) || + parameters.IsBound(VoiceField(voiceIndex, "color/tremolo_hz")); + var tremolo = hasTremolo + ? $" * (1.0 - {tremoloDepth} * (0.5 + 0.5 * lfo_sin({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)" + var noiseMix = $"clip01({parameters.Expression(VoiceField(voiceIndex, "color/noise"), voice.Color.NoiseMix)} + patch_mod_noise + {noise})"; + var driveExpression = $"clip01({parameters.Expression(VoiceField(voiceIndex, "color/drive"), voice.Color.Drive)} + patch_mod_drive + {drive})"; + var foldExpression = $"clip01({parameters.Expression(VoiceField(voiceIndex, "color/fold"), voice.Color.Fold)} + patch_mod_fold + {fold})"; + var formantMix = $"clip01({parameters.Expression(VoiceField(voiceIndex, "color/formant_mix"), voice.Color.FormantMix)} + patch_mod_formant_mix + {formant})"; + var lpf = $"clip01({parameters.Expression(VoiceField(voiceIndex, "filter/lpf"), voice.Filter.LowPass)} * (1.0 + {parameters.Expression(VoiceField(voiceIndex, "filter/lpf_ramp"), voice.Filter.LowPassRamp)} * age * 1.8) + patch_mod_lpf + {lpfMod})"; + var hpf = $"clip01({parameters.Expression(VoiceField(voiceIndex, "filter/hpf"), voice.Filter.HighPass)} * (1.0 + {parameters.Expression(VoiceField(voiceIndex, "filter/hpf_ramp"), voice.Filter.HighPassRamp)} * age * 2.0) + patch_mod_hpf + {hpfMod})"; + var hasResonance = voice.Filter.LowPassResonance > 0 || parameters.IsBound(VoiceField(voiceIndex, "filter/resonance")); + var resonance = parameters.Expression(VoiceField(voiceIndex, "filter/resonance"), voice.Filter.LowPassResonance); + var lowpass = hasResonance + ? $"fi.resonlp(max(20.0, {lpf} * 18000.0), 0.7 + clip01({resonance}) * 18.0, 1.0)" : $"fi.lowpass(1, max(20.0, {lpf} * 18000.0))"; source.AppendLine($"{name}_freq = {frequency};"); @@ -145,9 +171,11 @@ public static class FaustEmitter 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(5.0, ({hpf}) * ({hpf}) * 7000.0));"); - if (voice.Phaser.OffsetSeconds != 0 || voice.Phaser.RampSecondsPerSecond != 0) + if (voice.Phaser.OffsetSeconds != 0 || voice.Phaser.RampSecondsPerSecond != 0 || + parameters.IsBound(VoiceField(voiceIndex, "phaser/offset")) || + parameters.IsBound(VoiceField(voiceIndex, "phaser/ramp"))) { - var delay = $"min(2047.0, max(0.0, abs({F(voice.Phaser.OffsetSeconds)} + {F(voice.Phaser.RampSecondsPerSecond)} * age) * ma.SR))"; + var delay = $"min(2047.0, max(0.0, abs({parameters.Expression(VoiceField(voiceIndex, "phaser/offset"), voice.Phaser.OffsetSeconds)} + {parameters.Expression(VoiceField(voiceIndex, "phaser/ramp"), voice.Phaser.RampSecondsPerSecond)} * age) * ma.SR))"; source.AppendLine($"{name}_phased = {name}_filtered + ({name}_filtered : de.fdelay(2048, {delay}));"); } else @@ -155,18 +183,20 @@ public static class FaustEmitter 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($"{name} = (({name}_phased * (1.0 - {formantMix}) + {name}_formants * {formantMix}) * {envelope}{tremolo} * max(0.0, 1.0 + patch_mod_gain + {gain}) * {parameters.Expression(VoiceField(voiceIndex, "gain"), voice.Gain)});"); source.AppendLine(); } - private static string OscillatorExpression(SynthPatch patch, Voice voice, string frequency, string duty, string fmIndex) + private static string OscillatorExpression(SynthPatch patch, Voice voice, int voiceIndex, string frequency, string duty, string fmIndex, ParameterMap parameters) { var hasFmMod = patch.Controls.Any(control => control.Modulator.Target == ModTarget.FmIndex) || - voice.Modulators.Any(modulator => modulator.Target == ModTarget.FmIndex); + voice.Modulators.Any(modulator => modulator.Target == ModTarget.FmIndex) || + parameters.IsBound(VoiceField(voiceIndex, "fm/index")) || + parameters.IsBound(VoiceField(voiceIndex, "fm/decay")); 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" + ? $" + sin(2.0 * ma.PI * os.phasor(1.0, {frequency} * max(0.0, {parameters.Expression(VoiceField(voiceIndex, "fm/ratio"), Math.Max(voice.Fm.Ratio, 0))}))) * ({fmIndex}) / ma.PI" : ""; - var phase = $"wrap01(os.phasor(1.0, {frequency}) + {F(voice.Oscillator.Phase)}{phaseMod})"; + var phase = $"wrap01(os.phasor(1.0, {frequency}) + {parameters.Expression(VoiceField(voiceIndex, "osc/phase"), voice.Oscillator.Phase)}{phaseMod})"; return voice.Oscillator.Waveform switch { Waveform.Sine => $"sin(2.0 * ma.PI * {phase})", @@ -241,9 +271,72 @@ public static class FaustEmitter private static string FmDecay(float seconds) => seconds > 0 ? $"exp(-age / {F(Math.Max(seconds, 0.0001f))})" : "1.0"; + private static string FmDecay(string seconds, float defaultSeconds, bool isBound) => + defaultSeconds > 0 || isBound ? $"exp(-age / max({seconds}, 0.0001))" : "1.0"; + private static string ParameterIdentifier(int index) => $"patch_param_{index}"; + private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}"; + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private sealed class ParameterMap + { + private readonly Dictionary _parameterIndexes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _bindings = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _boundParameterPaths = new(StringComparer.OrdinalIgnoreCase); + + public ParameterMap(SynthPatch patch, List warnings) + { + for (var i = 0; i < patch.Parameters.Count; i++) + { + _parameterIndexes[patch.Parameters[i].Path] = i; + } + + foreach (var binding in patch.ParameterBindings) + { + if (!_parameterIndexes.ContainsKey(binding.ParameterPath)) + { + warnings.Add($"parameter binding {binding.FieldPath}: unknown parameter `{binding.ParameterPath}`"); + continue; + } + + _bindings[binding.FieldPath] = binding.ParameterPath; + _boundParameterPaths.Add(binding.ParameterPath); + } + + foreach (var parameter in patch.Parameters) + { + if (!_boundParameterPaths.Contains(parameter.Path)) + { + warnings.Add($"parameter {parameter.Path}: declared but not bound to a patch field"); + } + } + } + + public bool IsBound(string fieldPath) => _bindings.ContainsKey(fieldPath); + + public string Expression(string fieldPath, float fallback) + { + if (!_bindings.TryGetValue(fieldPath, out var parameterPath)) + { + return F(fallback); + } + + return ParameterIdentifier(_parameterIndexes[parameterPath]); + } + + public IEnumerable UnboundParameterIds() + { + foreach (var (path, index) in _parameterIndexes) + { + if (!_boundParameterPaths.Contains(path)) + { + yield return ParameterIdentifier(index); + } + } + } + } } public static class FaustCompiler diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 2611732..5c6272e 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -100,6 +100,8 @@ public sealed record PatchParameter( string AutomationRate = "control", string Notes = ""); +public sealed record ParameterBinding(string FieldPath, string ParameterPath); + public sealed record ReferenceSource( string Kind, string Uri, @@ -141,6 +143,7 @@ public sealed record SynthPatch public IReadOnlyList Voices { get; init; } = Array.Empty(); public IReadOnlyList Controls { get; init; } = Array.Empty(); public IReadOnlyList Parameters { get; init; } = Array.Empty(); + public IReadOnlyList ParameterBindings { 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 index 1d09e00..aab8fde 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -32,6 +32,7 @@ public static class PatchScript private readonly Dictionary> _templates = new(StringComparer.OrdinalIgnoreCase); private readonly List _controls = []; private readonly List _parameters = []; + private readonly List _parameterBindings = []; private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); public List Voices { get; } = []; @@ -44,6 +45,7 @@ public static class PatchScript Voices = Voices, Controls = _controls, Parameters = _parameters, + ParameterBindings = _parameterBindings, Repeat = Repeat, Gain = Gain, SoftClip = SoftClip @@ -74,7 +76,7 @@ public static class PatchScript _templates[name] = Without(fields, "name"); break; case "voice": - Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), line)); + Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line)); break; case "mod": AddModBus(fields, line); @@ -113,16 +115,16 @@ public static class PatchScript private void ApplyPatch(IReadOnlyDictionary fields, int line) { - if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseFloat(gain, line); + if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseBoundFloat(gain, line, Gain, "/patch/gain"); 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); + var interval = ParseBoundFloat(repeat, line, 0.1f, "/patch/repeat"); Repeat = interval > 0 ? new Repeat(interval) : null; } } - private Voice ParseVoice(IReadOnlyDictionary fields, int line) + private Voice ParseVoice(IReadOnlyDictionary fields, int voiceIndex, 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) @@ -139,54 +141,54 @@ public static class PatchScript 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)); + ParseBoundFloat(arpDelay ?? throw new PatchScriptException(line, "arpeggio needs arp_delay"), line, 0, VoiceField(voiceIndex, "arpeggio/delay")), + ParseBoundFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line, 1, VoiceField(voiceIndex, "arpeggio/multiplier"))); } 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", "pa")), + GetBoundFloat(fields, line, 440, VoiceField(voiceIndex, "osc/freq"), "freq", "frequency", "f"), + GetBoundFloat(fields, line, 0.5f, VoiceField(voiceIndex, "osc/duty"), "duty", "du"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "osc/phase"), "phase", "pa")), 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")), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/attack"), "attack", "a"), + GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "env/sustain"), "sustain", "s"), + GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "env/decay"), "decay", "d"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/punch"), "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", "pitch_dramp", "pdr"), - 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")), + GetBoundFloat(fields, line, 20, VoiceField(voiceIndex, "pitch/min_freq"), "min_freq", "min"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/ramp"), "pitch_ramp", "pr"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/delta"), "pitch_delta", "pd", "pitch_dramp", "pdr"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato"), "vibrato", "vi"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato_hz"), "vibrato_hz", "vh"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato_delay"), "vibrato_delay", "vd")), + Duty = new DutyMotion(GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "duty/ramp"), "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")), + GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "filter/lpf"), "lpf", "l"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/lpf_ramp"), "lpf_ramp", "lr"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/resonance"), "resonance", "res"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/hpf"), "hpf", "h"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/hpf_ramp"), "hpf_ramp", "hr")), Phaser = new Phaser( - GetFloat(fields, line, 0, "phaser", "ph"), - GetFloat(fields, line, 0, "phaser_ramp", "phr")), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/offset"), "phaser", "ph"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/ramp"), "phaser_ramp", "phr")), Arpeggio = arpeggio, Fm = new FrequencyModulation( - GetFloat(fields, line, 1, "fm", "fmr", "fm_ratio"), - GetFloat(fields, line, 0, "fm_index", "fmi"), - GetFloat(fields, line, 0, "fm_decay", "fmd")), + GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "fm/ratio"), "fm", "fmr", "fm_ratio"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "fm/index"), "fm_index", "fmi"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "fm/decay"), "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")), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/noise"), "noise", "nz"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/drive"), "drive", "drv"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/fold"), "fold", "fl"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/tremolo"), "tremolo", "tr"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/tremolo_hz"), "tremolo_hz", "th"), + GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/formant_mix"), "formant_mix", "fmix")), Formants = formants, Modulators = modulators, - Gain = GetFloat(fields, line, 0.2f, "gain", "g") + Gain = GetBoundFloat(fields, line, 0.2f, VoiceField(voiceIndex, "gain"), "gain", "g") }; } @@ -285,6 +287,42 @@ public static class PatchScript GetAny(fields, ["notes", "note"], ""))); } + private float GetBoundFloat( + IReadOnlyDictionary fields, + int line, + float fallback, + string fieldPath, + params string[] keys) => + TryGetAny(fields, keys, out var value) ? ParseBoundFloat(value, line, fallback, fieldPath) : fallback; + + private float ParseBoundFloat(string value, int line, float fallback, string fieldPath) + { + if (!value.StartsWith('@')) + { + return ParseFloat(value, line); + } + + var parameterPath = value[1..]; + if (!parameterPath.StartsWith('/')) + { + throw new PatchScriptException(line, "parameter reference must use `@/path`"); + } + var parameter = _parameters.FirstOrDefault(candidate => candidate.Path.Equals(parameterPath, StringComparison.OrdinalIgnoreCase)); + if (parameter is null) + { + throw new PatchScriptException(line, $"unknown parameter `{parameterPath}`"); + } + if (_parameterBindings.Any(binding => binding.FieldPath.Equals(fieldPath, StringComparison.OrdinalIgnoreCase))) + { + throw new PatchScriptException(line, $"duplicate parameter binding for `{fieldPath}`"); + } + + _parameterBindings.Add(new ParameterBinding(fieldPath, parameter.Path)); + return parameter.Default; + } + + private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}"; + private void AddSfxrPatch(SfxrParams parameters) { var mapped = parameters.ToPatch(); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 0aa3447..61648db 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -9,3 +9,4 @@ {"kind":"synth_test_ownership","lesson":"AquariumSynthCSharp owns reference-patch correctness. Aquarium should only smoke-test the engine synth host and bundled Faust boundary; SFXR/BFXR/reference parity belongs here so the engine can keep moving while the synth library is under active surgery.","source":"user questioned synth tests living in Aquarium"} {"kind":"package_boundary","lesson":"Aquarium consumes AquariumSynth.Dsl through a pinned package, not a live project reference. Breaking changes in AquariumSynthCSharp are allowed during synth construction as long as a new package version is packed and Aquarium is updated deliberately when the boundary should move.","source":"user wanted Aquarium builds insulated from synth-agent work"} {"kind":"runtime_patch_parameters","lesson":"Aquarium patch scripts now have first-class `param` declarations and patch/reference model contracts. Faust emission exposes declared parameters as smoothed hslider controls, but target binding is intentionally still absent and warned about; do not claim parameters shape sound until binding is designed.","source":"parameter contract implementation on 2026-05-13"} +{"kind":"runtime_patch_parameters","lesson":"Aquarium parameter binding uses Option B: numeric field values may reference declared parameters with `@/path`, producing exact field bindings such as `/voices/0/filter/lpf`. The field owns the meaning; the parameter owns the runtime value. Avoid target-routing parameter semantics unless a new authority is deliberately mapped.","source":"user rejected target binding as a landmine; field-site binding implemented on 2026-05-13"} diff --git a/state/memory.json b/state/memory.json index af3a57a..46b484d 100644 --- a/state/memory.json +++ b/state/memory.json @@ -46,11 +46,12 @@ "runtime_parameters": { "rule": "Patch parameters are runtime controls, not compile-time constants.", "faust_surface": "Faust UI controls expose parameter zones that hosts can update with setParamValue/getParamValue-style APIs.", - "aquarium_requirement": "Aquarium DSL patches must be able to declare stable exposed parameters with path/default/min/max/step metadata so callers can vary sound without recompiling." + "aquarium_requirement": "Aquarium DSL patches declare stable exposed parameters with path/default/min/max/step metadata so callers can vary sound without recompiling.", + "binding_rule": "Parameter binding uses field-site references such as `lpf=@/macro/brightness`; the field owns meaning and the parameter owns the runtime value. Avoid target-routing semantics unless a new graph authority is deliberately mapped." }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "20 passed", - "commit": "pending parameter contract commit" + "result": "23 passed", + "commit": "pending field-site parameter binding commit" } } diff --git a/state/scratch.md b/state/scratch.md index c1ff5fe..cf39295 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -13,15 +13,17 @@ Completed this slice: contracts with provenance/license/hash-ready fields. - Added `param` script declarations and duplicate parameter path validation. - Updated Faust emission so declared parameters produce smoothed `hslider` - controls. Target binding is not implemented yet and emits a warning; do not - pretend the parameter value shapes sound until binding exists. -- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 20 passed. + controls. +- Implemented Option B parameter binding: numeric fields may reference declared + parameters with `@/path`, for example `lpf=@/macro/brightness`. The parser + records an exact field binding such as `/voices/0/filter/lpf` and keeps the + parameter default as the graph value. +- Faust emission substitutes the parameter expression only at the bound field + site. Unbound parameters still emit a warning. +- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 23 passed. Next likely slice: -- Decide whether `param` should bind to explicit semantic targets such as - `target=lpf` or whether fields should accept parameter references such as - `lpf=@/macro/brightness`. Do not add both without a clear ownership model. - Implement a DX7 SysEx parser skeleton for single voices and cartridge payloads. - Extract operator topology, envelopes, ratios, feedback, levels, LFO, and pitch envelope into feature records before trying to translate. diff --git a/state/spine.yaml b/state/spine.yaml index f188e4c..85f2fa5 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-13T00:27:06+01:00 +updated: 2026-05-13T00:37:07+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -28,6 +28,7 @@ live_invariants: - Generated Faust should remain inspectable and deterministic. - Reference-patch correctness and synth parity tests live in AquariumSynthCSharp, not Aquarium. - Exposed patch parameters are runtime controls, not compile-time constants. + - Parameter binding uses field-site references like `lpf=@/macro/brightness`; do not add target-routing semantics unless a later map proves a new authority is needed. - Recompile only for graph shape changes; vary declared parameters through the hosted DSP control API. - Reference fixtures must preserve provenance, license status, source hash, and feature pressure. - Audio metrics are evidence, not final judgment. @@ -76,9 +77,8 @@ reference_targets: next_agent_start_here: recommended_first_task: > - Decide whether parameter declarations need explicit target binding before - DX7 work, then add a DX7 SysEx parser skeleton with feature extraction - tests. Do not start by importing a giant bank. + Add a DX7 SysEx parser skeleton with feature extraction tests. Do not start + by importing a giant bank. must_read: - AGENTS.md - docs/audio-doctrine.md @@ -94,9 +94,10 @@ completed_slices: summary: > Added PatchParameter and ReferencePatch contracts, `param` script parsing, duplicate path validation, and Faust hslider emission for declared - controls. Parameter target binding is intentionally not implemented yet; - the emitter warns so the gap stays visible. - verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 20 passed" + controls. Parameter binding uses field-site references such as + `lpf=@/macro/brightness`; the emitter substitutes the parameter only at + the exact bound field site and warns for unbound parameters. + verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 23 passed" cut_line: - "Cut any importer that cannot state source provenance and license." diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index f158161..e95ef36 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -90,6 +90,26 @@ public sealed class PatchScriptTests Assert.Contains("duplicate parameter path", exception.Message); } + [Fact] + public void ParserBindsParameterReferencesAtFieldSites() + { + var patch = PatchScript.Parse("param path=/macro/brightness default=.45 min=0 max=1 step=.001;v w=saw f=80 lpf=@/macro/brightness"); + + var binding = Assert.Single(patch.ParameterBindings); + Assert.Equal("/voices/0/filter/lpf", binding.FieldPath); + Assert.Equal("/macro/brightness", binding.ParameterPath); + Assert.Equal(.45f, patch.Voices[0].Filter.LowPass, 5); + } + + [Fact] + public void ParserRejectsUnknownParameterReferences() + { + var exception = Assert.Throws(() => + PatchScript.Parse("v w=saw f=80 lpf=@/macro/brightness")); + + Assert.Contains("unknown parameter `/macro/brightness`", exception.Message); + } + [Fact] public void BuiltInExampleParsesAndExportsFaust() { @@ -208,7 +228,17 @@ public sealed class PatchScriptTests Assert.Contains("patch_param_0 = hslider(\"/macro/brightness\", 0.45, 0, 1, 0.001) : si.smoo;", export.Source); Assert.Contains("patch_param_0", export.Source); - Assert.Contains("target binding is not implemented yet", Assert.Single(export.Warnings)); + Assert.Contains("declared but not bound", Assert.Single(export.Warnings)); + } + + [Fact] + public void FaustEmitterUsesParameterReferencesAtBoundFieldSites() + { + var export = FaustEmitter.EmitScript("param path=/macro/brightness default=.45 min=0 max=1 step=.001;v w=saw f=80 lpf=@/macro/brightness"); + + Assert.Contains("patch_param_0 = hslider(\"/macro/brightness\", 0.45, 0, 1, 0.001) : si.smoo;", export.Source); + Assert.Contains("clip01(patch_param_0 * (1.0 + 0 * age * 1.8) + patch_mod_lpf + 0.0)", export.Source); + Assert.Empty(export.Warnings); } [Fact] @@ -228,7 +258,7 @@ public sealed class PatchScriptTests [Fact] public async Task FaustCompilerValidatesParameterizedPatchWhenInstalled() { - var export = FaustEmitter.EmitScript("param path=/macro/brightness default=.45 min=0 max=1 step=.001;v w=saw f=80"); + var export = FaustEmitter.EmitScript("param path=/macro/brightness default=.45 min=0 max=1 step=.001;v w=saw f=80 lpf=@/macro/brightness"); var validation = await FaustCompiler.ValidateAsync(export.Source); if (validation is null)