From ff43eef5e3adfbeab797d3feeee3abc5ef3fc74f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 14 May 2026 20:54:44 +0100 Subject: [PATCH] Add PAD spectral cloud DSL surface --- docs/reference-synth-roadmap.md | 21 ++++- docs/zynaddsubfx-pressure-survey.md | 12 +++ src/AquariumSynth.Dsl/BuiltInScripts.cs | 5 +- src/AquariumSynth.Dsl/Model.cs | 7 ++ src/AquariumSynth.Dsl/PatchScript.cs | 80 +++++++++++++++++++ src/AquariumSynth.Dsl/ReferenceRebuilds.cs | 5 +- state/evidence.jsonl | 1 + state/memory.json | 11 +-- state/scratch.md | 19 +++++ state/spine.yaml | 28 ++++--- .../PatchScriptTests.cs | 46 +++++++++++ 11 files changed, 212 insertions(+), 23 deletions(-) diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index 093d074..b02e996 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -120,10 +120,11 @@ First deliverable: effects. - Rebuild one additive lead, one PAD texture, and one formant/vocal patch. The first rebuild catalog entries now parse/export and record the first Zyn - missing abstractions. Named layers, additive harmonic-bank syntax, and staged - rate/level envelopes for normal voices now exist; PAD-style spectral source - generation, arbitrary Zyn free-envelope point curves, fuller kit/effect - routing, and richer formant/vowel morphing remain pressure. + missing abstractions. Named layers, additive harmonic-bank syntax, PAD + spectral-cloud syntax, and staged rate/level envelopes for normal voices now + exist; exact PADsynth wavetable generation, arbitrary Zyn free-envelope point + curves, fuller kit/effect routing, and richer formant/vowel morphing remain + pressure. Do not vendor the upstream Zyn instrument-bank submodule until its preset license/provenance is explicit enough for this repo. Use project-authored @@ -160,6 +161,18 @@ This promotes the envelope authority already used by operator graphs into the voice path. It is enough to express layered PAD/vocal onset and release shapes, but it is not yet a Zyn free-envelope point editor. +The fourth response is `spectrum`: PAD layers can declare a source-level partial +cloud that lowers to detuned voice pairs: + +```text +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 claiming +exact PADsynth FFT/wavetable generation. `root`, `spread`, and `partials` are +authoring-time graph shape, not runtime controls. + Sources: - [ZynAddSubFX](https://github.com/zynaddsubfx/zynaddsubfx) diff --git a/docs/zynaddsubfx-pressure-survey.md b/docs/zynaddsubfx-pressure-survey.md index c469c5c..be6dce5 100644 --- a/docs/zynaddsubfx-pressure-survey.md +++ b/docs/zynaddsubfx-pressure-survey.md @@ -98,3 +98,15 @@ voice layer=pad freq=220 That gives PAD and vocal layers asymmetric contour authority without returning to anonymous ADSR piles. Arbitrary Zyn free-envelope point curves remain pressure; the staged surface is the first coherent rung, not the whole ladder. + +PAD layers now have a spectral-cloud source command: + +```text +layer name=pad_low engine=pad +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 lowers it to +detuned voice pairs. This is not a PADsynth wavetable generator. It is the +smallest honest source authority needed before exact PAD synthesis earns its +own machinery. diff --git a/src/AquariumSynth.Dsl/BuiltInScripts.cs b/src/AquariumSynth.Dsl/BuiltInScripts.cs index 29a301f..b046a8f 100644 --- a/src/AquariumSynth.Dsl/BuiltInScripts.cs +++ b/src/AquariumSynth.Dsl/BuiltInScripts.cs @@ -832,9 +832,8 @@ public static class BuiltInScripts layer name=pad_high engine=pad min_key=48 max_key=108 gain=0.044 vibrato=0.009 vibrato_hz=0.11 env=rl rates=0.42,0.58,0.9,1.25 levels=1,0.84,0.72,0 curves=lin,exp,lin,lin gate=1.9 layer name=air engine=add min_key=0 max_key=127 gain=0.018 wave=noise noise=0.82 env=rl rates=0.8,0.6,1.1,1.1 levels=1,0.82,0.55,0 curves=lin,lin,exp,lin gate=2.5 lpf=0.36 hpf=0.22 - voice layer=pad_low freq=130.8128 - voice layer=pad_low freq=196 gain=0.052 vibrato=0.012 vibrato_hz=0.13 - voice layer=pad_high freq=261.6256 wave=triangle + spectrum layer=pad_low root=130.8128 spread=0.012 partials=1:0.07,1.5:0.052 + spectrum layer=pad_high root=261.6256 spread=0.009 partials=1:0.044 wave=triangle voice layer=air freq=1800 """; diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 6af1237..4aa95df 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -201,6 +201,12 @@ public sealed record HarmonicBank( float RootFrequencyHz, IReadOnlyList Partials); +public sealed record SpectralBank( + string LayerName, + float RootFrequencyHz, + float SpreadRatio, + IReadOnlyList Partials); + public sealed record ReferencePatch( string Id, string Family, @@ -236,6 +242,7 @@ public sealed record SynthPatch public IReadOnlyList Voices { get; init; } = Array.Empty(); public IReadOnlyList Layers { get; init; } = Array.Empty(); public IReadOnlyList HarmonicBanks { get; init; } = Array.Empty(); + public IReadOnlyList SpectralBanks { get; init; } = Array.Empty(); public IReadOnlyList OperatorGraphs { get; init; } = Array.Empty(); public IReadOnlyList Controls { get; init; } = Array.Empty(); public IReadOnlyList Parameters { get; init; } = Array.Empty(); diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index f01dd1f..0feb67e 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -36,6 +36,7 @@ public static class PatchScript private readonly List _parameterBindings = []; private readonly List _layers = []; private readonly List _harmonicBanks = []; + private readonly List _spectralBanks = []; private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; @@ -56,6 +57,7 @@ public static class PatchScript Voices = Voices, Layers = _layers, HarmonicBanks = _harmonicBanks, + SpectralBanks = _spectralBanks, OperatorGraphs = _operatorGraphs, Controls = _controls, Parameters = _parameters, @@ -103,6 +105,10 @@ public static class PatchScript FlushPendingOperatorGraph(); AddHarmonicBank(fields, line); break; + case "spectrum": + FlushPendingOperatorGraph(); + AddSpectralBank(fields, line); + break; case "voice": FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line)); @@ -188,6 +194,79 @@ public static class PatchScript } } + private void AddSpectralBank(IReadOnlyDictionary fields, int line) + { + var layerName = Required(fields, "layer", line); + if (!_layerDefaults.ContainsKey(layerName)) + { + throw new PatchScriptException(line, $"unknown layer `{layerName}`"); + } + + var rootFrequency = GetFloat(fields, line, 440, "root", "base", "freq", "frequency"); + if (rootFrequency <= 0) + { + throw new PatchScriptException(line, "spectral root frequency must be greater than zero"); + } + + var spread = GetFloat(fields, line, 0, "spread", "width", "detune"); + if (spread is < 0 or >= 1) + { + throw new PatchScriptException(line, "spectral spread must be at least zero and less than one"); + } + + var partials = ParseHarmonicPartials( + GetAny(fields, ["partials", "bank", "tones"], ""), + line); + if (partials.Count == 0) + { + throw new PatchScriptException(line, "spectrum needs at least one partial"); + } + + _spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials)); + + var sharedFields = Without(fields, + "layer", + "root", + "base", + "freq", + "frequency", + "spread", + "width", + "detune", + "partials", + "bank", + "tones", + "gain", + "g"); + foreach (var partial in partials) + { + if (spread == 0) + { + AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio, partial.Gain, line); + continue; + } + + AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 - spread), partial.Gain * 0.5f, line); + AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 + spread), partial.Gain * 0.5f, line); + } + } + + private void AddSpectralVoice( + IReadOnlyDictionary sharedFields, + string layerName, + float frequency, + float gain, + int line) + { + var voiceFields = new Dictionary(sharedFields, StringComparer.OrdinalIgnoreCase) + { + ["layer"] = layerName, + ["freq"] = F(frequency), + ["gain"] = F(gain) + }; + Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line)); + } + private Dictionary ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_defaults, StringComparer.OrdinalIgnoreCase); @@ -982,6 +1061,7 @@ public static class PatchScript "def" or "t" or "template" => "template", "layer" or "kit" => "layer", "harmonics" or "partials" or "drawbars" => "harmonics", + "spectrum" or "spectral" or "padsource" or "pad_source" => "spectrum", "v" or "voice" => "voice", "opgraph" or "ops" or "operators" => "opgraph", "operator" or "op" => "operator", diff --git a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs index 42c9f62..ca70603 100644 --- a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs +++ b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs @@ -42,16 +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 before lowering to detuned Aquarium voices."), 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("pad_spectral_source", "PADsynth wavetable/spectral generation", "Aquarium has no PAD-style spectral source; this rebuild is a texture approximation."), + new("padsynth_wavetable_exactness", "PADsynth FFT/wavetable generation", "Aquarium has spectral source syntax, but it lowers to detuned voices rather than generating a PADsynth wavetable."), 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 PAD parity. The current voice DSL can sketch the texture, but PAD-style spectral generation deserves its own source authority if repeated targets need it."), + "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."), new( "zyn/project-vocal-layer/aquarium", "zynaddsubfx", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f24b6e5..495bf1f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -63,3 +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 lowered to detuned voice pairs. This gives PAD texture rebuilds a source-level partial-cloud authority without claiming exact PADsynth FFT/wavetable generation; roots/spreads/partials are authoring-time graph shape, not runtime controls.","source":"Zyn PAD spectral-cloud DSL slice on 2026-05-14"} diff --git a/state/memory.json b/state/memory.json index dbd5738..9e9b125 100644 --- a/state/memory.json +++ b/state/memory.json @@ -21,6 +21,7 @@ "structured development .aqua patch library", "ZynAddSubFX .xiz feature extraction with project-authored fixtures", "Zyn additive harmonic-bank DSL surface", + "Zyn PAD spectral-cloud DSL surface", "staged rate/level envelopes for normal voices", "thresholded DX7 rendered parity test", "reference rebuild catalog", @@ -83,9 +84,9 @@ "next": "Use the now-validated algorithm-8 route scale, transpose lowering, and fixed-frequency lowering as fixed inputs while isolating remaining community-voice timbre mismatch, then expand to broader public-domain banks." }, "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` banks attach to those layers, and staged `env=rl` envelopes now work on normal voices/layer defaults.", - "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 and lowers them to voices for inspectable Faust output. 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, PAD spectral sources, arbitrary free-envelope point curves, or richer formant morphing.", - "next": "Choose the next syntax that attaches to named layers: PAD spectral source or richer formant morphing. Prove one across more than one target." + "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 lowers partials to detuned voice pairs. 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 FFT/wavetable generation, 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": { "rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.", @@ -100,7 +101,7 @@ }, "last_verified": { "command": "AQUARIUM_DX7_PYTHON= dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "91 passed; staged voice envelope DSL added without regressing DX7 parity", - "commit": "pending Zyn staged voice envelopes" + "result": "93 passed; PAD spectral-cloud DSL added without regressing DX7 parity", + "commit": "pending Zyn PAD spectral clouds" } } diff --git a/state/scratch.md b/state/scratch.md index c17e9b7..9a6b0d7 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -15,6 +15,25 @@ Doctrine update: 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, then lowers + each partial to detuned voice pairs. `root`, `spread`, and `partials` are + authoring-time graph 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 FFT/wavetable generation. It is a source-level + partial-cloud authority for readable PAD rebuilds. +- Focused verification passed: + `dotnet test tests\AquariumSynth.Dsl.Tests\AquariumSynth.Dsl.Tests.csproj --no-restore --filter "FullyQualifiedName~SpectralBank|FullyQualifiedName~ZynStyleReferenceRebuildsParseExportAndDeclarePressure|FullyQualifiedName~BuiltInReferenceScriptsParseAndExportFaust"`: + 4 passed. +- Full verification with bundled Python/dexed-py: + `AQUARIUM_DX7_PYTHON= dotnet test AquariumSynthCSharp.slnx --no-restore`: + 93 passed. + +Previous slice: + - Promoted staged rate/level envelopes to normal voices and layer defaults. Voice fields now accept `env=rl rates=... levels=... curves=... gate=...`; the model stores `Voice.RateLevelEnvelope`, and Faust emission uses diff --git a/state/spine.yaml b/state/spine.yaml index 0628673..c274523 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-14T20:45:00+01:00 +updated: 2026-05-14T21:10:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -76,6 +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 lower to detuned voice pairs for now; do not pretend this covers exact PADsynth FFT/wavetable generation. - 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. @@ -97,10 +98,10 @@ 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, and PAD/vocal rebuilds now 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` partial-cloud 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 PAD spectral source or richer formant morphing syntax." - - "Additive harmonic banks and staged voice envelopes now exist; prove any next syntax across more than one target." + - "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." - "Do not add all remaining pressures at once." faust: priority: 3 @@ -132,11 +133,10 @@ next_agent_start_here: `src/AquariumSynth.Dsl/ZynInstrument.cs` with project-authored fixtures under `tests/AquariumSynth.Dsl.Tests/Fixtures/ZynAddSubFX/ProjectAuthored`. Named `layer` declarations own source identity, additive `harmonics` banks - attach to those layers, and staged `env=rl` envelopes now work on normal - voices/layer defaults. Next pressure is to choose the next syntax that - attaches to named layers: PAD spectral source or richer formant morphing. - Do one, prove it across more than one target, and leave DX7 alone unless a - regression test fails. + attach to those layers, PAD `spectrum` banks attach to those layers, 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: - AGENTS.md - docs/audio-doctrine.md @@ -149,6 +149,16 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-14 + 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 lowers each + partial to detuned voice pairs for inspectable Faust output. The Zyn PAD + texture rebuild now uses two spectral banks under `pad_low` and + `pad_high`, while exact PADsynth FFT/wavetable generation remains missing + pressure. + verification: "AQUARIUM_DX7_PYTHON= dotnet test AquariumSynthCSharp.slnx --no-restore: 93 passed" - date: 2026-05-14 summary: > Promoted staged rate/level envelopes from operator graphs to normal diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 4cfaef7..035b345 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -259,6 +259,10 @@ public sealed class PatchScriptTests Assert.NotEmpty(rebuild.MatchedFeatures); Assert.NotEmpty(rebuild.MissingFeatures); Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "named_layers"); + if (rebuild.ReferenceId == "zyn/project/pad-texture") + { + Assert.NotEmpty(patch.SpectralBanks); + } if (rebuild.ReferenceId is "zyn/project/pad-texture" or "zyn/project/vocal-layer") { Assert.Contains(patch.Voices, voice => voice.RateLevelEnvelope is not null); @@ -381,6 +385,48 @@ public sealed class PatchScriptTests Assert.Contains("rl4_env(0.1, 1, 0, 0.2, 0.8, 1, 0.3, 0.5, 1, 0.4, 0, 0, patch_param_0)", export.Source); } + [Fact] + public void SpectralBankSyntaxExpandsPadPartialCloud() + { + var patch = PatchScript.Parse(""" + layer name=pad engine=pad gain=.08 wave=saw env=rl rates=.1,.2,.3,.4 levels=1,.8,.5,0 gate=.9 + spectrum layer=pad root=100 spread=.01 partials=1:.08,1.5:.04 + """); + var export = FaustEmitter.Emit(patch); + + var bank = Assert.Single(patch.SpectralBanks); + Assert.Equal("pad", bank.LayerName); + Assert.Equal(100, bank.RootFrequencyHz); + Assert.Equal(.01f, bank.SpreadRatio, 5); + Assert.Equal(2, bank.Partials.Count); + Assert.Equal(4, patch.Voices.Count); + Assert.All(patch.Voices, voice => Assert.Equal("pad", voice.Layer?.Name)); + Assert.All(patch.Voices, voice => Assert.NotNull(voice.RateLevelEnvelope)); + Assert.Equal([99, 101, 148.5f, 151.5f], patch.Voices.Select(voice => voice.Oscillator.FrequencyHz).ToArray()); + Assert.Equal(.04f, patch.Voices[0].Gain, 5); + Assert.Equal(.04f, patch.Voices[1].Gain, 5); + Assert.Equal(.02f, patch.Voices[2].Gain, 5); + Assert.Equal(.02f, patch.Voices[3].Gain, 5); + Assert.Contains("process =", export.Source); + } + + [Fact] + public void SpectralBankSyntaxRejectsUnknownLayerOrBadSpread() + { + Assert.Throws(() => + PatchScript.Parse("spectrum layer=missing root=110 partials=1:.5")); + + Assert.Throws(() => PatchScript.Parse(""" + layer name=pad + spectrum layer=pad root=110 spread=-.01 partials=1:.5 + """)); + + Assert.Throws(() => PatchScript.Parse(""" + layer name=pad + spectrum layer=pad root=110 spread=1 partials=1:.5 + """)); + } + [Fact] public void Dx7Algorithm32RebuildMatchesAdditiveCarrierShape() {