Emit PAD spectra as Faust wavetables

This commit is contained in:
Meta Vens 2026-05-14 21:11:20 +01:00
commit 1cb6b2852f
11 changed files with 220 additions and 157 deletions

View file

@ -162,16 +162,17 @@ voice path. It is enough to express layered PAD/vocal onset and release shapes,
but it is not yet a Zyn free-envelope point editor.
The fourth response is `spectrum`: PAD layers can declare a source-level partial
cloud that lowers to detuned voice pairs:
cloud that emits as a Faust wavetable source before normal voice treatment:
```text
layer name=pad_low engine=pad
spectrum layer=pad_low root=130.8128 spread=.012 partials=1:.07,1.5:.052
```
This gives PAD texture targets a reusable source authority without claiming
exact PADsynth FFT/wavetable generation. `root`, `spread`, and `partials` are
authoring-time graph shape, not runtime controls.
This gives PAD texture targets a reusable source authority without inflating the
voice graph. It is still not a claim of exact PADsynth FFT/bandwidth parity:
`root`, `spread`, and `partials` are authoring-time table shape, not runtime
controls.
Sources:

View file

@ -106,7 +106,7 @@ layer name=pad_low engine=pad
spectrum layer=pad_low root=130.8128 spread=.012 partials=1:.07,1.5:.052
```
The command records PAD-like partial intent under the layer and lowers it to
detuned voice pairs. This is not a PADsynth wavetable generator. It is the
smallest honest source authority needed before exact PAD synthesis earns its
own machinery.
The command records PAD-like partial intent under the layer and emits a static
Faust wavetable source before normal voice treatment. This is not exact
PADsynth bandwidth/FFT generation. It is the smallest honest source authority
needed before exact PAD synthesis earns its own machinery.

View file

@ -32,7 +32,7 @@ public static class FaustEmitter
public static FaustExport Emit(SynthPatch patch, FaustExportOptions? options = null)
{
options ??= new FaustExportOptions();
if (patch.Voices.Count == 0 && patch.OperatorGraphs.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch));
if (patch.Voices.Count == 0 && patch.SpectralBanks.Count == 0 && patch.OperatorGraphs.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch));
var warnings = new List<string>();
var parameters = new ParameterMap(patch, warnings);
@ -91,7 +91,13 @@ public static class FaustEmitter
for (var i = 0; i < patch.Voices.Count; i++)
{
var name = $"voice_{i}";
EmitVoice(source, patch, patch.Voices[i], i, name, parameters, warnings);
EmitVoice(source, patch, patch.Voices[i], VoicePath(i), name, parameters, warnings);
voices.Add(name);
}
for (var i = 0; i < patch.SpectralBanks.Count; i++)
{
var name = $"spectral_{i}";
EmitSpectralBank(source, patch, patch.SpectralBanks[i], i, name, parameters, warnings);
voices.Add(name);
}
for (var i = 0; i < patch.OperatorGraphs.Count; i++)
@ -144,12 +150,13 @@ public static class FaustEmitter
StringBuilder source,
SynthPatch patch,
Voice voice,
int voiceIndex,
string ownerPath,
string name,
ParameterMap parameters,
List<string> warnings)
List<string> warnings,
string? oscillatorOverride = null)
{
if (voice.Filter.LowPassResonance != 0 || parameters.IsBound(VoiceField(voiceIndex, "filter/resonance")))
if (voice.Filter.LowPassResonance != 0 || parameters.IsBound(OwnerField(ownerPath, "filter/resonance")))
{
warnings.Add($"{name}: low-pass resonance is approximated with Faust resonlp");
}
@ -165,35 +172,35 @@ public static class FaustEmitter
var lpfMod = ModExpressionForTarget(voice.Modulators, ModTarget.LowPass);
var hpfMod = ModExpressionForTarget(voice.Modulators, ModTarget.HighPass);
var minFreq = parameters.Expression(VoiceField(voiceIndex, "pitch/min_freq"), voice.Pitch.MinFrequencyHz);
var noteFreq = NoteFrequencyExpression(source, patch.Playback, voice.Note, name, VoiceField(voiceIndex, "note/frequency"), parameters);
var noteGate = NoteGateExpression(source, patch.Playback, voice.Note, name, VoiceField(voiceIndex, "note/gate"), parameters);
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 minFreq = parameters.Expression(OwnerField(ownerPath, "pitch/min_freq"), voice.Pitch.MinFrequencyHz);
var noteFreq = NoteFrequencyExpression(source, patch.Playback, voice.Note, name, OwnerField(ownerPath, "note/frequency"), parameters);
var noteGate = NoteGateExpression(source, patch.Playback, voice.Note, name, OwnerField(ownerPath, "note/gate"), parameters);
var pitchRamp = parameters.Expression(OwnerField(ownerPath, "pitch/ramp"), voice.Pitch.RampPerSecond);
var pitchDelta = parameters.Expression(OwnerField(ownerPath, "pitch/delta"), voice.Pitch.DeltaRampPerSecond);
var vibratoDepth = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato"), voice.Pitch.VibratoDepth);
var vibratoHz = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato_hz"), voice.Pitch.VibratoHz);
var vibratoDelay = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato_delay"), voice.Pitch.VibratoDelaySeconds);
var baseFreq = $"max({minFreq}, {noteFreq} * 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"));
parameters.IsBound(OwnerField(ownerPath, "pitch/vibrato")) ||
parameters.IsBound(OwnerField(ownerPath, "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 < {parameters.Expression(VoiceField(voiceIndex, "arpeggio/delay"), voice.Arpeggio.DelaySeconds)}, {parameters.Expression(VoiceField(voiceIndex, "arpeggio/multiplier"), voice.Arpeggio.Multiplier)}, 1.0)";
: $"select2(age < {parameters.Expression(OwnerField(ownerPath, "arpeggio/delay"), voice.Arpeggio.DelaySeconds)}, {parameters.Expression(OwnerField(ownerPath, "arpeggio/multiplier"), voice.Arpeggio.Multiplier)}, 1.0)";
var frequency = $"(({baseFreq}){vibrato}) * {arpeggio} * pow(2.0, patch_mod_pitch + {pitch})";
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 dutyExpression = $"clip01({parameters.Expression(OwnerField(ownerPath, "osc/duty"), voice.Oscillator.Duty)} + {parameters.Expression(OwnerField(ownerPath, "duty/ramp"), voice.Duty.RampPerSecond)} * age + patch_mod_duty + {duty})";
var fmIndex = $"max(0.0, {parameters.Expression(OwnerField(ownerPath, "fm/index"), voice.Fm.Index)} + patch_mod_fm_index + {fmIndexMod}) * {FmDecay(parameters.Expression(OwnerField(ownerPath, "fm/decay"), voice.Fm.IndexDecaySeconds), voice.Fm.IndexDecaySeconds, parameters.IsBound(OwnerField(ownerPath, "fm/decay")))}";
var oscillator = oscillatorOverride ?? OscillatorExpression(patch, voice, ownerPath, frequency, dutyExpression, fmIndex, parameters);
var envelope = voice.RateLevelEnvelope is not null
? RateLevelEnvelopeExpression(voice.RateLevelEnvelope, noteGate)
: EnvelopeExpression(
voice.Envelope,
noteGate,
UsesHostPlayback(patch.Playback) || voice.Note.Source == NoteSource.Host,
field => parameters.Expression(VoiceField(voiceIndex, field), field switch
field => parameters.Expression(OwnerField(ownerPath, field), field switch
{
"env/attack" => voice.Envelope.AttackSeconds,
"env/decay" => voice.Envelope.DecaySeconds,
@ -201,22 +208,22 @@ public static class FaustEmitter
"env/release" => voice.Envelope.ReleaseSeconds,
_ => throw new ArgumentOutOfRangeException(nameof(field), field, null)
}));
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 tremoloDepth = parameters.Expression(OwnerField(ownerPath, "color/tremolo"), Math.Clamp(voice.Color.TremoloDepth, 0, 1));
var tremoloHz = parameters.Expression(OwnerField(ownerPath, "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"));
parameters.IsBound(OwnerField(ownerPath, "color/tremolo")) ||
parameters.IsBound(OwnerField(ownerPath, "color/tremolo_hz"));
var tremolo = hasTremolo
? $" * (1.0 - {tremoloDepth} * (0.5 + 0.5 * lfo_sin({tremoloHz}, 0.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 noiseMix = $"clip01({parameters.Expression(OwnerField(ownerPath, "color/noise"), voice.Color.NoiseMix)} + patch_mod_noise + {noise})";
var driveExpression = $"clip01({parameters.Expression(OwnerField(ownerPath, "color/drive"), voice.Color.Drive)} + patch_mod_drive + {drive})";
var foldExpression = $"clip01({parameters.Expression(OwnerField(ownerPath, "color/fold"), voice.Color.Fold)} + patch_mod_fold + {fold})";
var formantMix = $"clip01({parameters.Expression(OwnerField(ownerPath, "color/formant_mix"), voice.Color.FormantMix)} + patch_mod_formant_mix + {formant})";
var lpf = $"clip01({parameters.Expression(OwnerField(ownerPath, "filter/lpf"), voice.Filter.LowPass)} * (1.0 + {parameters.Expression(OwnerField(ownerPath, "filter/lpf_ramp"), voice.Filter.LowPassRamp)} * age * 1.8) + patch_mod_lpf + {lpfMod})";
var hpf = $"clip01({parameters.Expression(OwnerField(ownerPath, "filter/hpf"), voice.Filter.HighPass)} * (1.0 + {parameters.Expression(OwnerField(ownerPath, "filter/hpf_ramp"), voice.Filter.HighPassRamp)} * age * 2.0) + patch_mod_hpf + {hpfMod})";
var hasResonance = voice.Filter.LowPassResonance > 0 || parameters.IsBound(OwnerField(ownerPath, "filter/resonance"));
var resonance = parameters.Expression(OwnerField(ownerPath, "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))";
@ -228,10 +235,10 @@ public static class FaustEmitter
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 ||
parameters.IsBound(VoiceField(voiceIndex, "phaser/offset")) ||
parameters.IsBound(VoiceField(voiceIndex, "phaser/ramp")))
parameters.IsBound(OwnerField(ownerPath, "phaser/offset")) ||
parameters.IsBound(OwnerField(ownerPath, "phaser/ramp")))
{
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))";
var delay = $"min(2047.0, max(0.0, abs({parameters.Expression(OwnerField(ownerPath, "phaser/offset"), voice.Phaser.OffsetSeconds)} + {parameters.Expression(OwnerField(ownerPath, "phaser/ramp"), voice.Phaser.RampSecondsPerSecond)} * age) * ma.SR))";
source.AppendLine($"{name}_phased = {name}_filtered + ({name}_filtered : de.fdelay(2048, {delay}));");
}
else
@ -239,10 +246,28 @@ 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}) * {parameters.Expression(VoiceField(voiceIndex, "gain"), 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(OwnerField(ownerPath, "gain"), voice.Gain)});");
source.AppendLine();
}
private static void EmitSpectralBank(
StringBuilder source,
SynthPatch patch,
SpectralBank bank,
int bankIndex,
string name,
ParameterMap parameters,
List<string> warnings)
{
const int tableSize = 1024;
var table = SpectralWaveform(bank, tableSize);
var frequency = parameters.Expression(OwnerField(SpectralPath(bankIndex), "note/frequency"), bank.Treatment.Note.FrequencyHz);
source.AppendLine($"{name}_wave = waveform {{{string.Join(",", table.Select(F))}}};");
source.AppendLine($"{name}_read_index = int(os.phasor({tableSize}, {frequency}));");
source.AppendLine($"{name}_wavetable = {name}_wave, {name}_read_index : rdtable;");
EmitVoice(source, patch, bank.Treatment, SpectralPath(bankIndex), name, parameters, warnings, $"{name}_wavetable");
}
private static string NoteFrequencyExpression(StringBuilder source, Playback playback, Note note, string name, string fieldPath, ParameterMap parameters)
{
if (UsesHostPlayback(playback))
@ -292,16 +317,16 @@ public static class FaustEmitter
private static string Curve(RateLevelCurve curve) =>
curve == RateLevelCurve.Exponential ? "1" : "0";
private static string OscillatorExpression(SynthPatch patch, Voice voice, int voiceIndex, string frequency, string duty, string fmIndex, ParameterMap parameters)
private static string OscillatorExpression(SynthPatch patch, Voice voice, string ownerPath, 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) ||
parameters.IsBound(VoiceField(voiceIndex, "fm/index")) ||
parameters.IsBound(VoiceField(voiceIndex, "fm/decay"));
parameters.IsBound(OwnerField(ownerPath, "fm/index")) ||
parameters.IsBound(OwnerField(ownerPath, "fm/decay"));
var phaseMod = voice.Fm.Index > 0 || voice.Fm.IndexDecaySeconds > 0 || hasFmMod
? $" + 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"
? $" + sin(2.0 * ma.PI * os.phasor(1.0, {frequency} * max(0.0, {parameters.Expression(OwnerField(ownerPath, "fm/ratio"), Math.Max(voice.Fm.Ratio, 0))}))) * ({fmIndex}) / ma.PI"
: "";
var phase = $"wrap01(os.phasor(1.0, {frequency}) + {parameters.Expression(VoiceField(voiceIndex, "osc/phase"), voice.Oscillator.Phase)}{phaseMod})";
var phase = $"wrap01(os.phasor(1.0, {frequency}) + {parameters.Expression(OwnerField(ownerPath, "osc/phase"), voice.Oscillator.Phase)}{phaseMod})";
return voice.Oscillator.Waveform switch
{
Waveform.Sine => $"sin(2.0 * ma.PI * {phase})",
@ -442,6 +467,43 @@ public static class FaustEmitter
return $"({string.Join(" + ", parts)}) / {F(gainSum)}";
}
private static float[] SpectralWaveform(SpectralBank bank, int tableSize)
{
var samples = new float[tableSize];
var maxBin = tableSize / 2 - 1;
var amplitudes = new double[maxBin + 1];
foreach (var partial in bank.Partials)
{
var center = Math.Max(1, partial.Ratio);
var sigma = Math.Max(0.05, center * bank.SpreadRatio * 8);
var left = Math.Max(1, (int)Math.Floor(center - sigma * 4));
var right = Math.Min(maxBin, (int)Math.Ceiling(center + sigma * 4));
for (var bin = left; bin <= right; bin++)
{
var distance = (bin - center) / sigma;
amplitudes[bin] += partial.Gain * Math.Exp(-0.5 * distance * distance);
}
}
for (var i = 0; i < tableSize; i++)
{
var phase = i / (double)tableSize;
var value = 0.0;
for (var bin = 1; bin <= maxBin; bin++)
{
if (amplitudes[bin] == 0) continue;
value += amplitudes[bin] * Math.Sin(Math.Tau * bin * phase);
}
samples[i] = (float)value;
}
var peak = samples.Select(Math.Abs).DefaultIfEmpty(0).Max();
if (peak <= 0) return samples;
for (var i = 0; i < samples.Length; i++) samples[i] /= peak;
return samples;
}
private static string ModExpressionForTarget(IEnumerable<ControlLane> controls, ModTarget target)
{
var expressions = controls.Where(control => control.Modulator.Target == target)
@ -498,7 +560,11 @@ public static class FaustEmitter
private static string ParameterIdentifier(int index) => $"patch_param_{index}";
private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}";
private static string VoicePath(int voiceIndex) => $"/voices/{voiceIndex}";
private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}";
private static string OwnerField(string ownerPath, string field) => $"{ownerPath}/{field}";
private static int GraphIndex(string name)
{

View file

@ -205,7 +205,8 @@ public sealed record SpectralBank(
string LayerName,
float RootFrequencyHz,
float SpreadRatio,
IReadOnlyList<HarmonicPartial> Partials);
IReadOnlyList<HarmonicPartial> Partials,
Voice Treatment);
public sealed record ReferencePatch(
string Id,

View file

@ -6,7 +6,6 @@ public sealed class PatchScriptException(int line, string message) : Exception($
{
public int Line { get; } = line;
}
public static class PatchScript
{
public static SynthPatch Parse(string script)
@ -43,7 +42,7 @@ public static class PatchScript
public List<Voice> Voices { get; } = [];
public List<OperatorGraph> OperatorGraphs => _operatorGraphs;
public bool HasOutput => Voices.Count > 0 || _operatorGraphs.Count > 0 || _pendingOperatorGraph is not null;
public bool HasOutput => Voices.Count > 0 || _spectralBanks.Count > 0 || _operatorGraphs.Count > 0 || _pendingOperatorGraph is not null;
private Repeat? Repeat { get; set; }
private Playback Playback { get; set; } = new();
private float Gain { get; set; } = 1;
@ -111,7 +110,7 @@ public static class PatchScript
break;
case "voice":
FlushPendingOperatorGraph();
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line));
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "opgraph":
StartOperatorGraph(fields, line);
@ -190,7 +189,7 @@ public static class PatchScript
["freq"] = F(rootFrequency * partial.Ratio),
["gain"] = F(partial.Gain)
};
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line));
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), VoicePath(Voices.Count), line));
}
}
@ -222,9 +221,7 @@ public static class PatchScript
throw new PatchScriptException(line, "spectrum needs at least one partial");
}
_spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials));
var sharedFields = Without(fields,
var treatmentFields = Without(fields,
"layer",
"root",
"base",
@ -235,37 +232,17 @@ public static class PatchScript
"detune",
"partials",
"bank",
"tones",
"gain",
"g");
foreach (var partial in partials)
{
if (spread == 0)
{
AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio, partial.Gain, line);
continue;
}
AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 - spread), partial.Gain * 0.5f, line);
AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 + spread), partial.Gain * 0.5f, line);
}
"tones");
treatmentFields["layer"] = layerName;
treatmentFields["freq"] = F(rootFrequency);
var treatment = ParseVoice(
ExpandVoiceFields(treatmentFields, line),
SpectralPath(_spectralBanks.Count),
line);
_spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials, treatment));
}
private void AddSpectralVoice(
IReadOnlyDictionary<string, string> sharedFields,
string layerName,
float frequency,
float gain,
int line)
{
var voiceFields = new Dictionary<string, string>(sharedFields, StringComparer.OrdinalIgnoreCase)
{
["layer"] = layerName,
["freq"] = F(frequency),
["gain"] = F(gain)
};
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line));
}
private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}";
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
@ -372,21 +349,21 @@ public static class PatchScript
}
}
private Voice ParseVoice(IReadOnlyDictionary<string, string> fields, int voiceIndex, int line)
private Voice ParseVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var waveform = TryGetAny(fields, ["wave", "w"], out var wave) ? ParseWaveform(wave, line) : Waveform.Sine;
var frequency = GetBoundFloat(fields, line, 440, VoiceField(voiceIndex, "note/frequency"), "freq", "frequency", "f");
var frequency = GetBoundFloat(fields, line, 440, OwnerField(ownerPath, "note/frequency"), "freq", "frequency", "f");
var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec)
? ParseEnvelopeSpec(envSpec, fields, line, VoicePath(voiceIndex))
? ParseEnvelopeSpec(envSpec, fields, line, ownerPath)
: null;
var gateSeconds = envelopeSpec?.GateSeconds ??
GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "note/gate"), "gate", "hold", "duration", "sustain", "s");
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "note/gate"), "gate", "hold", "duration", "sustain", "s");
var sustainLevel = envelopeSpec?.Envelope.SustainLevel ??
GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "env/sustain_level"), "sustain_level", "sl");
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "env/sustain_level"), "sustain_level", "sl");
var gainScale = 1f;
if (TryGetAny(fields, ["punch", "pu"], out var punch))
{
gainScale = PunchGain(ParseBoundFloat(punch, line, 0, VoiceField(voiceIndex, "env/sustain_level")));
gainScale = PunchGain(ParseBoundFloat(punch, line, 0, OwnerField(ownerPath, "env/sustain_level")));
sustainLevel = 1 / gainScale;
}
var noteSource = ParseNoteSource(GetAny(fields, ["note_source", "source"], "oneshot"), line);
@ -414,8 +391,8 @@ public static class PatchScript
if (hasArpDelay || hasArpMult)
{
arpeggio = new Arpeggio(
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")));
ParseBoundFloat(arpDelay ?? throw new PatchScriptException(line, "arpeggio needs arp_delay"), line, 0, OwnerField(ownerPath, "arpeggio/delay")),
ParseBoundFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line, 1, OwnerField(ownerPath, "arpeggio/multiplier")));
}
return new Voice
@ -426,47 +403,47 @@ public static class PatchScript
Oscillator = new Oscillator(
waveform,
frequency,
GetBoundFloat(fields, line, 0.5f, VoiceField(voiceIndex, "osc/duty"), "duty", "du"),
GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "osc/phase"), "phase", "pa")),
GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "osc/duty"), "duty", "du"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "osc/phase"), "phase", "pa")),
Note = new Note(frequency, gateSeconds, noteSource),
Envelope = envelopeSpec?.Envelope ?? new Envelope(
GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/attack"), "attack", "a"),
GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/decay"), "env_decay", "ed"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/attack"), "attack", "a"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/decay"), "env_decay", "ed"),
sustainLevel,
GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "env/release"), "release", "rel", "decay", "d")),
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "env/release"), "release", "rel", "decay", "d")),
RateLevelEnvelope = envelopeSpec?.RateLevelEnvelope,
Pitch = new PitchMotion(
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")),
GetBoundFloat(fields, line, 20, OwnerField(ownerPath, "pitch/min_freq"), "min_freq", "min"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/ramp"), "pitch_ramp", "pr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/delta"), "pitch_delta", "pd", "pitch_dramp", "pdr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato"), "vibrato", "vi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_hz"), "vibrato_hz", "vh"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_delay"), "vibrato_delay", "vd")),
Duty = new DutyMotion(GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "duty/ramp"), "duty_ramp", "dur")),
Filter = new Filter(
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")),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/lpf"), "lpf", "l"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/lpf_ramp"), "lpf_ramp", "lr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/resonance"), "resonance", "res"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf"), "hpf", "h"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf_ramp"), "hpf_ramp", "hr")),
Phaser = new Phaser(
GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/offset"), "phaser", "ph"),
GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/ramp"), "phaser_ramp", "phr")),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/offset"), "phaser", "ph"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/ramp"), "phaser_ramp", "phr")),
Arpeggio = arpeggio,
Fm = new FrequencyModulation(
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")),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "fm/ratio"), "fm", "fmr", "fm_ratio"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/index"), "fm_index", "fmi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/decay"), "fm_decay", "fmd")),
Color = new VoiceColor(
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")),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/noise"), "noise", "nz"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/drive"), "drive", "drv"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/fold"), "fold", "fl"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo"), "tremolo", "tr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo_hz"), "tremolo_hz", "th"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/formant_mix"), "formant_mix", "fmix")),
Formants = formants,
Modulators = modulators,
Gain = GetBoundFloat(fields, line, 0.2f, VoiceField(voiceIndex, "gain"), "gain", "g") * gainScale
Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale
};
}
@ -741,7 +718,7 @@ public static class PatchScript
private static string VoicePath(int voiceIndex) => $"/voices/{voiceIndex}";
private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}";
private static string OwnerField(string ownerPath, string field) => $"{ownerPath}/{field}";
private static List<OperatorNode> ParseOperatorNodes(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)

View file

@ -42,14 +42,14 @@ public static class ReferenceRebuildCatalog
[
new("engine_pad", "1", "Approximated as slow layered oscillators plus an air/noise layer."),
new("named_layers", "pad_low,pad_high,air", "Layer declarations separate PAD body from air/noise texture."),
new("pad_spectral_source", "2 spectral banks / 3 partials", "PAD layers declare spectral partial clouds before lowering to detuned Aquarium voices."),
new("pad_spectral_source", "2 spectral banks / 3 partials", "PAD layers declare spectral partial clouds that emit Faust wavetable sources before ordinary voice treatment."),
new("filter_count", "1", "Represented with low-pass/high-pass field sites."),
new("free_envelope_count", "1", "Approximated with staged voice rate/level envelopes and macro spread."),
new("voice_rate_level_envelope", "3", "PAD body and air layers use staged rate/level envelopes attached through layer defaults."),
new("slow_modulation", "patch_lfo", "The rebuild adds slow patch modulation because PAD texture wants motion even though the minimal fixture only marks PAD/filter/free-envelope pressure.")
],
[
new("padsynth_wavetable_exactness", "PADsynth FFT/wavetable generation", "Aquarium has spectral source syntax, but it lowers to detuned voices rather than generating a PADsynth wavetable."),
new("padsynth_wavetable_exactness", "PADsynth FFT/wavetable generation", "Aquarium has spectral-source wavetables, but not Zyn's exact PADsynth bandwidth and FFT table construction."),
new("free_envelope_shape", "arbitrary Zyn envelope points", "Aquarium has staged voice envelopes now, but not arbitrary point/free-mode Zyn envelope curves.")
],
"This is pressure, not a claim of PADsynth parity. Aquarium can now state a layered spectral cloud, while exact PAD wavetable generation remains separate pressure."),

View file

@ -63,4 +63,4 @@
{"kind":"layer_language_golf","lesson":"The first Zyn-driven language-golf abstraction is `layer`: named layer declarations own engine tags, optional MIDI key range metadata, default gain, and effect-send labels, while lowering to ordinary Aquarium voices for now. The abstraction generalizes across additive, PAD-texture, and vocal/formant rebuilds and gives future additive-bank, PAD-source, free-envelope, and formant-motion syntax a coherent owner. It deliberately does not implement those later features yet.","source":"Zyn named layer DSL slice on 2026-05-14"}
{"kind":"harmonic_bank_language_golf","lesson":"Additive `harmonics` syntax now records authored ratio/gain partial banks under named layers and lowers them to ordinary Aquarium voices. This generalizes additive lead and future drawbar/partial-bank targets without hiding Zyn ADDsynth phase, bandwidth, oscillator shaping, or free-envelope semantics behind a fake parity claim.","source":"Zyn harmonic-bank DSL slice on 2026-05-14"}
{"kind":"voice_rate_level_envelopes","lesson":"The existing readable `env=rl rates=... levels=... curves=... gate=...` contour now applies to normal voices and therefore to named layer defaults, not only operator graphs. This gives PAD and vocal Zyn rebuilds staged non-ADSR onset/release shape while preserving the invariant that Note owns gate timing and envelope data owns level shape. Arbitrary Zyn free-envelope point curves remain missing pressure.","source":"Zyn staged voice envelope DSL slice on 2026-05-14"}
{"kind":"pad_spectral_cloud","lesson":"PAD-style layers now have `spectrum layer=... root=... spread=... partials=ratio:gain,...`, recorded as `SpectralBank` and lowered to detuned voice pairs. This gives PAD texture rebuilds a source-level partial-cloud authority without claiming exact PADsynth FFT/wavetable generation; roots/spreads/partials are authoring-time graph shape, not runtime controls.","source":"Zyn PAD spectral-cloud DSL slice on 2026-05-14"}
{"kind":"pad_spectral_cloud","lesson":"PAD-style layers now have `spectrum layer=... root=... spread=... partials=ratio:gain,...`, recorded as `SpectralBank` and emitted as static Faust waveform/rdtable wavetable sources before ordinary voice treatment. This gives PAD texture rebuilds a real spectral source authority without inflating the voice graph or claiming exact PADsynth bandwidth/FFT table generation; roots/spreads/partials are authoring-time table shape, not runtime controls.","source":"Zyn PAD spectral-cloud DSL slice on 2026-05-14"}

View file

@ -85,7 +85,7 @@
},
"zyn_reference_path": {
"status": "Initial ZynInstrumentReader parses plain XML and gzip-compressed .xiz-shaped XML from project-authored fixtures into ZynInstrument records and ReferencePatch features. First Zyn-style Aquarium rebuilds exist for additive lead, PAD texture, and vocal/formant layer. `ZynInstrumentSurvey` can rank .xiz directories by feature pressure. Named `layer` declarations provide the first Zyn-driven DSL abstraction; additive `harmonics`, PAD `spectrum`, and staged `env=rl` voice envelopes now attach to those layers.",
"lesson": "Start Zyn as structural classification, not translation. Upstream Zyn presets live in a separate bank submodule and should not be vendored until license/provenance is explicit. The hardened reader now parses the observed upstream instrument bank at commit e9f64a9: 1358/1358 .xiz files. The worst target is `olivers-100/0032-Drum Kit.xiz`, with 16 enabled kit items, mixed ADD/SUB/PAD engines, 106 envelopes, 25 free envelopes, 73 LFOs, 37 filters, and 3 formant filters. The first rebuilds show Aquarium can sketch small Zyn fixtures with existing voices/macros/formants/noise, and the upstream survey correctly pushed named kit/layer routing first. `layer` owns name, engine tag, optional key range metadata, default gain, and effect-send labels while lowering to ordinary voices. `harmonics` owns authored ratio/gain partial banks under those layers. `spectrum` owns PAD-style root/spread/partial clouds under those layers and lowers partials to detuned voice pairs. Staged `env=rl` now applies to voices as well as operators, preserving Note as gate authority and envelope data as level-shape authority. Aquarium still does not claim Zyn ADDsynth phase/bandwidth details, exact PADsynth FFT/wavetable generation, arbitrary free-envelope point curves, or richer formant morphing.",
"lesson": "Start Zyn as structural classification, not translation. Upstream Zyn presets live in a separate bank submodule and should not be vendored until license/provenance is explicit. The hardened reader now parses the observed upstream instrument bank at commit e9f64a9: 1358/1358 .xiz files. The worst target is `olivers-100/0032-Drum Kit.xiz`, with 16 enabled kit items, mixed ADD/SUB/PAD engines, 106 envelopes, 25 free envelopes, 73 LFOs, 37 filters, and 3 formant filters. The first rebuilds show Aquarium can sketch small Zyn fixtures with existing voices/macros/formants/noise, and the upstream survey correctly pushed named kit/layer routing first. `layer` owns name, engine tag, optional key range metadata, default gain, and effect-send labels while lowering to ordinary voices. `harmonics` owns authored ratio/gain partial banks under those layers. `spectrum` owns PAD-style root/spread/partial clouds under those layers and emits Faust wavetable sources before ordinary voice treatment. Staged `env=rl` now applies to voices as well as operators, preserving Note as gate authority and envelope data as level-shape authority. Aquarium still does not claim Zyn ADDsynth phase/bandwidth details, exact PADsynth bandwidth/FFT table generation, arbitrary free-envelope point curves, or richer formant morphing.",
"next": "Choose the next syntax that attaches to named layers: richer formant/vowel morphing. Prove it across more than one target."
},
"note_and_envelope_model": {
@ -101,7 +101,7 @@
},
"last_verified": {
"command": "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore",
"result": "93 passed; PAD spectral-cloud DSL added without regressing DX7 parity",
"commit": "pending Zyn PAD spectral clouds"
"result": "94 passed; PAD spectral-cloud DSL now emits Faust waveform/rdtable wavetable sources without regressing DX7 parity",
"commit": "pending Zyn PAD spectral wavetable sources"
}
}

View file

@ -17,20 +17,21 @@ Completed this slice:
- Added PAD spectral-cloud syntax attached to named layers:
`spectrum layer=pad_low root=130.8128 spread=.012 partials=1:.07,1.5:.052`.
- The parser preserves each bank as `SpectralBank` model data, then lowers
each partial to detuned voice pairs. `root`, `spread`, and `partials` are
authoring-time graph shape, not runtime controls.
- The parser preserves each bank as `SpectralBank` model data. Faust export now
emits each bank as a static `waveform`/`rdtable` wavetable source before
applying the normal voice treatment path. `root`, `spread`, and `partials`
are authoring-time table shape, not runtime controls.
- Converted the Zyn PAD texture rebuild from individual PAD body voices to two
spectral banks under `pad_low` and `pad_high`; the air/noise layer remains a
normal voice.
- This is not exact PADsynth FFT/wavetable generation. It is a source-level
- This is not exact PADsynth bandwidth/FFT table generation. It is a source-level
partial-cloud authority for readable PAD rebuilds.
- Focused verification passed:
- Focused verification before wavetable replacement passed:
`dotnet test tests\AquariumSynth.Dsl.Tests\AquariumSynth.Dsl.Tests.csproj --no-restore --filter "FullyQualifiedName~SpectralBank|FullyQualifiedName~ZynStyleReferenceRebuildsParseExportAndDeclarePressure|FullyQualifiedName~BuiltInReferenceScriptsParseAndExportFaust"`:
4 passed.
- Full verification with bundled Python/dexed-py:
- Wavetable replacement verification:
`AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore`:
93 passed.
94 passed.
Previous slice:

View file

@ -76,7 +76,7 @@ live_invariants:
- Named `layer` declarations own Zyn-style kit/layer identity: engine tag, optional key range metadata, default gain, and effect-send labels. They lower to ordinary voices for now; do not smuggle PAD/free-envelope/effect behavior into them.
- Additive `harmonics` declarations own authored ratio/gain partial banks under named layers. They lower to ordinary voices for now; do not pretend this covers Zyn ADDsynth phase, bandwidth, oscillator shaping, or free-envelope semantics.
- Normal voices may use staged `env=rl` rate/level envelopes. Notes still own gate timing; envelopes own level shape. This is not arbitrary Zyn free-envelope point editing.
- PAD `spectrum` declarations own authored partial clouds under named layers. They lower to detuned voice pairs for now; do not pretend this covers exact PADsynth FFT/wavetable generation.
- PAD `spectrum` declarations own authored partial clouds under named layers. They emit Faust wavetable sources before ordinary voice treatment; do not pretend this covers exact PADsynth bandwidth/FFT table generation.
- Structured `.aqua` stock lives under `patches/` as development source material. Every library patch must parse/export, and the patch library must stay out of the NuGet package unless intentionally promoted.
- Project-authored Dexed patches may be generated inside tests for calibration; do not ship them in the NuGet package or treat them as runtime dependencies.
- Harsh calibration probes belong in tests and ignored artifacts, not in the patch library unless they are musically useful stock.
@ -98,7 +98,7 @@ reference_targets:
priority: 2
purpose: "Additive, subtractive, PAD, formant, kit, and layered-instrument pressure test."
source: "https://github.com/zynaddsubfx/zynaddsubfx"
status: "Initial ZynInstrumentReader reads plain or gzip-compressed .xiz XML from project-authored fixtures and classifies active ADD/SUB/PAD engines, enabled kit items, layering, envelopes, free envelopes, LFOs, filters, formants, and effects as ReferencePatch features. First Aquarium rebuilds exist for additive lead, PAD texture, and vocal/formant layer and now use named `layer` declarations. Additive rebuilds can use `harmonics` banks attached to layers, PAD rebuilds can use `spectrum` partial-cloud banks attached to layers, and PAD/vocal rebuilds use staged voice `env=rl` envelopes through layer defaults. A non-vendored upstream bank survey parsed 1358/1358 .xiz files and identified named kit/layer routing as the first owner abstraction."
status: "Initial ZynInstrumentReader reads plain or gzip-compressed .xiz XML from project-authored fixtures and classifies active ADD/SUB/PAD engines, enabled kit items, layering, envelopes, free envelopes, LFOs, filters, formants, and effects as ReferencePatch features. First Aquarium rebuilds exist for additive lead, PAD texture, and vocal/formant layer and now use named `layer` declarations. Additive rebuilds can use `harmonics` banks attached to layers, PAD rebuilds can use `spectrum` wavetable source banks attached to layers, and PAD/vocal rebuilds use staged voice `env=rl` envelopes through layer defaults. A non-vendored upstream bank survey parsed 1358/1358 .xiz files and identified named kit/layer routing as the first owner abstraction."
first_slice:
- "Use named layers as the owner before promoting richer formant morphing syntax."
- "Additive harmonic banks, PAD spectral clouds, and staged voice envelopes now exist; prove any next syntax across more than one target."
@ -133,8 +133,9 @@ next_agent_start_here:
`src/AquariumSynth.Dsl/ZynInstrument.cs` with project-authored fixtures
under `tests/AquariumSynth.Dsl.Tests/Fixtures/ZynAddSubFX/ProjectAuthored`.
Named `layer` declarations own source identity, additive `harmonics` banks
attach to those layers, PAD `spectrum` banks attach to those layers, and
staged `env=rl` envelopes now work on normal voices/layer defaults. Next
attach to those layers, PAD `spectrum` banks attach to those layers as
Faust wavetable sources, and staged `env=rl` envelopes now work on normal
voices/layer defaults. Next
pressure is richer formant/vowel morphing. Prove it across more than one
target, and leave DX7 alone unless a regression test fails.
must_read:
@ -153,12 +154,12 @@ completed_slices:
summary: >
Added PAD spectral-cloud syntax for named layers. `spectrum
layer=pad_low root=130.8128 spread=.012 partials=1:.07,1.5:.052`
records authored PAD partial intent as `SpectralBank`, then lowers each
partial to detuned voice pairs for inspectable Faust output. The Zyn PAD
texture rebuild now uses two spectral banks under `pad_low` and
`pad_high`, while exact PADsynth FFT/wavetable generation remains missing
pressure.
verification: "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore: 93 passed"
records authored PAD partial intent as `SpectralBank`, then emits static
Faust waveform/rdtable wavetable sources before ordinary voice treatment.
The Zyn PAD texture rebuild now uses two spectral banks under `pad_low`
and `pad_high`, while exact PADsynth bandwidth/FFT table construction
remains missing pressure.
verification: "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore: 94 passed"
- date: 2026-05-14
summary: >
Promoted staged rate/level envelopes from operator graphs to normal

View file

@ -386,7 +386,7 @@ public sealed class PatchScriptTests
}
[Fact]
public void SpectralBankSyntaxExpandsPadPartialCloud()
public void SpectralBankSyntaxEmitsPadWavetableSource()
{
var patch = PatchScript.Parse("""
layer name=pad engine=pad gain=.08 wave=saw env=rl rates=.1,.2,.3,.4 levels=1,.8,.5,0 gate=.9
@ -399,14 +399,13 @@ public sealed class PatchScriptTests
Assert.Equal(100, bank.RootFrequencyHz);
Assert.Equal(.01f, bank.SpreadRatio, 5);
Assert.Equal(2, bank.Partials.Count);
Assert.Equal(4, patch.Voices.Count);
Assert.All(patch.Voices, voice => Assert.Equal("pad", voice.Layer?.Name));
Assert.All(patch.Voices, voice => Assert.NotNull(voice.RateLevelEnvelope));
Assert.Equal([99, 101, 148.5f, 151.5f], patch.Voices.Select(voice => voice.Oscillator.FrequencyHz).ToArray());
Assert.Equal(.04f, patch.Voices[0].Gain, 5);
Assert.Equal(.04f, patch.Voices[1].Gain, 5);
Assert.Equal(.02f, patch.Voices[2].Gain, 5);
Assert.Equal(.02f, patch.Voices[3].Gain, 5);
Assert.Empty(patch.Voices);
Assert.Equal("pad", bank.Treatment.Layer?.Name);
Assert.NotNull(bank.Treatment.RateLevelEnvelope);
Assert.Equal(100, bank.Treatment.Oscillator.FrequencyHz);
Assert.Equal(.08f, bank.Treatment.Gain, 5);
Assert.Contains("spectral_0_wave = waveform", export.Source);
Assert.Contains("spectral_0_wavetable = spectral_0_wave, spectral_0_read_index : rdtable", export.Source);
Assert.Contains("process =", export.Source);
}
@ -708,6 +707,23 @@ public sealed class PatchScriptTests
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesSpectralBankWhenInstalled()
{
var export = FaustEmitter.EmitScript("""
layer name=pad engine=pad gain=.08 env=rl rates=.1,.2,.3,.4 levels=1,.8,.5,0 gate=.9
spectrum layer=pad root=100 spread=.01 partials=1:.08,2:.04,3:.02
""");
var validation = await FaustCompiler.ValidateAsync(export.Source);
if (validation is null)
{
return;
}
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesMidiGatePatchWhenInstalled()
{