Bind patch parameters at field sites
This commit is contained in:
parent
8827a232a8
commit
9d902be606
8 changed files with 259 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue