Run processed Zyn oscillators through stage order

This commit is contained in:
Meta Vens 2026-05-15 09:41:17 +01:00
commit cc1aee2f60
5 changed files with 290 additions and 48 deletions

View file

@ -1,5 +1,6 @@
using System.IO.Compression;
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
@ -287,32 +288,18 @@ public static class ZynInstrumentReader
return [];
}
var baseFunction = IntParam(oscillator, "base_function", 0);
if (baseFunction == 0)
if (!NeedsOscilGenWaveformExpansion(oscillator))
{
return harmonicMagnitudes
return oscillator?.Element("HARMONICS")?.Elements("HARMONIC")
.Select(ParsePadDirectHarmonic)
.Where(partial => partial.Gain > 0)
.Take(maxHarmonics)
.ToArray();
}
var baseSpectrum = ZynBaseFunctionSpectrum(
baseFunction,
IntParam(oscillator, "base_function_par", 64),
maxHarmonics);
var gains = new double[maxHarmonics + 1];
foreach (var harmonic in harmonicMagnitudes)
{
var harmonicNumber = (int)Math.Round(harmonic.Ratio);
if (harmonicNumber <= 0) continue;
for (var baseHarmonic = 1; baseHarmonic < baseSpectrum.Length; baseHarmonic++)
{
var target = baseHarmonic * harmonicNumber;
if (target > maxHarmonics) break;
gains[target] += baseSpectrum[baseHarmonic] * harmonic.Gain;
}
.OrderBy(partial => partial.Ratio)
.Take(Math.Min(maxHarmonics, 32))
.ToArray() ?? [];
}
var freqs = ZynOscilGenFrequencies(oscillator, harmonicMagnitudes, maxHarmonics);
var gains = freqs.Select(value => value.Magnitude).ToArray();
ApplyZynSpectrumAdjust(
gains,
IntParam(oscillator, "spectrum_adjust_type", 0),
@ -324,10 +311,250 @@ public static class ZynInstrumentReader
.Select((gain, index) => new { gain, index })
.Where(item => item.index > 0 && Math.Abs(item.gain) > max * 0.0001)
.Select(item => new HarmonicPartial(item.index, (float)(0.16 * Math.Abs(item.gain) / max)))
.Take(maxHarmonics)
.Take(Math.Min(maxHarmonics, 32))
.ToArray();
}
private static bool NeedsOscilGenWaveformExpansion(XElement? oscillator) =>
IntParam(oscillator, "wave_shaping_function", 0) != 0 ||
IntParam(oscillator, "filter_type", 0) != 0 ||
IntParam(oscillator, "modulation", 0) != 0 ||
IntParam(oscillator, "spectrum_adjust_type", 0) != 0 ||
IntParam(oscillator, "harmonic_shift", 0) != 0 ||
IntParam(oscillator, "adaptive_harmonics", 0) != 0;
private static Complex[] ZynOscilGenFrequencies(
XElement? oscillator,
IReadOnlyList<HarmonicPartial> harmonicMagnitudes,
int maxHarmonics)
{
var freqs = Dft(ZynOscilGenBaseSamples(oscillator, harmonicMagnitudes), maxHarmonics);
if (NullableBoolParamValue(oscillator, "harmonic_shift_first") ?? false)
{
ApplyZynHarmonicShift(freqs, IntParam(oscillator, "harmonic_shift", 0));
}
var filterBeforeWaveShaping = IntParam(oscillator, "filter_before_wave_shaping", 0) != 0;
if (filterBeforeWaveShaping)
{
ApplyZynOscilFilter(freqs, oscillator);
freqs = ApplyZynWaveShape(freqs, oscillator, maxHarmonics);
}
else
{
freqs = ApplyZynWaveShape(freqs, oscillator, maxHarmonics);
ApplyZynOscilFilter(freqs, oscillator);
}
freqs = ApplyZynOscilModulation(freqs, oscillator, maxHarmonics);
if (!(NullableBoolParamValue(oscillator, "harmonic_shift_first") ?? false))
{
ApplyZynHarmonicShift(freqs, IntParam(oscillator, "harmonic_shift", 0));
}
return freqs;
}
private static double[] ZynOscilGenBaseSamples(XElement? oscillator, IReadOnlyList<HarmonicPartial> harmonicMagnitudes)
{
const int sampleCount = 4096;
var samples = new double[sampleCount];
var baseFunction = IntParam(oscillator, "base_function", 0);
var parameter = IntParam(oscillator, "base_function_par", 64);
var parameterValue = parameter == 64 ? 0.5 : (parameter + 0.5) / 128.0;
foreach (var harmonic in harmonicMagnitudes)
{
var harmonicNumber = Math.Max(1, (int)Math.Round(harmonic.Ratio));
for (var i = 0; i < sampleCount; i++)
{
var t = i / (double)sampleCount * harmonicNumber;
var sample = baseFunction == 0
? -Math.Sin(Math.Tau * t)
: ZynBaseFunction(baseFunction, t, parameterValue);
samples[i] += sample * harmonic.Gain;
}
}
NormalizePeak(samples);
return samples;
}
private static Complex[] Dft(IReadOnlyList<double> samples, int maxHarmonics)
{
var freqs = new Complex[maxHarmonics + 1];
for (var harmonic = 1; harmonic <= maxHarmonics; harmonic++)
{
var sum = Complex.Zero;
for (var i = 0; i < samples.Count; i++)
{
var angle = -Math.Tau * harmonic * i / samples.Count;
sum += samples[i] * Complex.FromPolarCoordinates(1.0, angle);
}
freqs[harmonic] = sum / samples.Count;
}
return freqs;
}
private static double[] IDft(Complex[] freqs, int sampleCount)
{
var samples = new double[sampleCount];
for (var i = 0; i < sampleCount; i++)
{
var sum = Complex.Zero;
for (var harmonic = 1; harmonic < freqs.Length; harmonic++)
{
var angle = Math.Tau * harmonic * i / sampleCount;
sum += freqs[harmonic] * Complex.FromPolarCoordinates(1.0, angle);
}
samples[i] = 2.0 * sum.Real;
}
return samples;
}
private static void ApplyZynOscilFilter(Complex[] freqs, XElement? oscillator)
{
var type = IntParam(oscillator, "filter_type", 0);
if (type == 0) return;
var par = 1.0 - IntParam(oscillator, "filter_par1", 64) / 128.0;
var par2 = IntParam(oscillator, "filter_par2", 64) / 127.0;
for (var harmonic = 1; harmonic < freqs.Length; harmonic++)
{
freqs[harmonic] *= ZynOscilFilterGain(type, harmonic, par, par2);
}
NormalizeComplexPeak(freqs);
}
private static double ZynOscilFilterGain(int type, int harmonic, double par, double par2)
{
var i = Math.Max(0, harmonic - 1);
return type switch
{
1 => ZynOscilLowPass(i, par, par2),
2 => Math.Pow(1.0 - Math.Pow(1.0 - par * par, i + 1), par2 * 2.0 + 0.1),
6 => (i + 1 > Math.Pow(2.0, (1.0 - par) * 10.0) ? 0.0 : 1.0) * par2 + (1.0 - par2),
7 => (i + 1 > Math.Pow(2.0, (1.0 - par) * 7.0) ? 1.0 : 0.0) * par2 + (1.0 - par2),
13 => i == (int)Math.Pow(2.0, (1.0 - par) * 7.2) ? Math.Pow(2.0, par2 * par2 * 8.0) : 1.0,
_ => 1.0
};
}
private static double ZynOscilLowPass(int i, double par, double par2)
{
var gain = Math.Pow(1.0 - par * par * par * 0.99, i);
var tmp = Math.Pow(par2, 4.0) * 0.5 + 0.0001;
return gain < tmp ? Math.Pow(gain, 10.0) / Math.Pow(tmp, 9.0) : gain;
}
private static Complex[] ApplyZynWaveShape(Complex[] freqs, XElement? oscillator, int maxHarmonics)
{
var type = IntParam(oscillator, "wave_shaping_function", 0);
if (type == 0) return freqs;
var samples = IDft(freqs, 4096);
NormalizePeak(samples);
var drive = IntParam(oscillator, "wave_shaping", 64) / 127.0;
for (var i = 0; i < samples.Length; i++)
{
samples[i] = ZynWaveShapeSample(samples[i], type, drive);
}
return Dft(samples, maxHarmonics);
}
private static double ZynWaveShapeSample(double sample, int type, double drive)
{
return type switch
{
1 => Math.Atan(sample * (Math.Pow(10.0, drive * drive * 3.0) - 1.0 + 0.001)) / Math.Atan(Math.Pow(10.0, drive * drive * 3.0) - 1.0 + 0.001),
4 => ZynSineWaveShape(sample, drive),
7 => Math.Clamp(sample, -Math.Pow(2.0, -drive * drive * 8.0), Math.Pow(2.0, -drive * drive * 8.0)) / Math.Pow(2.0, -drive * drive * 8.0),
11 => sample * (Math.Pow(5.0, drive * drive) - 0.5) * 0.9999 - Math.Floor(0.5 + sample * (Math.Pow(5.0, drive * drive) - 0.5) * 0.9999),
15 => sample * (drive * drive * 35.0 + 1.0) / Math.Pow(1.0 + Math.Pow(Math.Abs(sample * (drive * drive * 35.0 + 1.0)), 2.5), 1.0 / 2.5),
_ => sample
};
}
private static double ZynSineWaveShape(double sample, double drive)
{
var ws = drive * drive * drive * 32.0 + 0.0001;
var divisor = ws < 1.57 ? Math.Sin(ws) : 1.0;
return Math.Sin(sample * ws) / divisor;
}
private static Complex[] ApplyZynOscilModulation(Complex[] freqs, XElement? oscillator, int maxHarmonics)
{
var type = IntParam(oscillator, "modulation", 0);
if (type == 0) return freqs;
var samples = IDft(freqs, 4096);
NormalizePeak(samples);
var output = new double[samples.Length];
var par1 = IntParam(oscillator, "modulation_par1", 64) / 127.0;
var par2 = 0.5 - IntParam(oscillator, "modulation_par2", 64) / 127.0;
var par3 = IntParam(oscillator, "modulation_par3", 32) / 127.0;
(par1, par3) = type switch
{
1 => ((Math.Pow(2.0, par1 * 7.0) - 1.0) / 100.0, Math.Floor(Math.Pow(2.0, par3 * 5.0) - 1.0) < 0.9999 ? -1.0 : Math.Floor(Math.Pow(2.0, par3 * 5.0) - 1.0)),
2 => ((Math.Pow(2.0, par1 * 7.0) - 1.0) / 100.0, 1.0 + Math.Floor(Math.Pow(2.0, par3 * 5.0) - 1.0)),
3 => ((Math.Pow(2.0, par1 * 9.0) - 1.0) / 100.0, 0.01 + (Math.Pow(2.0, par3 * 16.0) - 1.0) / 10.0),
_ => (par1, par3)
};
for (var i = 0; i < output.Length; i++)
{
var t = i / (double)output.Length;
t = type switch
{
1 => t * par3 + Math.Sin((t + par2) * Math.Tau) * par1,
2 => t + Math.Sin((t * par3 + par2) * Math.Tau) * par1,
3 => t + Math.Pow((1.0 - Math.Cos((t + par2) * Math.Tau)) * 0.5, par3) * par1,
_ => t
};
output[i] = InterpolateWrap(samples, t * samples.Length);
}
return Dft(output, maxHarmonics);
}
private static double InterpolateWrap(IReadOnlyList<double> samples, double position)
{
position -= Math.Floor(position / samples.Count) * samples.Count;
var index = (int)Math.Floor(position);
var frac = position - index;
return samples[index] * (1.0 - frac) + samples[(index + 1) % samples.Count] * frac;
}
private static void ApplyZynHarmonicShift(Complex[] freqs, int shift)
{
if (shift == 0) return;
var copy = freqs.ToArray();
Array.Clear(freqs);
for (var i = 1; i < copy.Length; i++)
{
var target = i - shift;
if (target > 0 && target < freqs.Length)
{
freqs[target] = copy[i];
}
}
}
private static void NormalizePeak(double[] samples)
{
var peak = samples.Select(Math.Abs).DefaultIfEmpty(0).Max();
if (peak < 0.00001) peak = 1.0;
for (var i = 0; i < samples.Length; i++)
{
samples[i] /= peak;
}
}
private static void NormalizeComplexPeak(Complex[] freqs)
{
var peak = freqs.Select(value => value.Magnitude).DefaultIfEmpty(0).Max();
if (peak < 0.00001) peak = 1.0;
for (var i = 0; i < freqs.Length; i++)
{
freqs[i] /= peak;
}
}
private static HarmonicPartial ParsePadHarmonicMagnitude(XElement harmonic)
{
var id = IntAttribute(harmonic, "id");
@ -342,32 +569,12 @@ public static class ZynInstrumentReader
return new HarmonicPartial(ratio, gain);
}
private static double[] ZynBaseFunctionSpectrum(int baseFunction, int parameter, int maxHarmonics)
private static HarmonicPartial ParsePadDirectHarmonic(XElement harmonic)
{
const int sampleCount = 4096;
var parameterValue = parameter == 64 ? 0.5 : (parameter + 0.5) / 128.0;
var magnitudes = new double[maxHarmonics + 1];
for (var harmonic = 1; harmonic <= maxHarmonics; harmonic++)
{
var real = 0.0;
var imaginary = 0.0;
for (var i = 0; i < sampleCount; i++)
{
var t = i / (double)sampleCount;
var sample = ZynBaseFunction(baseFunction, t, parameterValue);
var angle = -Math.Tau * harmonic * t;
real += sample * Math.Cos(angle);
imaginary += sample * Math.Sin(angle);
}
magnitudes[harmonic] = Math.Sqrt(real * real + imaginary * imaginary) / sampleCount;
}
var max = magnitudes.DefaultIfEmpty(0).Max();
if (max > 0)
{
for (var i = 0; i < magnitudes.Length; i++) magnitudes[i] /= max;
}
return magnitudes;
var id = IntAttribute(harmonic, "id");
var ratio = Math.Max(1, id);
var magnitude = IntParam(harmonic, "mag", 0);
return new HarmonicPartial(ratio, 0.16f * Math.Clamp(magnitude / 127f, 0, 1));
}
private static double ZynBaseFunction(int function, double x, double a)

View file

@ -76,3 +76,4 @@
{"date":"2026-05-15","area":"zyn-pad-translation","lesson":"Zyn PAD artifacts now use per-preset generated `.aqua` instead of the generic baseline. `ZynInstrumentReader.RebuildFirstPadAsAquariumScript` translates the first enabled PAD kit item into one named PAD layer plus a `spectrum` bank using OSCIL harmonic magnitudes, Zyn oracle sample `basefreq` as table root, PAD volume as layer gain, and simple rate/level envelope scaffolding. Reports explicitly list missing Zyn semantics: bandwidth/profile spreading, harmonic profile shaping, and filter envelope/LFO lowering.","evidence":"ZynInstrumentTests.RebuildsProjectAuthoredPadAsAquariumSpectrumScript plus focused Zyn tests passed 13/13; latest upstream PAD reports include generated candidate scripts and log-mel scores from ~0.276 to ~0.637."}
{"date":"2026-05-15","topic":"zyn-pad-profile-table-semantics","lesson":"Zyn PAD bandwidth/profile/position fields belong on SpectralBank as table-generation data, not in parity side channels. Mapping those fields and Zyn's exponential PAD volume curve improved gain/envelope evidence, but profile math alone did not solve log-mel parity.","evidence":"Focused Zyn tests passed 13/13 after adding PadSpectrumProfile and zyn_* script fields; upstream PAD batch still has log-mel roughly 0.34-0.69 depending on fixture."}
{"date":"2026-05-15","topic":"zyn-oscilgen-approximation","lesson":"Expanding a subset of Zyn OscilGen base_function plus spectrum_adjust is conceptually the right owner for one-harmonic PAD presets, but the first approximation is mixed and must not be treated as solved parity.","evidence":"Soft Pad log-mel improved to 0.342738 and Organ Choir Pad2 to 0.573216, while sin2x pad regressed to 0.524421, DoublePadBass to 0.689315, and Ghost Ensemble to 0.482155. Next cut needs more OscilGen semantics or gating."}
{"date":"2026-05-15","topic":"zyn-oscilgen-stage-order","lesson":"Zyn OscilGen partial extraction should only run the sampled waveform pipeline when oscillator processing stages are live; otherwise direct harmonics stay readable and capped. A broad base-function expansion made some presets worse without earning its complexity.","evidence":"After adding sampled filter/waveshape/modulation stage order and restoring a capped direct path, sin2x pad improved to log-mel 0.455193, while Soft Pad stayed 0.342738 and Ghost stayed 0.40922. DoublePadBass and Organ Choir remain poor, so this is an incremental rung, not parity."}

View file

@ -86,7 +86,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. Zyn PAD `spectrum` banks can now carry explicit `zyn_mode`, `zyn_bandwidth`, `zyn_bwscale`, `zyn_profile`, and `zyn_position` table-generation fields, and `PadSynthWaveform` lowers those through Zyn-style profile/bandwidth math. The pinned Zyn source now builds as a test-only MSYS2/MinGW oracle through `tools/zyn-reference/build-zyn-reference.ps1`, which patches an ignored artifact source copy, proves `PadNoteTest`, and builds `ZynPadReference`.",
"lesson": "Start Zyn as structural classification, then move into explicit scoped rebuilds. Upstream Zyn presets live in a nested GPL instrument-bank submodule and may be used as test-only parity/pressure feedstock once provenance is recorded. The ZynAddSubFX engine source is pinned as GPL test/development material under `external/zynaddsubfx` at 3ab608c432996ba4d582176572c0b0f82328c825; use it as a parity oracle only, never as Aquarium runtime or NuGet package content. Zyn build fixes belong in `tools/zyn-reference/patches/` and apply to ignored artifact copies, not the clean pinned submodule. The PAD oracle now renders both generated PAD sample tables and note audio through Zyn `PADnoteParameters`/`PADnote`; optional parity artifacts live under `artifacts/parity/zyn-pad-reference` and `artifacts/parity/zyn-pad-fixtures`. The first project-authored PAD comparison proved the harness and showed Aquarium `spectrum` was not a close Zyn PAD emulation. User listening identified Zyn as pure wineglass and Aquarium as angry bees. Cutting unearned air/noise/drive/soft-clip from the rebuild, adding interpolated wavetable reads, and raising Aquarium spectral tables to Zyn's 131072-frame PAD size improved the report. A later pitch/drone pass split PAD table root from playback note frequency: `spectrum root=...` owns table/base frequency and `freq=...` owns the played note. Using Zyn's reported sample basefreq 77.7813 while playing 261.6256, removing gratuitous spread/vibrato, collapsing two same-note PAD voices into one coherent spectral table, and trimming gain improved the report to log-mel 0.35669, envelope 0.347572, RMS ratio 1.001704, centroid ratio 1.004072, score 0.6211. The first PAD fixture survey shows `pad-pure` and the old `pad-texture` render identically through Zyn, so the filter/category XML in `pad-texture` is not live pressure for this oracle path. `pad-harmonic` changes the source table and note output through explicit OSCIL harmonics, making it the next real PAD reference rung. The remaining gap is source-table/profile and envelope semantics, not more decorative patch layers. The report now also emits source-table WAVs for listening. The hardened reader parses the upstream GPL instrument bank at commit c5c912131b31df5fdf372d2f06a25aaf2375837f: 1318/1318 .xiz files; optional tests write a ranked report and render five upstream PAD listening fixtures under ignored artifacts. `RebuildFirstPadAsAquariumScript` now generates first-pass per-preset PAD `.aqua` candidates from the first enabled PAD kit item, OSCIL harmonic magnitudes, oracle-reported table base frequency, basic volume, and simple rate/level envelope scaffolding while reporting missing bandwidth/profile/filter semantics. 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 named layers and generated table base frequency; playback `freq` remains note authority. 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": "Complete or cut the first OscilGen base-function/spectrum-adjust approximation. It improves some PAD fixtures but regresses others, so do not claim Zyn PAD parity until modulation, waveshaping, oscillator filtering, adaptive harmonics, and global filter/envelope ownership are mapped or deliberately excluded."
"next": "Continue Zyn PAD parity from the sampled OscilGen stage-order path. It now handles live oscillator processing more coherently and keeps direct harmonic presets capped/readable, but DoublePadBass and Organ Choir Pad2 still miss badly. Do not claim Zyn PAD parity until broader OscilGen filtering/waveshaping/adaptive-harmonic behavior and note/global filter/envelope ownership are mapped or deliberately excluded."
},
"note_and_envelope_model": {
"rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.",

View file

@ -49,6 +49,28 @@ Current slice:
approximation to the cases it truly owns. Do not present the mixed OscilGen
subset as passing parity.
Current follow-up:
- Reworked Zyn PAD OscilGen partial extraction into a sampled stage-order
pipeline for presets with live oscillator processing: base waveform,
optional oscillator filter/waveshaper order, modulation, harmonic shift, then
spectrum adjustment. The implementation covers a useful subset of oscillator
filter types and waveshapers instead of pretending base-function convolution
alone is the synth.
- Gated that pipeline so direct harmonic presets remain readable and capped at
32 partials. This cuts the earlier mistake where base-function-only presets
grew huge partial lists without improving parity.
- Latest upstream PAD batch after this pass:
`sin2x pad` log-mel `0.455193`, score `0.405637` (better than the previous
OscilGen subset's `0.524421`);
`Soft Pad` log-mel `0.342738`, score `0.443083`;
`Ghost Ensemble` log-mel `0.40922`, score `0.421156`;
`Organ Choir Pad2` log-mel `0.664699`, score `0.369231`;
`DoublePadBass` log-mel `0.655288`, score `0.246664`.
- This is still not passing Zyn PAD parity. The likely next missing owners are
full OscilGen filter/waveshape coverage, adaptive harmonics, and note/global
filter/envelope behavior. Do not tune thresholds around these numbers.
Completed this slice:
- Added PAD spectral-cloud syntax attached to named layers:

View file

@ -168,6 +168,18 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-15
summary: >
Reworked the first Zyn OscilGen approximation into a sampled stage-order
path for PAD presets with live oscillator processing: base waveform,
optional oscillator filter/waveshaper order, modulation, harmonic shift,
and spectrum adjustment before extracting partials. Direct harmonic PAD
presets now stay on the simple readable path capped at 32 partials
instead of exploding into base-function-only frequency combs. This
improves the processed `sin2x pad` target versus the previous subset
while keeping the pass honest: DoublePadBass and Organ Choir Pad2 still
miss badly, so Zyn PAD parity remains open pressure.
verification: "dotnet test tests\\AquariumSynth.Dsl.Tests\\AquariumSynth.Dsl.Tests.csproj --no-restore --filter FullyQualifiedName~ZynReferenceParityTests: 4 passed"
- date: 2026-05-15
summary: >
Promoted explicit Zyn PAD table semantics into the Aquarium model and