diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index b02e996..855303d 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -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: diff --git a/docs/zynaddsubfx-pressure-survey.md b/docs/zynaddsubfx-pressure-survey.md index be6dce5..34a6b50 100644 --- a/docs/zynaddsubfx-pressure-survey.md +++ b/docs/zynaddsubfx-pressure-survey.md @@ -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. diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs index 813313e..98478fb 100644 --- a/src/AquariumSynth.Dsl/FaustExport.cs +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -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(); 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 warnings) + List 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 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 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) { diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 4aa95df..5fc6165 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -205,7 +205,8 @@ public sealed record SpectralBank( string LayerName, float RootFrequencyHz, float SpreadRatio, - IReadOnlyList Partials); + IReadOnlyList Partials, + Voice Treatment); public sealed record ReferencePatch( string Id, diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index 0feb67e..f7e7314 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -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 Voices { get; } = []; public List 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 sharedFields, - string layerName, - float frequency, - float gain, - int line) - { - var voiceFields = new Dictionary(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 ExpandVoiceFields(Dictionary fields, int line) { @@ -372,21 +349,21 @@ public static class PatchScript } } - private Voice ParseVoice(IReadOnlyDictionary fields, int voiceIndex, int line) + private Voice ParseVoice(IReadOnlyDictionary 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 ParseOperatorNodes(string value, int line) => value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs index ca70603..2327470 100644 --- a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs +++ b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs @@ -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."), diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 495bf1f..4700bae 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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"} diff --git a/state/memory.json b/state/memory.json index 9e9b125..902185c 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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= 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" } } diff --git a/state/scratch.md b/state/scratch.md index 9a6b0d7..ade0a2f 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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= dotnet test AquariumSynthCSharp.slnx --no-restore`: - 93 passed. + 94 passed. Previous slice: diff --git a/state/spine.yaml b/state/spine.yaml index c274523..e5e4f5d 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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= 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= dotnet test AquariumSynthCSharp.slnx --no-restore: 94 passed" - date: 2026-05-14 summary: > Promoted staged rate/level envelopes from operator graphs to normal diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 035b345..185d1d1 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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() {