diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index 8bab079..3f35f41 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -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) diff --git a/docs/zynaddsubfx-pressure-survey.md b/docs/zynaddsubfx-pressure-survey.md index e0f7e49..f9c9fad 100644 --- a/docs/zynaddsubfx-pressure-survey.md +++ b/docs/zynaddsubfx-pressure-survey.md @@ -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. diff --git a/src/AquariumSynth.Dsl/BuiltInScripts.cs b/src/AquariumSynth.Dsl/BuiltInScripts.cs index b18bf71..8929d93 100644 --- a/src/AquariumSynth.Dsl/BuiltInScripts.cs +++ b/src/AquariumSynth.Dsl/BuiltInScripts.cs @@ -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 """; diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 9ff58f4..aeee4d0 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -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 Voices { get; init; } = Array.Empty(); + public IReadOnlyList Layers { 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 0a3aa93..a2ff46b 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -34,7 +34,9 @@ public static class PatchScript private readonly List _operatorGraphs = []; private readonly List _parameters = []; private readonly List _parameterBindings = []; + private readonly List _layers = []; private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; public List 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 ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_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 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 LayerVoiceFields(PatchLayer layer, IReadOnlyDictionary fields) + { + var result = new Dictionary(fields, StringComparer.OrdinalIgnoreCase) + { + ["layer"] = layer.Name + }; + if (!result.ContainsKey("gain")) + { + result["gain"] = F(layer.Gain); + } + return result; + } + private void ApplyPatch(IReadOnlyDictionary 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) => diff --git a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs index dfe5acf..e083165 100644 --- a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs +++ b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs @@ -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.") ], diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 52b3256..81f57cd 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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"} diff --git a/state/memory.json b/state/memory.json index ea8019c..63c2405 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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= 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" } } diff --git a/state/scratch.md b/state/scratch.md index 1714d18..572cd19 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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= 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 diff --git a/state/spine.yaml b/state/spine.yaml index 984bbad..c025428 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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= dotnet test AquariumSynthCSharp.slnx --no-restore: 88 passed" - date: 2026-05-14 summary: > Hardened the Zyn reader against the real upstream instrument bank and diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 7de0b8c..392ed0b 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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(() => PatchScript.Parse(""" + layer name=body + layer name=body + voice layer=body + """)); + + Assert.Throws(() => PatchScript.Parse("voice layer=missing")); + } + [Fact] public void Dx7Algorithm32RebuildMatchesAdditiveCarrierShape() {