Generate PAD spectra with FFT wavetables

This commit is contained in:
Meta Vens 2026-05-14 21:20:15 +01:00
commit 25fb9d1e90
9 changed files with 215 additions and 68 deletions

View file

@ -169,15 +169,20 @@ 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 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.
The source table is generated by an authoring-time PADsynth-style path:
harmonics are spread into a frequency-domain amplitude profile, deterministic
random phase is applied, one inverse FFT produces the looped table, and Faust
only reads that baked table. This gives PAD texture targets a reusable source
authority without inflating the voice graph. It still does not claim full Zyn
PAD engine parity across all harmonic profiles, randomness controls, or
multi-table pitch-zone behavior. `root`, `spread`, and `partials` are
authoring-time table shape, not runtime controls.
Sources:
- [ZynAddSubFX](https://github.com/zynaddsubfx/zynaddsubfx)
- [ZynAddSubFX feature list](https://github.com/zynaddsubfx/zynaddsubfx)
- [Paul Nasca's PADsynth algorithm](https://zynaddsubfx.sourceforge.io/doc/PADsynth/PADsynth.htm)
### Tier 3: Faust Examples And Libraries

View file

@ -107,6 +107,8 @@ 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 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.
Faust wavetable source before normal voice treatment. The table is generated by
a PADsynth-style authoring path: frequency-domain harmonic spreading,
deterministic random phase, one inverse FFT, and normalization. This is not full
Zyn PAD engine parity across every harmonic profile and pitch-zone behavior, but
it is now real spectral-table synthesis rather than voice-count theater.

View file

@ -259,11 +259,12 @@ public static class FaustEmitter
ParameterMap parameters,
List<string> warnings)
{
const int tableSize = 1024;
var table = SpectralWaveform(bank, tableSize);
const int tableSize = 4096;
var table = PadSynthWaveform.Generate(bank, tableSize);
var frequency = parameters.Expression(OwnerField(SpectralPath(bankIndex), "note/frequency"), bank.Treatment.Note.FrequencyHz);
var readFrequency = $"({F(PadSynthWaveform.SampleRate)} / {F(tableSize)} * ({frequency}) / {F(bank.RootFrequencyHz)})";
source.AppendLine($"{name}_wave = waveform {{{string.Join(",", table.Select(F))}}};");
source.AppendLine($"{name}_read_index = int(os.phasor({tableSize}, {frequency}));");
source.AppendLine($"{name}_read_index = int(os.phasor({tableSize}, {readFrequency}));");
source.AppendLine($"{name}_wavetable = {name}_wave, {name}_read_index : rdtable;");
EmitVoice(source, patch, bank.Treatment, SpectralPath(bankIndex), name, parameters, warnings, $"{name}_wavetable");
}
@ -467,43 +468,6 @@ 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)

View file

@ -0,0 +1,166 @@
using System.Numerics;
namespace AquariumSynth.Dsl;
internal static class PadSynthWaveform
{
public const int SampleRate = 44100;
public static float[] Generate(SpectralBank bank, int tableSize)
{
if (tableSize < 2 || (tableSize & (tableSize - 1)) != 0)
{
throw new ArgumentException("PAD spectral table size must be a power of two", nameof(tableSize));
}
var spectrum = new Complex[tableSize];
var amplitudes = AmplitudeSpectrum(bank, tableSize);
var random = new Random(StableSeed(bank, tableSize));
var half = tableSize / 2;
for (var bin = 1; bin < half; bin++)
{
if (amplitudes[bin] <= 0)
{
continue;
}
var phase = random.NextDouble() * Math.Tau;
var value = Complex.FromPolarCoordinates(amplitudes[bin], phase);
spectrum[bin] = value;
spectrum[tableSize - bin] = Complex.Conjugate(value);
}
InverseFft(spectrum);
var samples = new float[tableSize];
for (var i = 0; i < samples.Length; i++)
{
samples[i] = (float)spectrum[i].Real;
}
Normalize(samples);
return samples;
}
private static double[] AmplitudeSpectrum(SpectralBank bank, int tableSize)
{
var amplitudes = new double[tableSize / 2 + 1];
foreach (var partial in bank.Partials)
{
var ratio = Math.Max(0.0001, partial.Ratio);
var centerCyclesPerSample = bank.RootFrequencyHz * ratio / SampleRate;
var bandwidthCents = 1200.0 * Math.Log2(1.0 + Math.Max(0, bank.SpreadRatio));
if (bandwidthCents <= 0)
{
var bin = (int)Math.Round(centerCyclesPerSample * tableSize);
if (bin > 0 && bin < amplitudes.Length)
{
amplitudes[bin] += partial.Gain;
}
continue;
}
var bandwidthHz = (Math.Pow(2.0, bandwidthCents / 1200.0) - 1.0) * bank.RootFrequencyHz * ratio;
var bandwidthCyclesPerSample = Math.Max(1e-9, bandwidthHz / (2.0 * SampleRate));
var centerBin = centerCyclesPerSample * tableSize;
var radiusBins = Math.Max(2, (int)Math.Ceiling(bandwidthCyclesPerSample * tableSize * 6));
var left = Math.Max(1, (int)Math.Floor(centerBin - radiusBins));
var right = Math.Min(amplitudes.Length - 1, (int)Math.Ceiling(centerBin + radiusBins));
for (var bin = left; bin <= right; bin++)
{
var cyclesPerSample = bin / (double)tableSize;
var x = (cyclesPerSample - centerCyclesPerSample) / bandwidthCyclesPerSample;
amplitudes[bin] += Math.Exp(-x * x) / bandwidthCyclesPerSample * partial.Gain;
}
}
return amplitudes;
}
private static void InverseFft(Complex[] data)
{
BitReverse(data);
for (var length = 2; length <= data.Length; length <<= 1)
{
var angle = Math.Tau / length;
var step = Complex.FromPolarCoordinates(1.0, angle);
for (var start = 0; start < data.Length; start += length)
{
var rotation = Complex.One;
var half = length / 2;
for (var offset = 0; offset < half; offset++)
{
var even = data[start + offset];
var odd = data[start + offset + half] * rotation;
data[start + offset] = even + odd;
data[start + offset + half] = even - odd;
rotation *= step;
}
}
}
for (var i = 0; i < data.Length; i++)
{
data[i] /= data.Length;
}
}
private static void BitReverse(Complex[] data)
{
var j = 0;
for (var i = 1; i < data.Length; i++)
{
var bit = data.Length >> 1;
while ((j & bit) != 0)
{
j ^= bit;
bit >>= 1;
}
j ^= bit;
if (i < j)
{
(data[i], data[j]) = (data[j], data[i]);
}
}
}
private static void Normalize(float[] samples)
{
var peak = samples.Select(Math.Abs).DefaultIfEmpty(0).Max();
if (peak <= 0)
{
return;
}
for (var i = 0; i < samples.Length; i++)
{
samples[i] /= peak;
}
}
private static int StableSeed(SpectralBank bank, int tableSize)
{
var hash = 2166136261u;
AddInt(tableSize);
AddFloat(bank.RootFrequencyHz);
AddFloat(bank.SpreadRatio);
foreach (var partial in bank.Partials)
{
AddFloat(partial.Ratio);
AddFloat(partial.Gain);
}
return unchecked((int)hash);
void AddFloat(float value) => AddUInt(BitConverter.SingleToUInt32Bits(value));
void AddInt(int value) => AddUInt((uint)value);
void AddUInt(uint value)
{
hash ^= value;
hash *= 16777619u;
}
}
}

View file

@ -42,17 +42,17 @@ 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 that emit Faust wavetable sources before ordinary voice treatment."),
new("pad_spectral_source", "2 spectral banks / 3 partials", "PAD layers declare spectral partial clouds that emit PADsynth-style FFT 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 wavetables, but not Zyn's exact PADsynth bandwidth and FFT table construction."),
new("padsynth_wavetable_exactness", "Full Zyn PAD engine behavior", "Aquarium now uses frequency-domain spreading, deterministic random phase, and one inverse FFT, but does not cover every Zyn harmonic profile, randomness control, or pitch-zone table behavior."),
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."),
"This is pressure, not a claim of full Zyn PAD parity. Aquarium can now state a layered FFT spectral cloud, while exact Zyn PAD profile coverage remains separate pressure."),
new(
"zyn/project-vocal-layer/aquarium",
"zynaddsubfx",

View file

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

View file

@ -85,7 +85,7 @@
},
"zyn_reference_path": {
"status": "Initial ZynInstrumentReader parses plain XML and gzip-compressed .xiz-shaped XML from project-authored fixtures into ZynInstrument records and ReferencePatch features. First Zyn-style Aquarium rebuilds exist for additive lead, PAD texture, and vocal/formant layer. `ZynInstrumentSurvey` can rank .xiz directories by feature pressure. Named `layer` declarations provide the first Zyn-driven DSL abstraction; additive `harmonics`, PAD `spectrum`, and staged `env=rl` voice envelopes now attach to those layers.",
"lesson": "Start Zyn as structural classification, not translation. Upstream Zyn presets live in a separate bank submodule and should not be vendored until license/provenance is explicit. The hardened reader now parses the observed upstream instrument bank at commit e9f64a9: 1358/1358 .xiz files. The worst target is `olivers-100/0032-Drum Kit.xiz`, with 16 enabled kit items, mixed ADD/SUB/PAD engines, 106 envelopes, 25 free envelopes, 73 LFOs, 37 filters, and 3 formant filters. The first rebuilds show Aquarium can sketch small Zyn fixtures with existing voices/macros/formants/noise, and the upstream survey correctly pushed named kit/layer routing first. `layer` owns name, engine tag, optional key range metadata, default gain, and effect-send labels while lowering to ordinary voices. `harmonics` owns authored ratio/gain partial banks under those layers. `spectrum` owns PAD-style root/spread/partial clouds under those layers and 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.",
"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 generates PADsynth-style FFT wavetables with frequency-domain spreading, deterministic random phase, and one inverse FFT 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, full Zyn PAD harmonic-profile/randomness/pitch-zone behavior, 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": "94 passed; PAD spectral-cloud DSL now emits Faust waveform/rdtable wavetable sources without regressing DX7 parity",
"commit": "pending Zyn PAD spectral wavetable sources"
"result": "94 passed; PAD spectral-cloud DSL now uses PADsynth-style FFT table generation without regressing DX7 parity",
"commit": "pending Zyn PAD FFT spectral tables"
}
}

View file

@ -17,14 +17,17 @@ 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. 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.
- The parser preserves each bank as `SpectralBank` model data. `PadSynthWaveform`
now generates each bank as a PADsynth-style FFT table: frequency-domain
harmonic spreading, deterministic random phase, one inverse FFT, and
normalization. Faust export emits that static `waveform`/`rdtable` 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 bandwidth/FFT table generation. It is a source-level
- This is not full Zyn PAD engine parity across every harmonic profile,
randomness control, or pitch-zone behavior. It is a source-level FFT
partial-cloud authority for readable PAD rebuilds.
- 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"`:
@ -32,6 +35,12 @@ Completed this slice:
- Wavetable replacement verification:
`AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore`:
94 passed.
- FFT table generator focused verification:
`dotnet test tests\AquariumSynth.Dsl.Tests\AquariumSynth.Dsl.Tests.csproj --no-restore --filter "FullyQualifiedName~SpectralBank|FullyQualifiedName~FaustCompilerValidatesSpectralBank|FullyQualifiedName~ZynStyleReferenceRebuildsParseExportAndDeclarePressure"`:
4 passed.
- Full verification after FFT table generator:
`AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore`:
94 passed.
Previous slice:

View file

@ -76,7 +76,7 @@ live_invariants:
- Named `layer` declarations own Zyn-style kit/layer identity: engine tag, optional key range metadata, default gain, and effect-send labels. They lower to ordinary voices for now; do not smuggle PAD/free-envelope/effect behavior into them.
- Additive `harmonics` declarations own authored ratio/gain partial banks under named layers. They lower to ordinary voices for now; do not pretend this covers Zyn ADDsynth phase, bandwidth, oscillator shaping, or free-envelope semantics.
- Normal voices may use staged `env=rl` rate/level envelopes. Notes still own gate timing; envelopes own level shape. This is not arbitrary Zyn free-envelope point editing.
- PAD `spectrum` declarations own authored partial clouds under named layers. They emit Faust wavetable sources before ordinary voice treatment; do not pretend this covers exact PADsynth bandwidth/FFT table generation.
- PAD `spectrum` declarations own authored partial clouds under named layers. They generate PADsynth-style FFT wavetables with frequency-domain spreading and deterministic random phase, then emit Faust table readers before ordinary voice treatment. Do not pretend this covers every Zyn PAD harmonic profile, randomness control, or pitch-zone table behavior.
- 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` 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."
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` PADsynth-style FFT wavetable 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."
@ -134,8 +134,8 @@ next_agent_start_here:
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 as
Faust wavetable sources, and staged `env=rl` envelopes now work on normal
voices/layer defaults. Next
PADsynth-style FFT 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:
@ -154,11 +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 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.
records authored PAD partial intent as `SpectralBank`, then generates a
PADsynth-style table with frequency-domain harmonic spreading,
deterministic random phase, one inverse FFT, and Faust waveform/rdtable
playback before ordinary voice treatment. The Zyn PAD texture rebuild now
uses two spectral banks under `pad_low` and `pad_high`; full Zyn PAD
profile/randomness/pitch-zone behavior remains missing pressure.
verification: "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore: 94 passed"
- date: 2026-05-14
summary: >