Add readable operator graph syntax

This commit is contained in:
Meta Vens 2026-05-13 11:47:39 +01:00
commit a3c4e64211
8 changed files with 268 additions and 37 deletions

View file

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

View file

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

View file

@ -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<PatchParameter> _parameters = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
private PendingOperatorGraph? _pendingOperatorGraph;
public List<Voice> Voices { get; } = [];
public List<OperatorGraph> 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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<OperatorNode> Operators { get; } = [];
public List<OperatorEdge> Edges { get; } = [];
public List<int> 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
};

View file

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

View file

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

View file

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

View file

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

View file

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