diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs index 89c3bc4..1e6780f 100644 --- a/src/AquariumSynth.Dsl/FaustExport.cs +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -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};"); diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 104b7f3..b428870 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -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); diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index 5a994e8..8cdffe4 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -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 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('@')) diff --git a/src/AquariumSynth.Dsl/SfxrParams.cs b/src/AquariumSynth.Dsl/SfxrParams.cs index cb0db7b..e6a5737 100644 --- a/src/AquariumSynth.Dsl/SfxrParams.cs +++ b/src/AquariumSynth.Dsl/SfxrParams.cs @@ -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( diff --git a/src/AquariumSynth.Dsl/ZynInstrument.cs b/src/AquariumSynth.Dsl/ZynInstrument.cs index 701f3c7..6b9ff23 100644 --- a/src/AquariumSynth.Dsl/ZynInstrument.cs +++ b/src/AquariumSynth.Dsl/ZynInstrument.cs @@ -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") diff --git a/state/evidence.jsonl b/state/evidence.jsonl index fa6a63f..d6b62c7 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/memory.json b/state/memory.json index 47f08d7..22d0b2d 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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.", diff --git a/state/scratch.md b/state/scratch.md index 073d678..f9e9b58 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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: diff --git a/state/spine.yaml b/state/spine.yaml index ba8fa33..6c52111 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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 diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 2c85d42..b90fa3a 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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() {