Add PAD spectral cloud DSL surface

This commit is contained in:
Meta Vens 2026-05-14 20:54:44 +01:00
commit ff43eef5e3
11 changed files with 212 additions and 23 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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
""";

View file

@ -201,6 +201,12 @@ public sealed record HarmonicBank(
float RootFrequencyHz,
IReadOnlyList<HarmonicPartial> Partials);
public sealed record SpectralBank(
string LayerName,
float RootFrequencyHz,
float SpreadRatio,
IReadOnlyList<HarmonicPartial> Partials);
public sealed record ReferencePatch(
string Id,
string Family,
@ -236,6 +242,7 @@ public sealed record SynthPatch
public IReadOnlyList<Voice> Voices { get; init; } = Array.Empty<Voice>();
public IReadOnlyList<PatchLayer> Layers { get; init; } = Array.Empty<PatchLayer>();
public IReadOnlyList<HarmonicBank> HarmonicBanks { get; init; } = Array.Empty<HarmonicBank>();
public IReadOnlyList<SpectralBank> SpectralBanks { get; init; } = Array.Empty<SpectralBank>();
public IReadOnlyList<OperatorGraph> OperatorGraphs { get; init; } = Array.Empty<OperatorGraph>();
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
public IReadOnlyList<PatchParameter> Parameters { get; init; } = Array.Empty<PatchParameter>();

View file

@ -36,6 +36,7 @@ public static class PatchScript
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly List<PatchLayer> _layers = [];
private readonly List<HarmonicBank> _harmonicBanks = [];
private readonly List<SpectralBank> _spectralBanks = [];
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Dictionary<string, string>> _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<string, string> 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<string, string> sharedFields,
string layerName,
float frequency,
float gain,
int line)
{
var voiceFields = new Dictionary<string, string>(sharedFields, StringComparer.OrdinalIgnoreCase)
{
["layer"] = layerName,
["freq"] = F(frequency),
["gain"] = F(gain)
};
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line));
}
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
var expanded = new Dictionary<string, string>(_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",

View file

@ -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",

View file

@ -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"}

View file

@ -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=<bundled 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"
}
}

View file

@ -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=<bundled 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

View file

@ -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=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore: 93 passed"
- date: 2026-05-14
summary: >
Promoted staged rate/level envelopes from operator graphs to normal

View file

@ -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<PatchScriptException>(() =>
PatchScript.Parse("spectrum layer=missing root=110 partials=1:.5"));
Assert.Throws<PatchScriptException>(() => PatchScript.Parse("""
layer name=pad
spectrum layer=pad root=110 spread=-.01 partials=1:.5
"""));
Assert.Throws<PatchScriptException>(() => PatchScript.Parse("""
layer name=pad
spectrum layer=pad root=110 spread=1 partials=1:.5
"""));
}
[Fact]
public void Dx7Algorithm32RebuildMatchesAdditiveCarrierShape()
{