Add named layer DSL surface

This commit is contained in:
Meta Vens 2026-05-14 20:25:59 +01:00
commit 62ea9bee17
11 changed files with 188 additions and 27 deletions

View file

@ -134,6 +134,12 @@ The current external-bank pressure survey is in
signal is that named kit/layer routing should probably precede syntax golf for
PAD, additive banks, or formant motion.
The first language-golf response is `layer`: a named layer declaration owns
engine tags, optional MIDI key range metadata, default gain, and an effect-send
label while lowering to ordinary Aquarium voices. It is intentionally only an
ownership/routing scaffold for now; it does not add PAD synthesis, free
envelopes, or effect buses by stealth.
Sources:
- [ZynAddSubFX](https://github.com/zynaddsubfx/zynaddsubfx)

View file

@ -61,3 +61,16 @@ Reason: the worst Zyn instruments repeatedly combine multiple kit items and
engines. A named layer surface would give additive banks, PAD sources, free
envelopes, and formant motion somewhere coherent to live later. Without it, the
next abstractions risk becoming loose fields on anonymous voices.
## First Response
Aquarium now has a minimal `layer` command:
```text
layer name=pad_low engine=pad min_key=36 max_key=84 gain=.07
voice layer=pad_low freq=130.8128
```
The layer owns source identity and metadata while current lowering still emits
ordinary voices. This is the scaffold needed before promoting PAD sources,
additive harmonic banks, free envelopes, or richer formant motion.

View file

@ -790,10 +790,13 @@ public static class BuiltInScripts
vibrato=@/macro/motion
vibrato_hz=5.5
voice freq=220 gain=0.16
voice freq=440 gain=0.075
voice freq=660 gain=0.045
voice freq=880 gain=0.025
layer name=body engine=add min_key=36 max_key=96 gain=0.16
layer name=shine engine=add min_key=48 max_key=108 gain=0.045
voice layer=body freq=220
voice layer=body freq=440 gain=0.075
voice layer=shine freq=660
voice layer=shine freq=880 gain=0.025
""";
public const string ZynStylePadTexture = """
@ -827,10 +830,14 @@ public static class BuiltInScripts
lpf=0.18
gain=0.05
voice freq=130.8128 gain=0.07 vibrato=@/macro/spread vibrato_hz=0.09
voice freq=196 gain=0.052 vibrato=0.012 vibrato_hz=0.13
voice freq=261.6256 gain=0.044 wave=triangle vibrato=0.009 vibrato_hz=0.11
voice wave=noise freq=1800 gain=0.018 noise=0.82 attack=0.8 sustain=1.7 decay=1.1 lpf=0.36 hpf=0.22
layer name=pad_low engine=pad min_key=36 max_key=84 gain=0.07 vibrato=@/macro/spread vibrato_hz=0.09
layer name=pad_high engine=pad min_key=48 max_key=108 gain=0.044 vibrato=0.009 vibrato_hz=0.11
layer name=air engine=add min_key=0 max_key=127 gain=0.018 wave=noise noise=0.82 attack=0.8 sustain=1.7 decay=1.1 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
voice layer=air freq=1800
""";
public const string ZynStyleVocalLayer = """
@ -861,27 +868,27 @@ public static class BuiltInScripts
formant_mix=0.22
gain=0.04
layer name=air engine=add min_key=48 max_key=108 gain=0.11 send=formant
layer name=body engine=sub min_key=24 max_key=72 gain=0.075
layer name=breath engine=add min_key=0 max_key=127 gain=0.018 wave=noise noise=0.75 hpf=0.58 lpf=0.62 send=formant
voice
layer=air
wave=saw
freq=174.614
gain=0.11
formants=520:90:0.9,1080:150:1,2450:280:0.35
formant_mix=@/macro/vowel
voice
layer=body
wave=triangle
freq=87.307
gain=0.075
lpf=0.48
drive=0.08
voice
wave=noise
layer=breath
freq=2600
gain=0.018
noise=0.75
hpf=0.58
lpf=0.62
formants=700:120:0.5,1600:220:0.6
formant_mix=0.3
""";

View file

@ -186,6 +186,14 @@ public sealed record ReferenceSource(
public sealed record ReferenceFeature(string Name, string Value, string Notes = "");
public sealed record PatchLayer(
string Name,
string Engine = "",
int? MinKey = null,
int? MaxKey = null,
float Gain = 1,
string EffectSend = "");
public sealed record ReferencePatch(
string Id,
string Family,
@ -199,6 +207,7 @@ public sealed record Repeat(float IntervalSeconds);
public sealed record Voice
{
public PatchLayer? Layer { get; init; }
public Oscillator Oscillator { get; init; } = new();
public Note Note { get; init; } = new();
public Envelope Envelope { get; init; } = new();
@ -217,6 +226,7 @@ public sealed record Voice
public sealed record SynthPatch
{
public IReadOnlyList<Voice> Voices { get; init; } = Array.Empty<Voice>();
public IReadOnlyList<PatchLayer> Layers { get; init; } = Array.Empty<PatchLayer>();
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

@ -34,7 +34,9 @@ public static class PatchScript
private readonly List<OperatorGraph> _operatorGraphs = [];
private readonly List<PatchParameter> _parameters = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly List<PatchLayer> _layers = [];
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Dictionary<string, string>> _layerDefaults = new(StringComparer.OrdinalIgnoreCase);
private PendingOperatorGraph? _pendingOperatorGraph;
public List<Voice> Voices { get; } = [];
@ -51,6 +53,7 @@ public static class PatchScript
return new SynthPatch
{
Voices = Voices,
Layers = _layers,
OperatorGraphs = _operatorGraphs,
Controls = _controls,
Parameters = _parameters,
@ -90,6 +93,10 @@ public static class PatchScript
var name = Required(fields, "name", line);
_templates[name] = Without(fields, "name");
break;
case "layer":
FlushPendingOperatorGraph();
AddLayer(fields, line);
break;
case "voice":
FlushPendingOperatorGraph();
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line));
@ -130,6 +137,14 @@ public static class PatchScript
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
var expanded = new Dictionary<string, string>(_defaults, StringComparer.OrdinalIgnoreCase);
if (TryGetAny(fields, ["layer"], out var layerName))
{
if (!_layerDefaults.TryGetValue(layerName, out var layer))
{
throw new PatchScriptException(line, $"unknown layer `{layerName}`");
}
Merge(expanded, layer);
}
if (TryGetAny(fields, ["use", "u"], out var templateName))
{
foreach (var name in templateName.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
@ -145,6 +160,43 @@ public static class PatchScript
return expanded;
}
private void AddLayer(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_layers.Any(layer => layer.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate layer `{name}`");
}
var layer = new PatchLayer(
name,
GetAny(fields, ["engine", "e"], ""),
TryGetAny(fields, ["min_key", "key_min", "lo"], out var minKey) ? ParseInt(minKey, line) : null,
TryGetAny(fields, ["max_key", "key_max", "hi"], out var maxKey) ? ParseInt(maxKey, line) : null,
GetBoundFloat(fields, line, 1, $"/layers/{_layers.Count}/gain", "gain", "g"),
GetAny(fields, ["send", "effect", "fx"], ""));
if (layer is { MinKey: { } min, MaxKey: { } max } && min > max)
{
throw new PatchScriptException(line, "layer min_key must be less than or equal to max_key");
}
_layers.Add(layer);
_layerDefaults[name] = LayerVoiceFields(layer, Without(fields, "name", "engine", "e", "min_key", "key_min", "lo", "max_key", "key_max", "hi", "send", "effect", "fx"));
}
private static Dictionary<string, string> LayerVoiceFields(PatchLayer layer, IReadOnlyDictionary<string, string> fields)
{
var result = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase)
{
["layer"] = layer.Name
};
if (!result.ContainsKey("gain"))
{
result["gain"] = F(layer.Gain);
}
return result;
}
private void ApplyPatch(IReadOnlyDictionary<string, string> fields, int line)
{
if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseBoundFloat(gain, line, Gain, "/patch/gain");
@ -230,6 +282,9 @@ public static class PatchScript
return new Voice
{
Layer = TryGetAny(fields, ["layer"], out var layerName)
? _layers.First(layer => layer.Name.Equals(layerName, StringComparison.OrdinalIgnoreCase))
: null,
Oscillator = new Oscillator(
waveform,
frequency,
@ -845,6 +900,7 @@ public static class PatchScript
"p" or "patch" or "instrument" => "patch",
"d" or "default" or "defaults" => "defaults",
"def" or "t" or "template" => "template",
"layer" or "kit" => "layer",
"v" or "voice" => "voice",
"opgraph" or "ops" or "operators" => "opgraph",
"operator" or "op" => "operator",
@ -959,6 +1015,9 @@ public static class PatchScript
? parsed
: throw new PatchScriptException(line, $"bad number `{value}`");
private static string F(float value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
private static float PunchGain(float value) => 1 + Math.Max(0, Math.Clamp(value, -1, 1));
private static int ParseInt(string value, int line) =>

View file

@ -22,6 +22,7 @@ public static class ReferenceRebuildCatalog
BuiltInScripts.ZynStyleAdditiveLead,
[
new("engine_add", "1", "Represented as explicit additive sine partial voices."),
new("named_layers", "body,shine", "Layer declarations group partial voices under reusable ADD engine intent."),
new("partial_voice_count", "4", "The additive shape is represented as four explicit Aquarium partial voices."),
new("envelope_count", "2", "Amplitude and pitch contour pressure maps to ADSR plus vibrato in the current patch surface."),
new("lfo_count", "1", "The fixture's amplitude/frequency motion is represented as patch-level vibrato/macro motion.")
@ -39,6 +40,7 @@ public static class ReferenceRebuildCatalog
BuiltInScripts.ZynStylePadTexture,
[
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("filter_count", "1", "Represented with low-pass/high-pass field sites."),
new("free_envelope_count", "1", "Approximated with slow ADSR timing and macro spread."),
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.")
@ -58,6 +60,7 @@ public static class ReferenceRebuildCatalog
new("engine_add", "1", "The air/formant layer is represented as a formant-filtered oscillator voice."),
new("engine_sub", "1", "The body layer is represented as a lower triangle voice."),
new("layered_instrument", "yes", "Multiple Zyn kit items map to multiple Aquarium voices."),
new("named_layers", "air,body,breath", "Layer declarations preserve source identity before voice expansion."),
new("formant_filter_count", "1", "Represented with Aquarium formant filters and a vowel macro."),
new("effect_count", "1", "Approximated with local drive/soft clipping rather than a Zyn effect slot.")
],

View file

@ -60,3 +60,4 @@
{"kind":"zyn_reference_start","lesson":"The ZynAddSubFX phase should start with structural feature extraction, not translation. Upstream Zyn instruments live in a separate bank submodule and the preset provenance is not clear enough to vendor casually, so the first parser slice uses project-authored .xiz fixtures. `ZynInstrumentReader` reads plain or gzip-compressed XML and classifies active ADD/SUB/PAD engines, kit layering, envelopes, free envelopes, LFOs, filters, formants, and effects into ReferencePatch features.","source":"ZynAddSubFX feature extraction kickoff on 2026-05-14"}
{"kind":"zyn_reference_rebuilds","lesson":"The first Zyn-style Aquarium rebuilds can sketch additive lead, PAD texture, and vocal/formant layer fixtures with existing voices, macros, formants, filters, noise, and modulation, but the missing abstractions are now concrete: additive harmonic-bank syntax, PAD/spectral source generation, free envelopes for normal voices, named kit/layer routing with per-layer effect sends, and richer formant/vowel morphing. Keep these as named pressure before adding new DSL surface.","source":"ZynAddSubFX rebuild catalog slice on 2026-05-14"}
{"kind":"zyn_worst_case_survey","lesson":"Scanning the upstream ZynAddSubFX instrument bank at commit e9f64a9 found 1358 .xiz files and the hardened reader now parses all of them. The nastiest target is `olivers-100/0032-Drum Kit.xiz`: 16 enabled kit items, mixed ADD/SUB/PAD engines, 106 envelopes, 25 free envelopes, 73 LFOs, 37 filters, and 3 formant filters. The broader top list says named kit/layer routing should come before syntax golf for additive banks, PAD sources, or formant motion; otherwise new abstractions will have no coherent owner.","source":"ZynAddSubFX pressure survey on 2026-05-14"}
{"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"}

View file

@ -81,9 +81,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.",
"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, but the upstream survey says named kit/layer routing is the owner abstraction needed before syntax golf for additive harmonic banks, PAD/spectral source generation, free envelopes, or richer formant/vowel morphing.",
"next": "Map named kit/layer routing first; do not add additive/PAD/free-envelope/formant syntax until layer authority is clear."
"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 now provide the first Zyn-driven DSL abstraction.",
"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` now owns name, engine tag, optional key range metadata, default gain, and effect-send labels while lowering to ordinary voices. It gives future additive-bank, PAD-source, free-envelope, and formant-motion syntax a coherent owner.",
"next": "Choose the next syntax that attaches to named layers: additive harmonic bank, PAD spectral source, free envelope, or richer formant morphing. Prove one across more than one target."
},
"note_and_envelope_model": {
"rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.",
@ -98,7 +98,7 @@
},
"last_verified": {
"command": "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore",
"result": "86 passed; Zyn worst-case survey added without regressing DX7 parity",
"commit": "pending Zyn pressure survey"
"result": "88 passed; named layer DSL added without regressing DX7 parity",
"commit": "pending Zyn named layers"
}
}

View file

@ -15,6 +15,21 @@ Doctrine update:
Completed this slice:
- Added Zyn-driven language golf through `layer` declarations. A layer owns
name, engine tag, optional MIDI key range metadata, default gain, and an
effect-send label, then lowers to ordinary voices for now.
- Converted the three Zyn-style rebuilds to use named layers:
additive `body`/`shine`, PAD `pad_low`/`pad_high`/`air`, and vocal
`air`/`body`/`breath`.
- This is an ownership scaffold, not hidden PAD/free-envelope/effect machinery.
Future additive-bank, PAD-source, free-envelope, and formant-motion syntax
now has somewhere coherent to attach.
- Verified with bundled Python/dexed-py still wired:
`AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore`:
88 passed.
Previous slice:
- Hardened `ZynInstrumentReader` against the real upstream Zyn instrument bank:
it now tolerates leading whitespace before XML declarations and counts only
actual `FORMANT_FILTER`/`FILTER` blocks instead of all internal formant/vowel

View file

@ -73,6 +73,7 @@ live_invariants:
- Reference fixtures must preserve provenance, license status, source hash, and feature pressure.
- Public-domain reference fixtures and dexed-py helpers are test/development material only; the NuGet package must ship only AquariumSynth.Dsl.
- ZynAddSubFX import starts as feature extraction, not translation. Use project-authored `.xiz` fixtures until external preset-bank license/provenance is explicit.
- 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.
- 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.
@ -94,9 +95,9 @@ 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. A non-vendored upstream bank survey parsed 1358/1358 .xiz files and identified named kit/layer routing as the next likely 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. A non-vendored upstream bank survey parsed 1358/1358 .xiz files and identified named kit/layer routing as the next likely owner abstraction."
first_slice:
- "Map named kit/layer routing first; the upstream worst-case survey says it is the owner needed before additive harmonic bank, PAD spectral source, free envelope, or richer formant morphing syntax."
- "Use named layers as the owner before promoting additive harmonic bank, PAD spectral source, free envelope, or richer formant morphing syntax."
- "Do not add all of them at once."
faust:
priority: 3
@ -127,11 +128,10 @@ next_agent_start_here:
Continue the ZynAddSubFX phase. The first parser/classifier slice exists in
`src/AquariumSynth.Dsl/ZynInstrument.cs` with project-authored fixtures
under `tests/AquariumSynth.Dsl.Tests/Fixtures/ZynAddSubFX/ProjectAuthored`.
Next pressure is to map named kit/layer routing from the Zyn survey evidence
in `docs/zynaddsubfx-pressure-survey.md`. The worst upstream targets combine
many enabled kit items and engines, so layer authority should come before
syntax golf for additive banks, PAD source generation, free envelopes, or
formant morphing. Leave DX7 alone unless a regression test fails.
Next pressure is to choose the next syntax that attaches to named layers:
additive harmonic bank, PAD spectral source, free envelope, or richer
formant morphing. Do one, prove it across more than one target, and leave
DX7 alone unless a regression test fails.
must_read:
- AGENTS.md
- docs/audio-doctrine.md
@ -144,6 +144,16 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-14
summary: >
Added the first Zyn-driven language-golf abstraction: `layer`.
Layer declarations own name, engine tag, optional MIDI key range metadata,
default gain, and effect-send labels while lowering to ordinary Aquarium
voices. The three Zyn-style rebuilds now use named layers, proving the
abstraction generalizes across additive, PAD-texture, and vocal/formant
targets without adding fixture-specific shortcuts or hidden PAD/effect
behavior.
verification: "AQUARIUM_DX7_PYTHON=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore: 88 passed"
- date: 2026-05-14
summary: >
Hardened the Zyn reader against the real upstream instrument bank and

View file

@ -255,8 +255,10 @@ public sealed class PatchScriptTests
var export = FaustEmitter.Emit(patch, new FaustExportOptions(rebuild.Id.Replace('/', '_').Replace('-', '_')));
Assert.Contains("process =", export.Source);
Assert.NotEmpty(patch.Layers);
Assert.NotEmpty(rebuild.MatchedFeatures);
Assert.NotEmpty(rebuild.MissingFeatures);
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "named_layers");
Assert.All(rebuild.MissingFeatures, feature => Assert.False(string.IsNullOrWhiteSpace(feature.Notes)));
}
}
@ -286,6 +288,41 @@ public sealed class PatchScriptTests
}
}
[Fact]
public void LayerSyntaxNamesReusableVoiceGroups()
{
var patch = PatchScript.Parse("""
layer name=kick engine=sub min_key=36 max_key=36 gain=.4 wave=sine freq=55 attack=.001 sustain=.04 decay=.18
layer name=air engine=add min_key=60 max_key=84 gain=.12 wave=saw lpf=.72
voice layer=kick
voice layer=air freq=440
voice layer=air freq=660 gain=.08
""");
Assert.Equal(2, patch.Layers.Count);
Assert.Equal(3, patch.Voices.Count);
Assert.Equal("kick", patch.Voices[0].Layer?.Name);
Assert.Equal("sub", patch.Voices[0].Layer?.Engine);
Assert.Equal(36, patch.Voices[0].Layer?.MinKey);
Assert.Equal(36, patch.Voices[0].Layer?.MaxKey);
Assert.Equal(.4f, patch.Voices[0].Gain, 5);
Assert.Equal("air", patch.Voices[1].Layer?.Name);
Assert.Equal("air", patch.Voices[2].Layer?.Name);
Assert.Equal(.08f, patch.Voices[2].Gain, 5);
}
[Fact]
public void LayerSyntaxRejectsUnknownOrDuplicateLayers()
{
Assert.Throws<PatchScriptException>(() => PatchScript.Parse("""
layer name=body
layer name=body
voice layer=body
"""));
Assert.Throws<PatchScriptException>(() => PatchScript.Parse("voice layer=missing"));
}
[Fact]
public void Dx7Algorithm32RebuildMatchesAdditiveCarrierShape()
{