Bind patch parameters at field sites

This commit is contained in:
Meta Vens 2026-05-13 00:38:23 +01:00
commit 9d902be606
8 changed files with 259 additions and 90 deletions

View file

@ -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<string>();
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<string> 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<string> warnings)
private static void EmitVoice(
StringBuilder source,
SynthPatch patch,
Voice voice,
int voiceIndex,
string name,
ParameterMap parameters,
List<string> 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<string, int> _parameterIndexes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _bindings = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _boundParameterPaths = new(StringComparer.OrdinalIgnoreCase);
public ParameterMap(SynthPatch patch, List<string> 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<string> UnboundParameterIds()
{
foreach (var (path, index) in _parameterIndexes)
{
if (!_boundParameterPaths.Contains(path))
{
yield return ParameterIdentifier(index);
}
}
}
}
}
public static class FaustCompiler

View file

@ -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<Voice> Voices { get; init; } = Array.Empty<Voice>();
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
public IReadOnlyList<PatchParameter> Parameters { get; init; } = Array.Empty<PatchParameter>();
public IReadOnlyList<ParameterBinding> ParameterBindings { get; init; } = Array.Empty<ParameterBinding>();
public Repeat? Repeat { get; init; }
public float Gain { get; init; } = 1;
public bool SoftClip { get; init; } = true;

View file

@ -32,6 +32,7 @@ public static class PatchScript
private readonly Dictionary<string, Dictionary<string, string>> _templates = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ControlLane> _controls = [];
private readonly List<PatchParameter> _parameters = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
public List<Voice> 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<string, string> 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<string, string> fields, int line)
private Voice ParseVoice(IReadOnlyDictionary<string, string> 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<string, string> 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();

View file

@ -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"}

View file

@ -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"
}
}

View file

@ -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.

View file

@ -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."

View file

@ -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<PatchScriptException>(() =>
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)