Emit PAD spectra as Faust wavetables
This commit is contained in:
parent
ff43eef5e3
commit
1cb6b2852f
11 changed files with 220 additions and 157 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue