Map Zyn PAD detune and filter order

This commit is contained in:
Meta Vens 2026-05-15 12:19:04 +01:00
commit 01eea5eccf
10 changed files with 145 additions and 4 deletions

View file

@ -224,9 +224,10 @@ public static class FaustEmitter
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 lowPassOrder = Math.Clamp(voice.Filter.LowPassOrder, 1, 12);
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))";
: $"fi.lowpass({lowPassOrder}, max(20.0, {lpf} * 18000.0))";
source.AppendLine($"{name}_freq = {frequency};");
source.AppendLine($"{name}_osc = {oscillator};");

View file

@ -105,6 +105,7 @@ public sealed record Filter(
float LowPass = 1,
float LowPassRamp = 0,
float LowPassResonance = 0,
int LowPassOrder = 1,
float HighPass = 0,
float HighPassRamp = 0);

View file

@ -441,6 +441,7 @@ public static class PatchScript
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"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/lpf_order"), "lpf_order", "lpfo"),
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(
@ -707,6 +708,24 @@ public static class PatchScript
params string[] keys) =>
TryGetAny(fields, keys, out var value) ? ParseBoundFloat(value, line, fallback, fieldPath) : fallback;
private int GetBoundInt(
IReadOnlyDictionary<string, string> fields,
int line,
int fallback,
string fieldPath,
params string[] keys)
{
if (!TryGetAny(fields, keys, out var value))
{
return fallback;
}
if (value.StartsWith('@'))
{
throw new PatchScriptException(line, $"parameter binding is not supported for integer field `{fieldPath}`");
}
return ParseInt(value, line);
}
private float ParseBoundFloat(string value, int line, float fallback, string fieldPath)
{
if (!value.StartsWith('@'))

View file

@ -187,6 +187,7 @@ public sealed record SfxrParams
Math.Clamp(LpfFreq, 0, 1),
Math.Clamp(LpfRamp, -1, 1),
Math.Clamp(LpfResonance, 0, 1),
1,
Math.Clamp(HpfFreq, 0, 1),
Math.Clamp(HpfRamp, -1, 1)),
Phaser = new Phaser(

View file

@ -249,6 +249,8 @@ public static class ZynInstrumentReader
MathF.Pow(0.1f, 3.0f * (1.0f - volume / 96.0f)),
0.001f,
1.5f);
var frequency = pad.Element("FREQUENCY_PARAMETERS");
var noteFrequencyHz = ZynPadPlaybackFrequency(frequency, playbackFrequencyHz);
var attack = EnvelopeTime(envelope, "A_dt", 0.28f);
var decay = EnvelopeTime(envelope, "D_dt", 0.42f);
var release = EnvelopeTime(envelope, "R_dt", 1.2f);
@ -259,6 +261,7 @@ public static class ZynInstrumentReader
var position = ZynPositionText(pad.Element("HARMONIC_POSITION"));
var filter = pad.Element("FILTER_PARAMETERS");
var lowPass = ZynPadLowPass(filter);
var lowPassOrder = ZynPadLowPassOrder(filter);
var safeName = includeKitSuffix ? $"{safeBaseName}_{kitId}" : safeBaseName;
var partialText = string.Join(",", harmonics.Select(partial => $"{F(partial.Ratio)}:{F(partial.Gain)}"));
@ -267,6 +270,7 @@ public static class ZynInstrumentReader
matched.Add(new($"{featurePrefix}oscillator_base_function", IntParam(oscillator, "base_function", 0).ToString(CultureInfo.InvariantCulture), "Expanded Zyn OscilGen base function into harmonic partials before PAD synthesis."));
matched.Add(new($"{featurePrefix}oscillator_spectrum_adjust", $"{IntParam(oscillator, "spectrum_adjust_type", 0)}:{IntParam(oscillator, "spectrum_adjust_par", 64)}", "Applied Zyn OscilGen spectrum adjustment to generated harmonic partials."));
matched.Add(new($"{featurePrefix}table_root", F(tableRootFrequencyHz), "Root frequency comes from the Zyn oracle generated sample basefreq."));
matched.Add(new($"{featurePrefix}note_frequency", F(noteFrequencyHz), "Mapped Zyn PAD coarse/fine detune into Aquarium table playback frequency."));
matched.Add(new($"{featurePrefix}volume", volume.ToString(CultureInfo.InvariantCulture), "Mapped PAD amplitude volume into Aquarium layer gain."));
matched.Add(new($"{featurePrefix}bandwidth", bandwidth.ToString(CultureInfo.InvariantCulture), "Mapped Zyn PAD bandwidth into Aquarium spectral table generation."));
matched.Add(new($"{featurePrefix}bandwidth_scale", bandwidthScale.ToString(CultureInfo.InvariantCulture), "Mapped Zyn PAD bandwidth scaling exponent selection."));
@ -275,14 +279,15 @@ public static class ZynInstrumentReader
if (lowPass < 1)
{
matched.Add(new($"{featurePrefix}filter_lpf", F(lowPass), "Mapped static Zyn PAD global low-pass frequency into Aquarium layer filtering."));
matched.Add(new($"{featurePrefix}filter_lpf_order", lowPassOrder.ToString(CultureInfo.InvariantCulture), "Mapped Zyn analog LP1/LP2 and stage count into Aquarium low-pass order."));
}
if (filter is not null)
{
missing.Add(new($"{featurePrefix}filter_modulation", "present", "Filter envelope/LFO lowering remains separate pressure."));
}
script.AppendLine($"layer name={safeName} engine=pad gain={F(layerGain)} lpf={F(lowPass)} env=rl rates={F(attack)},{F(decay)},1,{F(release)} levels=1,1,1,0 curves=lin,lin,lin,lin gate=1.5");
script.AppendLine($"spectrum layer={safeName} root={F(tableRootFrequencyHz)} freq={F(playbackFrequencyHz)} spread=0 zyn_mode={mode} zyn_bandwidth={bandwidth} zyn_bwscale={bandwidthScale} zyn_profile={profile} zyn_position={position} partials={partialText}");
script.AppendLine($"layer name={safeName} engine=pad gain={F(layerGain)} lpf={F(lowPass)} lpf_order={lowPassOrder} env=rl rates={F(attack)},{F(decay)},1,{F(release)} levels=1,1,1,0 curves=lin,lin,lin,lin gate=1.5");
script.AppendLine($"spectrum layer={safeName} root={F(tableRootFrequencyHz)} freq={F(noteFrequencyHz)} spread=0 zyn_mode={mode} zyn_bandwidth={bandwidth} zyn_bwscale={bandwidthScale} zyn_profile={profile} zyn_position={position} partials={partialText}");
}
private static ZynKitItem ParseKitItem(XElement item)
@ -825,6 +830,87 @@ public static class ZynInstrumentReader
return Math.Clamp(cutoffHz / 18000.0f, 20.0f / 18000.0f, 1f);
}
private static int ZynPadLowPassOrder(XElement? filter)
{
if (filter is null)
{
return 1;
}
var category = DescendantIntParam(filter, "category", 0);
var type = DescendantIntParam(filter, "type", 0);
if (category != 0)
{
return 1;
}
var poles = type switch
{
1 => 1,
2 => 2,
_ => 1
};
return Math.Clamp(poles * (DescendantIntParam(filter, "stages", 0) + 1), 1, 12);
}
private static float ZynPadPlaybackFrequency(XElement? frequency, float playbackFrequencyHz)
{
var detuneCents = ZynDetuneCents(
IntParam(frequency, "detune_type", 1),
IntParam(frequency, "coarse_detune", 0),
IntParam(frequency, "detune", 8192));
return playbackFrequencyHz * MathF.Pow(2.0f, detuneCents / 1200.0f);
}
private static float ZynDetuneCents(int type, int coarseDetune, int fineDetune)
{
var octave = coarseDetune / 1024;
if (octave >= 8)
{
octave -= 16;
}
var coarse = coarseDetune % 1024;
if (coarse > 512)
{
coarse -= 1024;
}
var fine = fineDetune - 8192;
float coarseCents;
float fineCents;
switch (type)
{
case 2:
coarseCents = MathF.Abs(coarse * 10.0f);
fineCents = MathF.Abs(fine / 8192.0f) * 10.0f;
break;
case 3:
coarseCents = MathF.Abs(coarse * 100.0f);
fineCents = MathF.Pow(10.0f, MathF.Abs(fine / 8192.0f) * 3.0f) / 10.0f - 0.1f;
break;
case 4:
coarseCents = MathF.Abs(coarse * 701.95500087f);
fineCents = (MathF.Pow(2.0f, MathF.Abs(fine / 8192.0f) * 12.0f) - 1.0f) / 4095.0f * 1200.0f;
break;
default:
coarseCents = MathF.Abs(coarse * 50.0f);
fineCents = MathF.Abs(fine / 8192.0f) * 35.0f;
break;
}
if (fineDetune < 8192)
{
fineCents = -fineCents;
}
if (coarse < 0)
{
coarseCents = -coarseCents;
}
return octave * 1200.0f + coarseCents + fineCents;
}
private static int IntParam(XElement? root, string name, int fallback)
{
var value = root?.Elements("par")

View file

@ -80,3 +80,4 @@
{"date":"2026-05-15","topic":"zyn-pad-kit-layering","lesson":"Do not treat first enabled PAD kit item as a Zyn instrument when kit_mode is multi-kit. Multi-kit PAD presets need per-kit table roots and layered Aqua spectrum banks, or parity artifacts compare against an amputated preset.","evidence":"DoublePadBass has kit_mode=1 with enabled PAD kit items 0 and 1. Rebuilding/summing both moved log-mel from about 0.655 to 0.493 and generated doublepadbass_0 plus doublepadbass_1. Raising the volume cap to 3.0 worsened log-mel to 0.521 and was cut."}
{"date":"2026-05-15","topic":"zyn-ghost-base-function","lesson":"A direct-harmonic readability gate must not erase source identity. Ghost Ensemble needed power-base OscilGen expansion even without downstream oscillator stages; otherwise it collapsed toward a generic two-partial pad.","evidence":"Ghost Ensemble has base_function=4 with two explicit harmonics. Expanding power-base multi-harmonic sources changed the candidate from two partials to 32 partials and improved score to 0.446977, with log-mel 0.402789."}
{"date":"2026-05-15","topic":"zyn-pad-static-filter","lesson":"Zyn PAD static global filter frequency is audible layer-balance authority, not optional decoration. Read nested FILTER params under FILTER_PARAMETERS and lower old Zyn cutoff values through the exponential FilterParams curve, not a linear freq/127 guess.","evidence":"DoublePadBass kit0 filter freq=12 and kit1 freq=61. Linear mapping gave lpf=0.094488/0.480315 and still sounded like all kazoo. Zyn's curve maps them to lpf=0.003325/0.047225, improving log-mel to 0.334546 and making the generated patch deep-bass-first. Filter envelopes/LFOs and level recovery remain missing pressure."}
{"date":"2026-05-15","topic":"zyn-pad-detune-and-filter-order","lesson":"Zyn PAD note frequency is not just the requested note plus table root; FREQUENCY_PARAMETERS coarse/fine detune are live playback authority, and analog LP2 needs a steeper low-pass than Aquarium's old one-pole default.","evidence":"DoublePadBass has coarse_detune=15360, which Zyn getdetune wraps from octave 15 to -1 octave. Emitting spectrum freq=130.8128 instead of 261.6256 and lpf_order=2 moved DoublePadBass log-mel from 0.334546 after static cutoff mapping to 0.259298; remaining gap is RMS/envelope."}

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": "Continue Zyn PAD parity from the sampled OscilGen stage-order, multi-kit PAD, and Zyn-curve static global-filter paths. Ghost Ensemble now expands its power-base multi-harmonic OscilGen source instead of collapsing into a generic two-partial pad, and DoublePadBass now rebuilds both enabled PAD kit items with per-kit static low-pass filters using Zyn's exponential old-cutoff curve. DoublePadBass is now deep-bass-first but under-loud; remaining pressure is filter envelope/level recovery. Do not claim Zyn PAD parity until broader OscilGen filtering/waveshaping/adaptive-harmonic behavior and note/global filter envelope/LFO ownership are mapped or deliberately excluded."
"next": "Continue Zyn PAD parity from the sampled OscilGen stage-order, multi-kit PAD, Zyn-curve static global-filter, low-pass-order, and PAD frequency-detune paths. Ghost Ensemble now expands its power-base multi-harmonic OscilGen source instead of collapsing into a generic two-partial pad. DoublePadBass now rebuilds both enabled PAD kit items, maps Zyn analog LP2 to `lpf_order=2`, and applies Zyn `getdetune` semantics so `coarse_detune=15360` plays one octave down (`freq=130.8128` instead of `261.6256`). Latest DoublePadBass log-mel is 0.259298; remaining pressure is mostly loudness/envelope recovery, especially filter envelope/LFO behavior. Do not claim Zyn PAD parity until broader OscilGen filtering/waveshaping/adaptive-harmonic behavior and note/global filter envelope/LFO 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

@ -105,6 +105,15 @@ Current user-ear correction:
`doublepadbass_0 lpf=0.003325` and `doublepadbass_1 lpf=0.047225`. The
character should now be deep-bass-first; remaining pressure is filter
envelope/level recovery, not more static high-band brightness.
- User still heard Aqua as kazoo-first. The next missing authority was not
brightness but pitch: DoublePadBass PAD frequency params set
`coarse_detune=15360`, and Zyn's `getdetune` wraps octave `15` to `-1`.
Aqua had ignored PAD coarse/fine detune and played both spectral tables at
`261.6256 Hz`; it now emits `freq=130.8128` for both enabled kit layers.
- The same pass added explicit `lpf_order` so Zyn analog `LP2` filters lower to
Faust `fi.lowpass(2, ...)` instead of pretending all low-passes are first
order. Latest DoublePadBass: log-mel `0.259298`, score `0.370218`, centroid
ratio `1.027115`; remaining pressure is loudness/envelope recovery.
Completed this slice:

View file

@ -168,6 +168,19 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-15
summary: >
Mapped two missing Zyn PAD authorities exposed by DoublePadBass listening:
analog low-pass slope and PAD frequency detune. Aquarium now has a general
`lpf_order` field which emits Faust `fi.lowpass(order, ...)`; Zyn analog
`LP2` filters lower to `lpf_order=2` and include Zyn stage count. The Zyn
PAD importer also applies Zyn `getdetune` semantics to
`FREQUENCY_PARAMETERS`, so DoublePadBass `coarse_detune=15360` wraps to
one octave down and generated `spectrum` playback moves from `261.6256`
to `130.8128`. DoublePadBass log-mel moved from `0.334546` after static
cutoff mapping to `0.259298`; the remaining gap is mostly envelope/RMS,
not wrong pitch or high-band balance.
verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 103 passed"
- date: 2026-05-15
summary: >
Mapped static Zyn PAD global low-pass filters into generated Aqua layers

View file

@ -656,6 +656,16 @@ public sealed class PatchScriptTests
Assert.Empty(export.Warnings);
}
[Fact]
public void VoiceLowPassOrderSelectsFaustFilterOrder()
{
var patch = PatchScript.Parse("v w=saw f=80 lpf=.1 lpf_order=2");
var export = FaustEmitter.Emit(patch);
Assert.Equal(2, patch.Voices[0].Filter.LowPassOrder);
Assert.Contains("fi.lowpass(2, max(20.0,", export.Source);
}
[Fact]
public void VoiceEnvelopeUsesStandardAdsrAndNoteGate()
{