From a3c4e642111daf714db208859f6c4a8091498c9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 13 May 2026 11:47:39 +0100 Subject: [PATCH] Add readable operator graph syntax --- src/AquariumSynth.Dsl/BuiltInScripts.cs | 41 +++- src/AquariumSynth.Dsl/Model.cs | 2 + src/AquariumSynth.Dsl/PatchScript.cs | 195 +++++++++++++++--- state/evidence.jsonl | 1 + state/memory.json | 8 +- state/scratch.md | 16 +- state/spine.yaml | 16 +- .../PatchScriptTests.cs | 26 +++ 8 files changed, 268 insertions(+), 37 deletions(-) diff --git a/src/AquariumSynth.Dsl/BuiltInScripts.cs b/src/AquariumSynth.Dsl/BuiltInScripts.cs index 5f9dc3c..d76eaa0 100644 --- a/src/AquariumSynth.Dsl/BuiltInScripts.cs +++ b/src/AquariumSynth.Dsl/BuiltInScripts.cs @@ -685,9 +685,44 @@ public static class BuiltInScripts name=dx7_algo8_core freq=330 gain=0.18 - carriers=1,3 - ops=6:4:0.9:0:0.035:0.18,5:3:0.82:0:0.04:0.2,4:2:0.7:0.18:0.05:0.24,3:2:0.6:0:0.06:0.3,2:1:0.75:0:0.045:0.2,1:1:0.82:0:0.08:0.34 - edges=6>5:1.1,5>3:0.9,4>3:0.75,2>1:0.85 + + operator name=op6 + ratio=4 + level=0.9 + env=ad:0.035:0.18 + + operator name=op5 + ratio=3 + level=0.82 + env=ad:0.04:0.2 + + operator name=op4 + ratio=2 + level=0.7 + feedback=0.18 + env=ad:0.05:0.24 + + operator name=op3 + ratio=2 + level=0.6 + env=ad:0.06:0.3 + + operator name=op2 + ratio=1 + level=0.75 + env=ad:0.045:0.2 + + operator name=op1 + ratio=1 + level=0.82 + env=adsr:0.005:0.08:0.68:0.34 + + route from=op6 to=op5 index=1.1 + route from=op5 to=op3 index=0.9 + route from=op4 to=op3 index=0.75 + route from=op2 to=op1 index=0.85 + carrier name=op1 + carrier name=op3 voice freq=330 diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 7c743f5..c309ff3 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -94,6 +94,8 @@ public sealed record OperatorNode( float Ratio = 1, float Level = 1, float Feedback = 0, + float AttackSeconds = 0, + float ReleaseSeconds = 0, Envelope Envelope = null!) { public Envelope Envelope { get; init; } = Envelope ?? new(); diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index d49fb71..663d862 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -19,7 +19,7 @@ public static class PatchScript line = statement.Line; } - if (compiler.Voices.Count == 0 && compiler.OperatorGraphs.Count == 0) + if (!compiler.HasOutput) { throw new PatchScriptException(line, "patch script produced no voices or operator graphs"); } @@ -35,24 +35,30 @@ public static class PatchScript private readonly List _parameters = []; private readonly List _parameterBindings = []; private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); + private PendingOperatorGraph? _pendingOperatorGraph; public List Voices { get; } = []; public List OperatorGraphs => _operatorGraphs; + public bool HasOutput => Voices.Count > 0 || _operatorGraphs.Count > 0 || _pendingOperatorGraph is not null; private Repeat? Repeat { get; set; } private float Gain { get; set; } = 1; private bool SoftClip { get; set; } = true; - public SynthPatch Build() => new() + public SynthPatch Build() { - Voices = Voices, - OperatorGraphs = _operatorGraphs, - Controls = _controls, - Parameters = _parameters, - ParameterBindings = _parameterBindings, - Repeat = Repeat, - Gain = Gain, - SoftClip = SoftClip - }; + FlushPendingOperatorGraph(); + return new SynthPatch + { + Voices = Voices, + OperatorGraphs = _operatorGraphs, + Controls = _controls, + Parameters = _parameters, + ParameterBindings = _parameterBindings, + Repeat = Repeat, + Gain = Gain, + SoftClip = SoftClip + }; + } public void Apply(string statement, int line) { @@ -62,6 +68,7 @@ public static class PatchScript var fields = ParseFields(parts.Skip(1), line); if (SfxrParams.Named(rawCommand) is { } namedParams) { + FlushPendingOperatorGraph(); AddSfxrPatch(ApplySfxrFields(namedParams, fields, line)); return; } @@ -69,31 +76,48 @@ public static class PatchScript switch (command) { case "patch": + FlushPendingOperatorGraph(); ApplyPatch(fields, line); break; case "defaults": + FlushPendingOperatorGraph(); Merge(_defaults, fields); break; case "template": + FlushPendingOperatorGraph(); var name = Required(fields, "name", line); _templates[name] = Without(fields, "name"); break; case "voice": + FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line)); break; case "opgraph": - AddOperatorGraph(fields, line); + StartOperatorGraph(fields, line); + break; + case "operator": + AddOperator(fields, line); + break; + case "route": + AddOperatorRoute(fields, line); + break; + case "carrier": + AddOperatorCarrier(fields, line); break; case "mod": + FlushPendingOperatorGraph(); AddModBus(fields, line); break; case "control": + FlushPendingOperatorGraph(); AddControlLane(fields, line); break; case "param": + FlushPendingOperatorGraph(); AddParameter(fields, line); break; case "sfxr": + FlushPendingOperatorGraph(); AddSfxrPatch(ParseSfxrCommand(fields, line)); break; default: @@ -260,15 +284,102 @@ public static class PatchScript ? ParseOperatorIds(carrierSpec, line) : operators.Select(op => op.Id).ToList(); - var operatorIds = operators.Select(op => op.Id).ToHashSet(); - foreach (var edge in edges) + AddValidatedOperatorGraph(line, new OperatorGraph( + GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"), + GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"), + operators, + edges, + carriers, + GetBoundFloat(fields, line, 0.2f, $"{graphPath}/gain", "gain", "g"))); + } + + private void StartOperatorGraph(IReadOnlyDictionary fields, int line) + { + FlushPendingOperatorGraph(); + if (fields.ContainsKey("ops")) + { + AddOperatorGraph(fields, line); + return; + } + + var graphIndex = _operatorGraphs.Count; + var graphPath = $"/opgraphs/{graphIndex}"; + _pendingOperatorGraph = new PendingOperatorGraph( + line, + GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"), + GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"), + GetBoundFloat(fields, line, 0.2f, $"{graphPath}/gain", "gain", "g")); + } + + private void AddOperator(IReadOnlyDictionary fields, int line) + { + var graph = RequiredPendingOperatorGraph(line); + var id = ParseOperatorId(Required(fields, "name", line), line); + var envelope = TryGetAny(fields, ["env", "envelope"], out var envSpec) + ? ParseEnvelopeSpec(envSpec, line) + : new Envelope( + GetFloat(fields, line, 0, "attack", "a"), + GetFloat(fields, line, 0.1f, "sustain", "s"), + GetFloat(fields, line, 0.1f, "decay", "d"), + GetFloat(fields, line, 0, "punch", "pu")); + + graph.Operators.Add(new OperatorNode( + id, + GetFloat(fields, line, 1, "ratio", "r"), + GetFloat(fields, line, 1, "level", "l"), + GetFloat(fields, line, 0, "feedback", "fb"), + 0, + GetFloat(fields, line, 0, "release"), + envelope)); + } + + private void AddOperatorRoute(IReadOnlyDictionary fields, int line) + { + var graph = RequiredPendingOperatorGraph(line); + var source = ParseOperatorId(Required(fields, "from", line), line); + var target = ParseOperatorId(Required(fields, "to", line), line); + graph.Edges.Add(new OperatorEdge(source, target, GetFloat(fields, line, 1, "index", "amount", "depth"))); + } + + private void AddOperatorCarrier(IReadOnlyDictionary fields, int line) + { + var graph = RequiredPendingOperatorGraph(line); + graph.Carriers.Add(ParseOperatorId(Required(fields, "name", line), line)); + } + + private PendingOperatorGraph RequiredPendingOperatorGraph(int line) => + _pendingOperatorGraph ?? throw new PatchScriptException(line, "operator graph command needs an active opgraph"); + + private void FlushPendingOperatorGraph() + { + if (_pendingOperatorGraph is null) return; + var graph = _pendingOperatorGraph; + _pendingOperatorGraph = null; + AddValidatedOperatorGraph(graph.Line, new OperatorGraph( + graph.Name, + graph.FrequencyHz, + graph.Operators, + graph.Edges, + graph.Carriers.Count > 0 ? graph.Carriers : graph.Operators.Select(op => op.Id).ToList(), + graph.Gain)); + } + + private void AddValidatedOperatorGraph(int line, OperatorGraph graph) + { + if (graph.Operators.Count == 0) + { + throw new PatchScriptException(line, "operator graph needs at least one operator"); + } + + var operatorIds = graph.Operators.Select(op => op.Id).ToHashSet(); + foreach (var edge in graph.Edges) { if (!operatorIds.Contains(edge.SourceId) || !operatorIds.Contains(edge.TargetId)) { throw new PatchScriptException(line, $"operator edge `{edge.SourceId}>{edge.TargetId}` references an unknown operator"); } } - foreach (var carrier in carriers) + foreach (var carrier in graph.Carriers) { if (!operatorIds.Contains(carrier)) { @@ -276,13 +387,7 @@ public static class PatchScript } } - _operatorGraphs.Add(new OperatorGraph( - GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"), - GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"), - operators, - edges, - carriers, - GetBoundFloat(fields, line, 0.2f, $"{graphPath}/gain", "gain", "g"))); + _operatorGraphs.Add(graph); } private void AddParameter(IReadOnlyDictionary fields, int line) @@ -381,6 +486,8 @@ public static class PatchScript ParseFloat(pieces[1], line), ParseFloat(pieces[2], line), pieces.Length >= 4 ? ParseFloat(pieces[3], line) : 0, + 0, + 0, pieces.Length >= 6 ? new Envelope(0, ParseFloat(pieces[4], line), ParseFloat(pieces[5], line)) : new Envelope()); }) .ToList(); @@ -413,6 +520,32 @@ public static class PatchScript .Select(part => ParseInt(part, line)) .ToList(); + private static Envelope ParseEnvelopeSpec(string value, int line) + { + var pieces = value.Split(':'); + return pieces[0].ToLowerInvariant() switch + { + "ad" when pieces.Length == 3 => new Envelope( + ParseFloat(pieces[1], line), + 0, + ParseFloat(pieces[2], line)), + "adsr" when pieces.Length == 5 => new Envelope( + ParseFloat(pieces[1], line), + ParseFloat(pieces[2], line), + ParseFloat(pieces[4], line), + ParseFloat(pieces[3], line)), + _ => throw new PatchScriptException(line, $"bad envelope `{value}`") + }; + } + + private static int ParseOperatorId(string value, int line) + { + var normalized = value.StartsWith("op", StringComparison.OrdinalIgnoreCase) + ? value[2..] + : value; + return ParseInt(normalized, line); + } + private void AddSfxrPatch(SfxrParams parameters) { var mapped = parameters.ToPatch(); @@ -468,6 +601,17 @@ public static class PatchScript if (TryGetAny(fields, ["arp_mod", "am"], out var arpMod)) parameters = parameters with { ArpMod = Math.Clamp(ParseFloat(arpMod, line), -1, 1) }; return parameters; } + + private sealed record PendingOperatorGraph( + int Line, + string Name, + float FrequencyHz, + float Gain) + { + public List Operators { get; } = []; + public List Edges { get; } = []; + public List Carriers { get; } = []; + } } private static readonly (string[] Keys, ModTarget Target)[] ModTargets = @@ -532,13 +676,16 @@ public static class PatchScript return (ParseModTarget(pieces[0], line), ParseFloat(pieces[1], line)); }); - private static string CanonicalCommand(string command) => command.ToLowerInvariant() switch + private static string CanonicalCommand(string command) => command.ToLowerInvariant() switch { "p" or "patch" => "patch", "d" or "default" or "defaults" => "defaults", "def" or "t" or "template" => "template", "v" or "voice" => "voice", "opgraph" or "ops" or "operators" => "opgraph", + "operator" or "op" => "operator", + "route" or "edge" => "route", + "carrier" or "out" => "carrier", "mod" or "wob" or "wobble" or "bus" => "mod", "lfo" or "control" => "control", "param" or "parameter" => "param", @@ -556,6 +703,8 @@ public static class PatchScript "a" => "attack", "s" => "sustain", "d" => "decay", + "from" or "src" => "from", + "to" or "dst" => "to", _ => field }; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 7ab5d4c..859aa41 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -14,3 +14,4 @@ {"kind":"dx7_reference_path","lesson":"DX7 algorithm topology is now derived from the reverse-engineered OPS algorithm ROM table, not hand-drawn diagrams. Topology exposes carrier operators, modulation edges, feedback-register writers, self-feedback operators, delayed-feedback targets, and raw ROM steps. Use this as pressure evidence before adding an Aquarium operator graph layer.","source":"DX7 algorithm topology slice on 2026-05-13; Ken Shirriff OPS ROM table"} {"kind":"dx7_reference_rebuilds","lesson":"Two DX7-style Aquarium rebuilds now make the pressure explicit: algorithm 32 maps cleanly to layered additive voices, but algorithm 8 exposes missing graph structure (operator modulation edges, summed modulators, and operator-local self-feedback). Do not patch this with more Voice fields; decide whether an operator graph model owns this invariant.","source":"DX7 rebuild catalog slice on 2026-05-13"} {"kind":"operator_graph_model","lesson":"DX7 algorithm-8 pressure justified a first-class OperatorGraph abstraction. OperatorGraph owns FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields. Remaining DX7 pressure is exact feedback-register timing and rate/level envelopes.","source":"operator graph implementation slice on 2026-05-13"} +{"kind":"operator_graph_authoring","lesson":"Operator graph authoring should use readable `operator`, `route`, and `carrier` declarations with `env=ad:...` or `env=adsr:...`. Compact `ops=`/`edges=` tuples are acceptable as importer/interchange scaffolding only, not the face of the DSL.","source":"user rejected number-soup operator syntax; readable operator graph syntax slice on 2026-05-13"} diff --git a/state/memory.json b/state/memory.json index 5e2cf02..891012f 100644 --- a/state/memory.json +++ b/state/memory.json @@ -49,8 +49,8 @@ }, "dx7_reference_path": { "status": "DX7 SysEx parser extracts voice edit buffers, packed voices, packed 32-voice banks, six operators, envelopes, algorithm, feedback, oscillator sync, LFO, transpose, voice name, checksum validation, ReferencePatch features, algorithm topology from the reverse-engineered OPS algorithm ROM table, two Aquarium DSL rebuild attempts, and first-class OperatorGraph support.", - "lesson": "Algorithm 32's additive six-carrier shape maps cleanly to six Aquarium voices. Algorithm 8 justified OperatorGraph: operators, modulation edges, carriers, and feedback belong to a graph authority, not Voice fields.", - "next": "Refine operator graph parameter binding and envelope shape; exact DX7 feedback-register timing remains separate pressure." + "lesson": "Algorithm 32's additive six-carrier shape maps cleanly to six Aquarium voices. Algorithm 8 justified OperatorGraph: operators, modulation edges, carriers, and feedback belong to a graph authority, not Voice fields. Readable authoring uses operator/route/carrier declarations; compact ops/edges tuples are scaffolding only.", + "next": "Refine operator graph parameter binding and envelope internals; exact DX7 feedback-register timing remains separate pressure." }, "runtime_parameters": { "rule": "Patch parameters are runtime controls, not compile-time constants.", @@ -60,7 +60,7 @@ }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "35 passed", - "commit": "pending operator graph model commit" + "result": "36 passed", + "commit": "pending readable operator graph syntax commit" } } diff --git a/state/scratch.md b/state/scratch.md index 7bcc737..316df5a 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -7,6 +7,16 @@ the synth library while Aquarium work continues elsewhere. Completed this slice: +- Replaced the DX7 algorithm-8 authoring surface with readable operator graph + syntax: `operator`, `route`, and `carrier` declarations. +- Added `env=ad:attack:decay` and `env=adsr:attack:sustain:level:release` + envelope forms for operator declarations. +- Kept compact `ops=`/`edges=` syntax as parser/interchange scaffolding, but it + is no longer the built-in authoring example. +- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 36 passed. + +Previous slice: + - Added a first-class `OperatorGraph` model with operators, modulation edges, carriers, graph gain, and operator feedback. - Added `opgraph` patch-script syntax. Example: @@ -17,7 +27,7 @@ Completed this slice: and DX7 rate/level envelopes, not graph ownership. - Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 35 passed. -Previous slice: +Earlier rebuild slice: - Added `ReferenceRebuild` and `ReferenceRebuildCatalog` for explicit reference-target rebuild attempts. @@ -74,7 +84,7 @@ Parameter slice: Next likely slice: -- Refine operator graphs with parameter bindings inside graph fields, then - decide how much DX7 rate/level envelope shape belongs in the shared model. +- Refine operator graphs with parameter bindings inside graph fields and decide + whether ADSR level semantics should stop borrowing `Envelope.Punch`. - Keep tests focused on structure first, then add rendered audio comparison once the render path is explicit. diff --git a/state/spine.yaml b/state/spine.yaml index b92264c..c31134e 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-13T01:24:10+01:00 +updated: 2026-05-13T11:46:38+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -29,6 +29,7 @@ live_invariants: - Aquarium owns its patch graph; external synths are references, not runtime dependencies. - Patch scripts lower into explicit graph records before Faust emission. - Operator graphs own FM topology: operators, modulation edges, carriers, and feedback. Do not hide this authority inside Voice fields. + - Readable operator graph authoring uses `operator`, `route`, and `carrier` declarations; compact `ops=`/`edges=` is only scaffolding/interchange. - Generated Faust should remain inspectable and deterministic. - Reference-patch correctness and synth parity tests live in AquariumSynthCSharp, not Aquarium. - Exposed patch parameters are runtime controls, not compile-time constants. @@ -46,7 +47,7 @@ reference_targets: source: "https://github.com/asb2m10/dexed" status: "DX7 SysEx parser, algorithm topology extraction, two Aquarium DSL rebuild attempts, and first-class operator graph support exist." first_slice: - - "Refine operator graph parameter binding and envelope shape." + - "Refine operator graph parameter binding and envelope internals." - "Add rendered/audio comparison once a render path is explicit." zynaddsubfx: priority: 2 @@ -81,8 +82,8 @@ reference_targets: next_agent_start_here: recommended_first_task: > - Refine operator graph parameter binding and envelope shape. Keep operator - topology in OperatorGraph, not Voice fields. + Refine operator graph parameter binding and envelope internals. Keep + readable authoring syntax as operator/route/carrier declarations. must_read: - AGENTS.md - docs/audio-doctrine.md @@ -94,6 +95,13 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-13 + summary: > + Added readable operator graph authoring syntax with `operator`, `route`, + and `carrier` declarations, plus `env=ad:attack:decay` and + `env=adsr:attack:sustain:level:release` forms. Converted the DX7 + algorithm-8 rebuild away from compact number soup. + verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 36 passed" - date: 2026-05-13 summary: > Added first-class OperatorGraph support with operators, modulation edges, diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 1b4e25b..e38aebd 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -279,6 +279,32 @@ public sealed class PatchScriptTests Assert.Contains("opgraph_0 = (opgraph_0_op_1) * 0.2;", export.Source); } + [Fact] + public void ReadableOperatorGraphSyntaxParsesRoutesCarriersAndEnvelopes() + { + var patch = PatchScript.Parse(""" + opgraph name=pair freq=220 gain=.2 + operator name=op2 ratio=2 level=.8 env=ad:.01:.2 + operator name=op1 ratio=1 level=1 env=adsr:.02:.1:.65:.3 + route from=op2 to=op1 index=1.4 + carrier name=op1 + """); + + var graph = Assert.Single(patch.OperatorGraphs); + var op2 = graph.Operators.Single(op => op.Id == 2); + var op1 = graph.Operators.Single(op => op.Id == 1); + + Assert.Equal("pair", graph.Name); + Assert.Equal([1], graph.Carriers); + Assert.Single(graph.Edges); + Assert.Equal(.01f, op2.Envelope.AttackSeconds, 5); + Assert.Equal(.2f, op2.Envelope.DecaySeconds, 5); + Assert.Equal(.02f, op1.Envelope.AttackSeconds, 5); + Assert.Equal(.1f, op1.Envelope.SustainSeconds, 5); + Assert.Equal(.65f, op1.Envelope.Punch, 5); + Assert.Equal(.3f, op1.Envelope.DecaySeconds, 5); + } + [Fact] public void FaustEmitterProducesWobbleSource() {