Add operator graph patch layer

This commit is contained in:
Meta Vens 2026-05-13 01:25:05 +01:00
commit 20203761c7
10 changed files with 280 additions and 41 deletions

View file

@ -681,30 +681,26 @@ public static class BuiltInScripts
hpf=0.02
lpf=0.9
opgraph
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
voice
freq=330
gain=0.16
gain=0.04
fm=2
fm_index=@/macro/brightness
fm_decay=0.22
voice
freq=660
gain=0.1
fm=3
fm_index=1.4
fm_decay=0.16
gain=0.025
drive=0.08
fold=0.05
voice
freq=990
gain=0.045
sustain=0.025
decay=0.34
fm=1
fm_index=0.55
fm_decay=0.12
""";
public static readonly IReadOnlyList<(string Name, string Script)> AdvancedReferenceScripts =

View file

@ -28,7 +28,7 @@ public static class FaustEmitter
public static FaustExport Emit(SynthPatch patch, FaustExportOptions? options = null)
{
options ??= new FaustExportOptions();
if (patch.Voices.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch));
if (patch.Voices.Count == 0 && patch.OperatorGraphs.Count == 0) throw new ArgumentException("cannot export an empty patch", nameof(patch));
var warnings = new List<string>();
var parameters = new ParameterMap(patch, warnings);
@ -77,8 +77,15 @@ public static class FaustEmitter
EmitVoice(source, patch, patch.Voices[i], i, name, parameters, warnings);
voices.Add(name);
}
for (var i = 0; i < patch.OperatorGraphs.Count; i++)
{
var name = $"opgraph_{i}";
EmitOperatorGraph(source, patch.OperatorGraphs[i], name, warnings);
voices.Add(name);
}
var final = $"({string.Join(" + ", voices)}) * {parameters.Expression("/patch/gain", patch.Gain)}";
var mix = voices.Count == 0 ? "0.0" : string.Join(" + ", voices);
var final = $"({mix}) * {parameters.Expression("/patch/gain", patch.Gain)}";
if (patch.SoftClip) final = $"softclip({final})";
var unbound = parameters.UnboundParameterIds().ToList();
if (unbound.Count > 0)
@ -208,6 +215,79 @@ public static class FaustEmitter
};
}
private static void EmitOperatorGraph(StringBuilder source, OperatorGraph graph, string name, List<string> warnings)
{
if (graph.Operators.Count == 0)
{
warnings.Add($"{name}: empty operator graph was ignored");
source.AppendLine($"{name} = 0.0;");
return;
}
var ordered = TopologicalOperators(graph, warnings, name);
var operatorIds = graph.Operators.Select(op => op.Id).ToHashSet();
var carrierIds = graph.Carriers.Where(operatorIds.Contains).ToList();
if (carrierIds.Count == 0)
{
warnings.Add($"{name}: operator graph has no valid carriers");
source.AppendLine($"{name} = 0.0;");
return;
}
source.AppendLine($"{name}_freq = {F(graph.FrequencyHz)};");
foreach (var op in ordered)
{
var opName = $"{name}_op_{op.Id}";
var incoming = graph.Edges
.Where(edge => edge.TargetId == op.Id && operatorIds.Contains(edge.SourceId))
.Select(edge => $"{name}_op_{edge.SourceId} * {F(edge.Index)}")
.ToList();
if (op.Feedback != 0)
{
incoming.Add($"{opName} * {F(op.Feedback)} : si.smoo");
warnings.Add($"{name}: operator {op.Id} feedback is approximated with smoothed self-reference");
}
var phaseMod = incoming.Count == 0 ? "0.0" : string.Join(" + ", incoming);
var envelope = $"env({F(op.Envelope.AttackSeconds)}, {F(op.Envelope.SustainSeconds)}, {F(op.Envelope.DecaySeconds)}, {F(op.Envelope.Punch)})";
source.AppendLine($"{opName} = sin(2.0 * ma.PI * (os.phasor(1.0, {name}_freq * {F(Math.Max(op.Ratio, 0))}) + ({phaseMod}) / ma.PI)) * {envelope} * {F(op.Level)};");
}
source.AppendLine($"{name} = ({string.Join(" + ", carrierIds.Select(id => $"{name}_op_{id}"))}) * {F(graph.Gain)};");
source.AppendLine();
}
private static IReadOnlyList<OperatorNode> TopologicalOperators(OperatorGraph graph, List<string> warnings, string name)
{
var byId = graph.Operators.ToDictionary(op => op.Id);
var emitted = new HashSet<int>();
var ordered = new List<OperatorNode>();
while (ordered.Count < graph.Operators.Count)
{
var ready = graph.Operators
.Where(op => !emitted.Contains(op.Id))
.Where(op => graph.Edges
.Where(edge => edge.TargetId == op.Id && byId.ContainsKey(edge.SourceId))
.All(edge => emitted.Contains(edge.SourceId)))
.OrderByDescending(op => op.Id)
.ToList();
if (ready.Count == 0)
{
warnings.Add($"{name}: operator graph has a cycle; remaining operators use declaration order");
ordered.AddRange(graph.Operators.Where(op => !emitted.Contains(op.Id)));
break;
}
foreach (var op in ready)
{
ordered.Add(op);
emitted.Add(op.Id);
}
}
return ordered;
}
private static string FormantExpression(string name, Voice voice)
{
if (voice.Formants.Count == 0) return $"{name}_phased";

View file

@ -89,6 +89,26 @@ public sealed record Modulator(
public sealed record ControlLane(string Name, Modulator Modulator);
public sealed record OperatorNode(
int Id,
float Ratio = 1,
float Level = 1,
float Feedback = 0,
Envelope Envelope = null!)
{
public Envelope Envelope { get; init; } = Envelope ?? new();
}
public sealed record OperatorEdge(int SourceId, int TargetId, float Index = 1);
public sealed record OperatorGraph(
string Name,
float FrequencyHz,
IReadOnlyList<OperatorNode> Operators,
IReadOnlyList<OperatorEdge> Edges,
IReadOnlyList<int> Carriers,
float Gain = 0.2f);
public sealed record PatchParameter(
string Path,
string Label,
@ -141,6 +161,7 @@ public sealed record Voice
public sealed record SynthPatch
{
public IReadOnlyList<Voice> Voices { get; init; } = Array.Empty<Voice>();
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>();
public IReadOnlyList<ParameterBinding> ParameterBindings { get; init; } = Array.Empty<ParameterBinding>();

View file

@ -19,9 +19,9 @@ public static class PatchScript
line = statement.Line;
}
if (compiler.Voices.Count == 0)
if (compiler.Voices.Count == 0 && compiler.OperatorGraphs.Count == 0)
{
throw new PatchScriptException(line, "patch script produced no voices");
throw new PatchScriptException(line, "patch script produced no voices or operator graphs");
}
return compiler.Build();
@ -31,11 +31,13 @@ public static class PatchScript
{
private readonly Dictionary<string, Dictionary<string, string>> _templates = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ControlLane> _controls = [];
private readonly List<OperatorGraph> _operatorGraphs = [];
private readonly List<PatchParameter> _parameters = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
public List<Voice> Voices { get; } = [];
public List<OperatorGraph> OperatorGraphs => _operatorGraphs;
private Repeat? Repeat { get; set; }
private float Gain { get; set; } = 1;
private bool SoftClip { get; set; } = true;
@ -43,6 +45,7 @@ public static class PatchScript
public SynthPatch Build() => new()
{
Voices = Voices,
OperatorGraphs = _operatorGraphs,
Controls = _controls,
Parameters = _parameters,
ParameterBindings = _parameterBindings,
@ -78,6 +81,9 @@ public static class PatchScript
case "voice":
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line));
break;
case "opgraph":
AddOperatorGraph(fields, line);
break;
case "mod":
AddModBus(fields, line);
break;
@ -242,6 +248,43 @@ public static class PatchScript
GetFloat(fields, line, 0, "bias", "b"))));
}
private void AddOperatorGraph(IReadOnlyDictionary<string, string> fields, int line)
{
var graphIndex = _operatorGraphs.Count;
var graphPath = $"/opgraphs/{graphIndex}";
var operators = ParseOperatorNodes(Required(fields, "ops", line), line);
var edges = TryGetAny(fields, ["edges", "e"], out var edgeSpec)
? ParseOperatorEdges(edgeSpec, line)
: [];
var carriers = TryGetAny(fields, ["carriers", "c"], out var carrierSpec)
? ParseOperatorIds(carrierSpec, line)
: operators.Select(op => op.Id).ToList();
var operatorIds = operators.Select(op => op.Id).ToHashSet();
foreach (var edge in 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)
{
if (!operatorIds.Contains(carrier))
{
throw new PatchScriptException(line, $"carrier `{carrier}` references an unknown operator");
}
}
_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")));
}
private void AddParameter(IReadOnlyDictionary<string, string> fields, int line)
{
var path = Required(fields, "path", line);
@ -323,6 +366,53 @@ public static class PatchScript
private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}";
private static List<OperatorNode> ParseOperatorNodes(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length is < 3 or > 6)
{
throw new PatchScriptException(line, $"bad operator `{part}`");
}
return new OperatorNode(
ParseInt(pieces[0], line),
ParseFloat(pieces[1], line),
ParseFloat(pieces[2], line),
pieces.Length >= 4 ? ParseFloat(pieces[3], line) : 0,
pieces.Length >= 6 ? new Envelope(0, ParseFloat(pieces[4], line), ParseFloat(pieces[5], line)) : new Envelope());
})
.ToList();
private static List<OperatorEdge> ParseOperatorEdges(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length is < 1 or > 2)
{
throw new PatchScriptException(line, $"bad operator edge `{part}`");
}
var nodes = pieces[0].Split('>');
if (nodes.Length != 2)
{
throw new PatchScriptException(line, $"bad operator edge `{part}`");
}
return new OperatorEdge(
ParseInt(nodes[0], line),
ParseInt(nodes[1], line),
pieces.Length == 2 ? ParseFloat(pieces[1], line) : 1);
})
.ToList();
private static List<int> ParseOperatorIds(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => ParseInt(part, line))
.ToList();
private void AddSfxrPatch(SfxrParams parameters)
{
var mapped = parameters.ToPatch();
@ -448,6 +538,7 @@ public static class PatchScript
"d" or "default" or "defaults" => "defaults",
"def" or "t" or "template" => "template",
"v" or "voice" => "voice",
"opgraph" or "ops" or "operators" => "opgraph",
"mod" or "wob" or "wobble" or "bus" => "mod",
"lfo" or "control" => "control",
"param" or "parameter" => "param",
@ -540,6 +631,11 @@ public static class PatchScript
? parsed
: throw new PatchScriptException(line, $"bad number `{value}`");
private static int ParseInt(string value, int line) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new PatchScriptException(line, $"bad integer `{value}`");
private static bool ParseBool(string value, int line) => value.ToLowerInvariant() switch
{
"true" or "1" or "yes" or "on" => true,

View file

@ -37,16 +37,16 @@ public static class ReferenceRebuildCatalog
"dx7/algo8-bright-pair",
BuiltInScripts.Dx7StyleAlgorithm8BrightPair,
[
new("carrier_operators", "1,3", "Approximated as two audible Aquarium voices."),
new("feedback_sources", "4", "Approximated with drive/fold and FM index decay on the brighter voice."),
new("carrier_operators", "1,3", "Represented as carriers in a first-class Aquarium operator graph."),
new("modulation_edges", "6->5,4+5->3,2->1", "Represented as explicit operator edges in the graph."),
new("feedback_sources", "4", "Represented as operator-local feedback approximation on operator 4."),
new("runtime_macros", "brightness,strike", "Field-site parameters expose useful sound controls without recompilation.")
],
[
new("modulation_edges", "6->5,4+5->3,2->1", "The current voice DSL cannot express separate six-operator routing."),
new("self_feedback_operators", "4", "There is no operator-local feedback edge; fold/drive is only a timbral approximation."),
new("dx7_feedback_register", "one-sample feedback register", "Aquarium operator feedback currently uses a smoothed self-reference approximation, not exact DX7 feedback register timing."),
new("operator_envelopes", "independent DX7 rate/level envelopes", "Aquarium has simpler per-voice envelopes, not per-operator EGs.")
],
"This patch is deliberately a pressure test. It sounds in the family, but the missing edges are real and should become evidence for an operator graph layer if more DX7 targets need the same shape.")
"This patch is deliberately a pressure test. The new operator graph owns the topology; exact DX7 envelope and feedback-register behavior remain pressure for later refinement.")
];
public static IEnumerable<ReferenceRebuild> All()

View file

@ -13,3 +13,4 @@
{"kind":"dx7_reference_path","lesson":"DX7 reference elaboration starts with structural extraction, not translation. The repo now parses 155-byte voice edit buffers, 128-byte packed voices, and 4104-byte packed 32-voice SysEx banks into neutral DX7 records with checksum validation and ReferencePatch features. Next pressure point is explicit algorithm topology metadata for carriers, modulation edges, and feedback edge location.","source":"DX7 SysEx parser slice on 2026-05-13"}
{"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"}

View file

@ -10,6 +10,7 @@
],
"own_here": [
"patch graph model",
"operator graph model",
"patch parameter contracts",
"reference patch contracts",
"reference rebuild catalog",
@ -47,9 +48,9 @@
"rule": "Add DSL abstractions only when reference rebuilds prove the missing invariant."
},
"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, and two Aquarium DSL rebuild attempts.",
"lesson": "Algorithm 32's additive six-carrier shape maps cleanly to six Aquarium voices. Algorithm 8 only approximates because Aquarium lacks explicit operator modulation edges, summed modulators, and operator-local self-feedback.",
"next": "Decide whether the repeated DX7 topology mismatch justifies a first-class operator graph model; map ownership before implementing it."
"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."
},
"runtime_parameters": {
"rule": "Patch parameters are runtime controls, not compile-time constants.",
@ -59,7 +60,7 @@
},
"last_verified": {
"command": "dotnet test AquariumSynthCSharp.slnx --no-restore",
"result": "34 passed",
"commit": "pending DX7 rebuild catalog commit"
"result": "35 passed",
"commit": "pending operator graph model commit"
}
}

View file

@ -7,6 +7,18 @@ the synth library while Aquarium work continues elsewhere.
Completed this slice:
- Added a first-class `OperatorGraph` model with operators, modulation edges,
carriers, graph gain, and operator feedback.
- Added `opgraph` patch-script syntax. Example:
`opgraph name=core freq=330 carriers=1,3 ops=6:4:.9,5:3:.8,4:2:.7:.18,3:2:.6,2:1:.75,1:1:.82 edges=6>5:1.1,5>3:.9,4>3:.75,2>1:.85`
- Faust emission now mixes operator graphs alongside normal voices.
- Updated the DX7 algorithm-8 rebuild to use a real operator graph for the
topology. The remaining missing feature is exact DX7 feedback-register timing
and DX7 rate/level envelopes, not graph ownership.
- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 35 passed.
Previous slice:
- Added `ReferenceRebuild` and `ReferenceRebuildCatalog` for explicit
reference-target rebuild attempts.
- Added two DX7-style Aquarium DSL rebuilds:
@ -19,7 +31,7 @@ Completed this slice:
instead of being hidden inside prose.
- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 34 passed.
Previous slice:
Earlier topology slice:
- Added DX7 algorithm topology metadata for algorithms 1-32 using Ken
Shirriff's reverse-engineered OPS algorithm ROM table.
@ -62,7 +74,7 @@ Parameter slice:
Next likely slice:
- Decide whether the repeated DX7 algorithm-8 mismatch justifies a first-class
operator graph model. Map ownership before implementing it.
- Refine operator graphs with parameter bindings inside graph fields, then
decide how much DX7 rate/level envelope shape belongs in the shared model.
- 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-13T00:54:02+01:00
updated: 2026-05-13T01:24:10+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -15,6 +15,7 @@ current_mechanism:
parser: src/AquariumSynth.Dsl/PatchScript.cs
model: src/AquariumSynth.Dsl/Model.cs
faust_emitter: src/AquariumSynth.Dsl/FaustExport.cs
operator_graph_model: src/AquariumSynth.Dsl/Model.cs
analysis: src/AquariumSynth.Dsl/AudioAnalysis.cs
built_ins: src/AquariumSynth.Dsl/BuiltInScripts.cs
dx7_references: src/AquariumSynth.Dsl/Dx7SysEx.cs
@ -27,6 +28,7 @@ current_mechanism:
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.
- 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.
@ -42,10 +44,10 @@ reference_targets:
priority: 1
purpose: "Structured FM/operator graph pressure test."
source: "https://github.com/asb2m10/dexed"
status: "DX7 SysEx parser, algorithm topology extraction, and two Aquarium DSL rebuild attempts exist; operator-graph decision is next."
status: "DX7 SysEx parser, algorithm topology extraction, two Aquarium DSL rebuild attempts, and first-class operator graph support exist."
first_slice:
- "Decide whether DX7 algorithm-8-style mismatches justify a first-class operator graph model."
- "If yes, map ownership before implementing it."
- "Refine operator graph parameter binding and envelope shape."
- "Add rendered/audio comparison once a render path is explicit."
zynaddsubfx:
priority: 2
purpose: "Additive, subtractive, PAD, formant, kit, and layered-instrument pressure test."
@ -79,9 +81,8 @@ reference_targets:
next_agent_start_here:
recommended_first_task: >
Decide whether the DX7 algorithm-8 rebuild mismatch justifies a first-class
operator graph model. Map ownership first; do not smuggle it into Voice
fields.
Refine operator graph parameter binding and envelope shape. Keep operator
topology in OperatorGraph, not Voice fields.
must_read:
- AGENTS.md
- docs/audio-doctrine.md
@ -93,6 +94,14 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-13
summary: >
Added first-class OperatorGraph support with operators, modulation edges,
carriers, operator feedback, parser syntax, and Faust emission mixed with
normal voices. Updated the DX7 algorithm-8 rebuild to use the operator
graph; remaining pressure is exact DX7 feedback-register timing and
rate/level envelopes.
verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 35 passed"
- date: 2026-05-13
summary: >
Added ReferenceRebuild catalog entries and two DX7-style Aquarium DSL

View file

@ -246,9 +246,16 @@ public sealed class PatchScriptTests
var topology = Dx7SysEx.AlgorithmTopology(8);
Assert.Equal(2, topology.CarrierOperators.Count);
Assert.True(patch.Voices.Count > topology.CarrierOperators.Count, "Approximation adds a shimmer layer beyond the two DX7 carriers.");
Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "modulation_edges");
Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "self_feedback_operators");
var graph = Assert.Single(patch.OperatorGraphs);
Assert.Equal([1, 3], graph.Carriers);
Assert.Equal(6, graph.Operators.Count);
Assert.Contains(graph.Edges, edge => edge.SourceId == 6 && edge.TargetId == 5);
Assert.Contains(graph.Edges, edge => edge.SourceId == 5 && edge.TargetId == 3);
Assert.Contains(graph.Edges, edge => edge.SourceId == 4 && edge.TargetId == 3);
Assert.Contains(graph.Edges, edge => edge.SourceId == 2 && edge.TargetId == 1);
Assert.Contains(graph.Operators, op => op.Id == 4 && op.Feedback > 0);
Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "modulation_edges");
Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "dx7_feedback_register");
Assert.Equal(2, patch.Parameters.Count);
Assert.Equal(3, patch.ParameterBindings.Count);
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/voices/0/fm/index");
@ -256,6 +263,22 @@ public sealed class PatchScriptTests
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/voices/1/env/decay");
}
[Fact]
public void OperatorGraphScriptParsesAndExportsFaust()
{
var patch = PatchScript.Parse("opgraph name=pair freq=220 gain=.2 carriers=1 ops=2:2:.8,1:1:1 edges=2>1:1.4");
var graph = Assert.Single(patch.OperatorGraphs);
var export = FaustEmitter.Emit(patch);
Assert.Equal("pair", graph.Name);
Assert.Equal(2, graph.Operators.Count);
Assert.Single(graph.Edges);
Assert.Empty(patch.Voices);
Assert.Contains("opgraph_0_op_2", export.Source);
Assert.Contains("opgraph_0_op_1", export.Source);
Assert.Contains("opgraph_0 = (opgraph_0_op_1) * 0.2;", export.Source);
}
[Fact]
public void FaustEmitterProducesWobbleSource()
{