Preserve Zyn PAD filter slope with Q

This commit is contained in:
Meta Vens 2026-05-15 14:45:16 +01:00
commit ee67865c31
8 changed files with 72 additions and 9 deletions

View file

@ -235,7 +235,7 @@ public static class FaustEmitter
var lowPassQ = parameters.Expression(OwnerField(ownerPath, "filter/lpf_q"), voice.Filter.LowPassQ);
var lowPassOrder = Math.Clamp(voice.Filter.LowPassOrder, 1, 12);
var lowpass = hasExplicitQ
? $"fi.resonlp(max(20.0, {lpf} * 18000.0), max(0.1, {lowPassQ}), 1.0)"
? ResonantLowPassCascade($"max(20.0, {lpf} * 18000.0)", $"max(0.1, {lowPassQ})", lowPassOrder)
: hasResonance
? $"fi.resonlp(max(20.0, {lpf} * 18000.0), 0.7 + clip01({resonance}) * 18.0, 1.0)"
: $"fi.lowpass({lowPassOrder}, max(20.0, {lpf} * 18000.0))";
@ -262,6 +262,12 @@ public static class FaustEmitter
source.AppendLine();
}
private static string ResonantLowPassCascade(string cutoff, string q, int order)
{
var stages = Math.Max(1, (order + 1) / 2);
return string.Join(" : ", Enumerable.Repeat($"fi.resonlp({cutoff}, {q}, 1.0)", stages));
}
private static void EmitSpectralBank(
StringBuilder source,
SynthPatch patch,

View file

@ -140,7 +140,7 @@ internal static class PadSynthWaveform
for (var i = 0; i < profileSize * superSample; i++)
{
var x = (i + 0.5) / (profileSize * superSample);
var x = i / (double)(profileSize * superSample);
x = (x - 0.5) * width + 0.5;
if (x is < 0 or > 1)
{

View file

@ -84,3 +84,4 @@
{"date":"2026-05-15","topic":"zyn-pad-filter-envelope","lesson":"Zyn PAD FILTER_ENVELOPE is audible transient authority. Static cutoff parity can get the tone right while still missing the opening hit; Aquarium needs explicit low-pass cutoff envelopes with nonzero start levels.","evidence":"DoublePadBass sounded tonally right but softer at onset. Mapping Zyn ADSR-filter octave offsets into lpf_env=rl with lpf_start improved log-mel from 0.259298 to 0.241993, envelope distance from 0.631427 to 0.554658, RMS ratio from 0.390177 to 0.456173, and score from 0.370218 to 0.404382."}
{"date":"2026-05-15","topic":"zyn-pad-output-calibration","lesson":"After PAD table roots, detune, filter order, filter envelope, and logarithmic envelope times are mapped, remaining Zyn PAD loudness can be a generated-rebuild output calibration rather than another per-layer fixture tweak.","evidence":"Current PAD fixture RMS ratios before calibration were consistently low: DoublePadBass 0.511669, Soft Pad 0.619347, Organ Choir 0.588127, Ghost 0.730155, sin2x 0.775653. Emitting patch gain=1.6 moved DoublePadBass candidate WAV peak to 0.979 against clipped Zyn reference peak 1.0, envelope distance to 0.226563, RMS ratio to 0.818669, and score to 0.631168 while keeping batch RMS plausible."}
{"date":"2026-05-15","topic":"zyn-pad-filter-q","lesson":"Zyn PAD analog filter Q is real filter damping authority and belongs as a general Aquarium filter surface (`lpf_q`), not as a fixture-specific cutoff/gain fudge.","evidence":"Ghost Ensemble has FILTER freq_track=97 and q=28. Adding note-tracked cutoff lowered Ghost lpf from 0.369706 to 0.282776 and adding explicit lpf_q=0.499021 moved log-mel from 0.38424 to 0.375637 and RMS ratio from 1.155288 to 1.09995. DoublePadBass remained acceptable and improved to log-mel 0.247729 / score 0.650069 after Q."}
{"date":"2026-05-15","topic":"zyn-pad-q-order-cascade","lesson":"Explicit low-pass Q must not erase low-pass order. When a reference filter has both damping and slope, Faust lowering should cascade resonant stages according to `lpf_order` rather than replacing the filter with one generic resonant low-pass.","evidence":"Ghost Ensemble still sounded bright in the middle after lpf_q. Cascading resonlp stages for lpf_order=4 moved Ghost log-mel from 0.375637 to 0.369402 and RMS ratio from 1.09995 to 1.054875 while keeping DoublePadBass near its accepted score: log-mel 0.247748, score 0.650029. ZynPadReference harmonic dumps showed Ghost's first 32 imported OscilGen partial ratios already matched Zyn, so this was filter slope, not first-page harmonic extraction."}

View file

@ -87,7 +87,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, Zyn-curve static global-filter, low-pass-order, PAD frequency-detune, low-pass filter-envelope, logarithmic Zyn envelope-time, generated Zyn PAD output-calibration, low-pass note tracking, and explicit low-pass Q paths. Ghost Ensemble now expands its power-base multi-harmonic OscilGen source, applies Zyn filter note tracking from `freq_track`, and emits `lpf_q` from the Zyn analog filter `q`; latest Ghost log-mel is 0.375637, envelope distance is 0.363075, RMS ratio is 1.09995, centroid ratio is 1.010727, and score is 0.561458. DoublePadBass is accepted by ear and latest survey after Q reports log-mel 0.247729, envelope distance 0.208429, RMS ratio 0.887621, score 0.650069. Remaining Ghost pressure is a hot higher-partial source-table tail, so look next at OscilGen/PAD harmonic-table shape rather than more static filter knobs. 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, PAD frequency-detune, low-pass filter-envelope, logarithmic Zyn envelope-time, generated Zyn PAD output-calibration, low-pass note tracking, explicit low-pass Q, and Q+order cascade paths. Ghost Ensemble now expands its power-base multi-harmonic OscilGen source, applies Zyn filter note tracking from `freq_track`, emits `lpf_q` from the Zyn analog filter `q`, and preserves low-pass slope by cascading resonant Faust stages according to `lpf_order`; latest Ghost log-mel is 0.369402, envelope distance is 0.363685, RMS ratio is 1.054875, centroid ratio is 1.006808, and score is 0.57229. DoublePadBass is accepted by ear and latest survey after Q/order cascade reports log-mel 0.247748, envelope distance 0.208433, RMS ratio 0.887555, score 0.650029. ZynPadReference now has a test-only `harmonics` mode; Ghost's first 32 imported harmonic ratios match Zyn's OscilGen output, so the remaining Ghost pressure is not first-page harmonic extraction. A 1024-sample OscilGen table experiment improved Ghost but regressed DoublePadBass and was cut; a 64-partial expansion worsened Ghost and was cut. 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

@ -159,6 +159,27 @@ Current user-ear correction:
- Latest upstream PAD survey after Q kept the accepted batch intact:
DoublePadBass log-mel `0.247729`, envelope `0.208429`, RMS ratio `0.887621`,
score `0.650069`; Soft Pad log-mel `0.316754`, score `0.6089`.
- User then heard Ghost as closer but still overly bright in the middle. A
Zyn oracle `harmonics` diagnostic was added to `ZynPadReference` so the
exact normalized OscilGen harmonic vector can be dumped before PAD table
generation. That proved Ghost's first 32 imported partial ratios already
match Zyn's OscilGen output; the h8/h10 glare was not a first-page harmonic
extraction bug.
- Two tempting cuts were tested and rejected or narrowed:
using Zyn's 1024-sample OscilGen base table helped Ghost but regressed the
accepted DoublePadBass batch, so it was cut; raising generated OscilGen
partial output from 32 to 64 worsened Ghost, so the readable 32-partial cap
remains.
- The real Ghost middle-brightness fix was filter lowering ownership: explicit
`lpf_q` had accidentally replaced a 4-pole Zyn-style low-pass with a single
Faust `resonlp`, preserving Q but throwing away slope. Faust export now
cascades resonant low-pass stages according to `lpf_order` when `lpf_q` is
present. Latest Ghost:
artifact `artifacts/parity/zyn-upstream-pad-fixtures/0121-ghost-ensemble-20260515T134050872`,
log-mel `0.369402`, envelope distance `0.363685`, RMS ratio `1.054875`,
centroid ratio `1.006808`, score `0.57229`. DoublePadBass remains at
log-mel `0.247748`, envelope `0.208433`, RMS ratio `0.887555`, score
`0.650029`.
Completed this slice:

View file

@ -1,6 +1,6 @@
project: AquariumSynthCSharp
spine_version: 1
updated: 2026-05-15T13:16:00+01:00
updated: 2026-05-15T13:45:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -81,7 +81,7 @@ live_invariants:
- 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 and split table root from playback pitch: `root` is source/base frequency, while `freq` is the note played by the spectral voice. They generate PADsynth-style FFT wavetables with frequency-domain spreading and deterministic random phase, then emit Faust table readers before ordinary voice treatment. Zyn-fed PAD banks may additionally carry `zyn_mode`, `zyn_bandwidth`, `zyn_bwscale`, `zyn_profile`, and `zyn_position`; those fields belong to the spectral table generator, not to parity-test side channels. Do not pretend this covers every Zyn OscilGen modulation/waveshaping/filter/adaptive-harmonic path yet.
- Filter damping is first-class: `lpf_q`/`lpq` owns explicit low-pass Q when a reference synth exposes analog filter damping. Do not fake Q by secretly moving cutoff, slope, or layer gain.
- Filter damping is first-class: `lpf_q`/`lpq` owns explicit low-pass Q when a reference synth exposes analog filter damping. Low-pass order remains slope authority even when Q is present; Faust lowering cascades resonant low-pass stages instead of replacing order with one generic resonant filter. Do not fake Q by secretly moving cutoff, slope, or layer gain.
- 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.
@ -103,7 +103,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` PADsynth-style FFT wavetable banks attached to layers, and PAD/vocal rebuilds use staged voice `env=rl` envelopes through layer defaults. `ZynInstrumentReader.RebuildFirstPadAsAquariumScript` now emits per-preset PAD `.aqua` rebuilds for enabled PAD kit items with OSCIL harmonic expansion, oracle-reported table base frequency, PAD detune, Zyn PAD profile/bandwidth/position semantics, static/filter-envelope low-pass mapping, low-pass note tracking, explicit low-pass Q, logarithmic Zyn envelope times, and calibrated PAD output gain. ZynAddSubFX source is pinned under `external/zynaddsubfx` at 3ab608c432996ba4d582176572c0b0f82328c825 for test-only parity work, and `tools/zyn-reference/build-zyn-reference.ps1` now builds an ignored patched source copy with MSYS2/MinGW, proves `PadNoteTest`, and builds `ZynPadReference`. The optional PAD reports emit note and source-table WAV/listening artifacts. Latest accepted upstream PAD pressure has DoublePadBass close enough by ear with log-mel 0.247729 / score 0.650069 after filter Q, while Ghost Ensemble improves to log-mel 0.375637 / score 0.561458 but still shows a hot higher-partial source-table tail. The upstream GPL instrument bank is initialized at `external/zynaddsubfx/instruments` commit c5c912131b31df5fdf372d2f06a25aaf2375837f; survey tests parse 1318/1318 .xiz files, write a pressure report, and render a five-patch upstream PAD listening batch under ignored artifacts."
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. `ZynInstrumentReader.RebuildFirstPadAsAquariumScript` now emits per-preset PAD `.aqua` rebuilds for enabled PAD kit items with OSCIL harmonic expansion, oracle-reported table base frequency, PAD detune, Zyn PAD profile/bandwidth/position semantics, static/filter-envelope low-pass mapping, low-pass note tracking, explicit low-pass Q, logarithmic Zyn envelope times, and calibrated PAD output gain. ZynAddSubFX source is pinned under `external/zynaddsubfx` at 3ab608c432996ba4d582176572c0b0f82328c825 for test-only parity work, and `tools/zyn-reference/build-zyn-reference.ps1` now builds an ignored patched source copy with MSYS2/MinGW, proves `PadNoteTest`, and builds `ZynPadReference`, including a test-only `harmonics` mode for dumping normalized OscilGen amplitudes before PAD generation. The optional PAD reports emit note and source-table WAV/listening artifacts. Latest accepted upstream PAD pressure has DoublePadBass close enough by ear with log-mel 0.247748 / score 0.650029 after Q/order cascade, while Ghost Ensemble improves to log-mel 0.369402 / score 0.57229. The remaining Ghost gap is no longer first-32 OscilGen harmonic extraction; oracle harmonic dumps match those ratios. The upstream GPL instrument bank is initialized at `external/zynaddsubfx/instruments` commit c5c912131b31df5fdf372d2f06a25aaf2375837f; survey tests parse 1318/1318 .xiz files, write a pressure report, and render a five-patch upstream PAD listening batch under ignored artifacts."
first_slice:
- "Use the Zyn PAD oracle report to isolate source-table, playback/gain, envelope, and fixture-mapping mismatch before further Zyn DSL golf."
- "Use named layers as the owner before promoting richer formant morphing syntax."

View file

@ -415,7 +415,7 @@ public sealed class PatchScriptTests
public void LayerLowPassQParsesAndExportsAsExplicitFilterDamping()
{
var patch = PatchScript.Parse("""
layer name=pad engine=pad gain=.08 lpf=.3 lpf_q=.5
layer name=pad engine=pad gain=.08 lpf=.3 lpf_q=.5 lpf_order=4
voice layer=pad freq=220
""");
var export = FaustEmitter.Emit(patch);
@ -424,6 +424,7 @@ public sealed class PatchScriptTests
Assert.Equal(.5f, voice.Filter.LowPassQ, 5);
Assert.Contains("fi.resonlp(max(20.0, clip01(0.3", export.Source);
Assert.Contains("max(0.1, 0.5)", export.Source);
Assert.Contains(") : fi.resonlp(max(20.0, clip01(0.3", export.Source);
}
[Fact]

View file

@ -13,6 +13,7 @@
#include "../Misc/XMLwrapper.h"
#include "../Params/Controller.h"
#include "../Params/PADnoteParameters.h"
#include "../Synth/OscilGen.h"
#include "../Synth/PADnote.h"
#include <algorithm>
#include <cmath>
@ -67,6 +68,30 @@ void writeFloat32(const std::string& path, const float* samples, int count)
}
}
void normalizeMaxLikePad(float* values, int count)
{
float max = 0.0f;
for(int i = 0; i < count; ++i)
if(values[i] > i)
max = values[i];
if(max > 0.000001f)
for(int i = 0; i < count; ++i)
values[i] /= max;
}
void writeHarmonics(
PADnoteParameters& parameters,
const SYNTH_T& synth,
const std::string& path,
float baseFrequency)
{
std::vector<float> harmonics(synth.oscilsize, 0.0f);
parameters.oscilgen->get(harmonics.data(), baseFrequency, false);
normalizeMaxLikePad(harmonics.data(), synth.oscilsize / 2);
writeFloat32(path, harmonics.data(), synth.oscilsize / 2);
}
void renderNote(
const PADnoteParameters& parameters,
const SYNTH_T& synth,
@ -103,7 +128,7 @@ void renderNote(
int main(int argc, char** argv)
{
if(argc < 3 || argc > 7) {
std::cerr << "Usage: ZynPadReference <input.xiz> <output.f32> [kit-index] [sample-index|note] [frequency-hz] [seconds]\n";
std::cerr << "Usage: ZynPadReference <input.xiz> <output.f32> [kit-index] [sample-index|note|harmonics] [frequency-hz] [seconds]\n";
return 2;
}
@ -111,7 +136,8 @@ int main(int argc, char** argv)
const std::string outputPath = argv[2];
const int kitIndex = argc >= 4 ? parseInt(argv[3], "kit index") : 0;
const bool renderAudio = argc >= 5 && std::string(argv[4]) == "note";
const int requestedSample = argc >= 5 && !renderAudio ? parseInt(argv[4], "sample index") : 0;
const bool renderHarmonics = argc >= 5 && std::string(argv[4]) == "harmonics";
const int requestedSample = argc >= 5 && !renderAudio && !renderHarmonics ? parseInt(argv[4], "sample index") : 0;
const float frequency = argc >= 6 ? std::strtof(argv[5], nullptr) : 261.6256f;
const float seconds = argc >= 7 ? std::strtof(argv[6], nullptr) : 1.5f;
@ -151,6 +177,14 @@ int main(int argc, char** argv)
return 0;
}
if(renderHarmonics) {
writeHarmonics(parameters, synth, outputPath, frequency);
std::cout << "mode=harmonics count=" << synth.oscilsize / 2
<< " basefreq=" << frequency << "\n";
FFT_cleanup();
return 0;
}
int sampleIndex = requestedSample;
if(sampleIndex < 0 || sampleIndex >= PAD_MAX_SAMPLES || parameters.sample[sampleIndex].smp == nullptr) {
sampleIndex = -1;