Bind operator graph parameters

This commit is contained in:
Meta Vens 2026-05-13 16:50:43 +01:00
commit 5b6eb15fe3
7 changed files with 97 additions and 38 deletions

View file

@ -88,7 +88,7 @@ public static class FaustEmitter
for (var i = 0; i < patch.OperatorGraphs.Count; i++)
{
var name = $"opgraph_{i}";
EmitOperatorGraph(source, patch.Playback, patch.OperatorGraphs[i], name, warnings);
EmitOperatorGraph(source, patch.Playback, patch.OperatorGraphs[i], name, parameters, warnings);
voices.Add(name);
}
@ -296,7 +296,7 @@ public static class FaustEmitter
};
}
private static void EmitOperatorGraph(StringBuilder source, Playback playback, OperatorGraph graph, string name, List<string> warnings)
private static void EmitOperatorGraph(StringBuilder source, Playback playback, OperatorGraph graph, string name, ParameterMap parameters, List<string> warnings)
{
if (graph.Operators.Count == 0)
{
@ -315,16 +315,18 @@ public static class FaustEmitter
return;
}
var graphIndex = GraphIndex(name);
var graphPath = $"/opgraphs/{graphIndex}";
var graphNoteFreq = UsesHostPlayback(playback)
? "freq"
: graph.Note.Source == NoteSource.Host
? $"{name}_note_freq"
: F(graph.Note.FrequencyHz);
: parameters.Expression($"{graphPath}/note/frequency", graph.Note.FrequencyHz);
var graphNoteGate = UsesHostPlayback(playback)
? "gate"
: graph.Note.Source == NoteSource.Host
? $"{name}_note_gate"
: F(graph.Note.GateSeconds);
: parameters.Expression($"{graphPath}/note/gate", graph.Note.GateSeconds);
if (!UsesHostPlayback(playback) && graph.Note.Source == NoteSource.Host)
{
source.AppendLine($"{name}_note_freq = hslider(\"/{name}/note/frequency\", {F(graph.Note.FrequencyHz)}, 20, 20000, 0.01) : si.smoo;");
@ -334,13 +336,14 @@ public static class FaustEmitter
foreach (var op in ordered)
{
var opName = $"{name}_op_{op.Id}";
var operatorPath = $"{graphPath}/operators/{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)}")
.Select(edge => $"{name}_op_{edge.SourceId} * {parameters.Expression($"{graphPath}/routes/{edge.SourceId}>{edge.TargetId}/index", edge.Index)}")
.ToList();
if (op.Feedback != 0)
{
incoming.Add($"{opName} * {F(op.Feedback)} : si.smoo");
incoming.Add($"{opName} * {parameters.Expression($"{operatorPath}/feedback", op.Feedback)} : si.smoo");
warnings.Add($"{name}: operator {op.Id} feedback is approximated with smoothed self-reference");
}
@ -351,16 +354,16 @@ public static class FaustEmitter
UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host,
field => field switch
{
"env/attack" => F(op.Envelope.AttackSeconds),
"env/decay" => F(op.Envelope.DecaySeconds),
"env/sustain_level" => F(op.Envelope.SustainLevel),
"env/release" => F(op.Envelope.ReleaseSeconds),
"env/attack" => parameters.Expression($"{operatorPath}/env/attack", op.Envelope.AttackSeconds),
"env/decay" => parameters.Expression($"{operatorPath}/env/decay", op.Envelope.DecaySeconds),
"env/sustain_level" => parameters.Expression($"{operatorPath}/env/sustain_level", op.Envelope.SustainLevel),
"env/release" => parameters.Expression($"{operatorPath}/env/release", op.Envelope.ReleaseSeconds),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, null)
});
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($"{opName} = sin(2.0 * ma.PI * (os.phasor(1.0, {name}_freq * max(0.0, {parameters.Expression($"{operatorPath}/ratio", op.Ratio)})) + ({phaseMod}) / ma.PI)) * {envelope} * {parameters.Expression($"{operatorPath}/level", op.Level)};");
}
source.AppendLine($"{name} = ({string.Join(" + ", carrierIds.Select(id => $"{name}_op_{id}"))}) * {F(graph.Gain)};");
source.AppendLine($"{name} = ({string.Join(" + ", carrierIds.Select(id => $"{name}_op_{id}"))}) * {parameters.Expression($"{graphPath}/gain", graph.Gain)};");
source.AppendLine();
}
@ -465,6 +468,15 @@ public static class FaustEmitter
private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}";
private static int GraphIndex(string name)
{
const string prefix = "opgraph_";
return name.StartsWith(prefix, StringComparison.Ordinal) &&
int.TryParse(name[prefix.Length..], NumberStyles.None, CultureInfo.InvariantCulture, out var index)
? index
: 0;
}
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
private sealed class ParameterMap

View file

@ -364,6 +364,7 @@ public static class PatchScript
var graphPath = $"/opgraphs/{graphIndex}";
_pendingOperatorGraph = new PendingOperatorGraph(
line,
graphPath,
GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"),
GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"),
new Note(
@ -377,21 +378,22 @@ public static class PatchScript
{
var graph = RequiredPendingOperatorGraph(line);
var id = ParseOperatorId(Required(fields, "name", line), line);
var operatorPath = $"{graph.Path}/operators/{id}";
var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec)
? ParseEnvelopeSpec(envSpec, line)
? ParseEnvelopeSpec(envSpec, line, operatorPath)
: new ParsedEnvelope(
new Envelope(
GetFloat(fields, line, 0, "attack", "a"),
GetFloat(fields, line, 0, "env_decay", "ed"),
GetFloat(fields, line, 1, "sustain_level", "sl"),
GetFloat(fields, line, 0.1f, "release", "rel", "decay", "d")),
GetFloat(fields, line, graph.Note.GateSeconds, "gate", "hold", "duration"));
GetBoundFloat(fields, line, 0, $"{operatorPath}/env/attack", "attack", "a"),
GetBoundFloat(fields, line, 0, $"{operatorPath}/env/decay", "env_decay", "ed"),
GetBoundFloat(fields, line, 1, $"{operatorPath}/env/sustain_level", "sustain_level", "sl"),
GetBoundFloat(fields, line, 0.1f, $"{operatorPath}/env/release", "release", "rel", "decay", "d")),
GetBoundFloat(fields, line, graph.Note.GateSeconds, $"{operatorPath}/note/gate", "gate", "hold", "duration"));
graph.Operators.Add(new OperatorNode(
id,
GetFloat(fields, line, 1, "ratio", "r"),
GetFloat(fields, line, 1, "level", "l"),
GetFloat(fields, line, 0, "feedback", "fb"),
GetBoundFloat(fields, line, 1, $"{operatorPath}/ratio", "ratio", "r"),
GetBoundFloat(fields, line, 1, $"{operatorPath}/level", "level", "l"),
GetBoundFloat(fields, line, 0, $"{operatorPath}/feedback", "feedback", "fb"),
graph.Note with { GateSeconds = envelopeSpec.GateSeconds },
envelopeSpec.Envelope));
}
@ -401,7 +403,7 @@ public static class PatchScript
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")));
graph.Edges.Add(new OperatorEdge(source, target, GetBoundFloat(fields, line, 1, $"{graph.Path}/routes/{source}>{target}/index", "index", "amount", "depth")));
}
private void AddOperatorCarrier(IReadOnlyDictionary<string, string> fields, int line)
@ -583,20 +585,20 @@ public static class PatchScript
.Select(part => ParseInt(part, line))
.ToList();
private static ParsedEnvelope ParseEnvelopeSpec(string value, int line)
private ParsedEnvelope ParseEnvelopeSpec(string value, int line, string fieldPath)
{
var pieces = value.Split(':');
return pieces[0].ToLowerInvariant() switch
{
"ad" when pieces.Length == 3 => AdEnvelope(
ParseFloat(pieces[1], line),
ParseFloat(pieces[2], line)),
ParseBoundFloat(pieces[1], line, 0, $"{fieldPath}/env/attack"),
ParseBoundFloat(pieces[2], line, 0.1f, $"{fieldPath}/env/decay")),
"adsr" when pieces.Length == 5 => new ParsedEnvelope(
new Envelope(
ParseFloat(pieces[1], line),
ParseFloat(pieces[2], line),
ParseFloat(pieces[3], line),
ParseFloat(pieces[4], line)),
ParseBoundFloat(pieces[1], line, 0, $"{fieldPath}/env/attack"),
ParseBoundFloat(pieces[2], line, 0, $"{fieldPath}/env/decay"),
ParseBoundFloat(pieces[3], line, 1, $"{fieldPath}/env/sustain_level"),
ParseBoundFloat(pieces[4], line, 0.1f, $"{fieldPath}/env/release")),
0.1f),
_ => throw new PatchScriptException(line, $"bad envelope `{value}`")
};
@ -671,6 +673,7 @@ public static class PatchScript
private sealed record PendingOperatorGraph(
int Line,
string Path,
string Name,
float FrequencyHz,
Note Note,

View file

@ -17,3 +17,4 @@
{"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"}
{"kind":"note_envelope_model","lesson":"Aquarium's general envelope model is ADSR: attack, decay, sustain level, release. Note timing belongs to `Note` records: frequency, gate duration, and host/MIDI source. SFXR sustain maps to note gate duration; SFXR punch maps to lower sustain level plus voice gain compensation so ADSR peak remains implicit 1 and the held level sits below the transient.","source":"user identified SFXR sustain as note duration and Punch as transient above sustain; punch mapping correction on 2026-05-13"}
{"kind":"faust_polyphony","lesson":"Prefer Faust-managed MIDI polyphony over Aquarium graph inflation. Aquarium should emit a single voice graph with standard `freq`, `gain`, and `gate` controls plus `[midi:on][nvoices:n]`; Faust architectures own MIDI decoding and voice allocation unless a concrete target proves the boundary insufficient.","source":"Faust official MIDI/polyphony docs and user preference for Faust support on 2026-05-13"}
{"kind":"operator_graph_parameters","lesson":"Readable operator graph fields bind runtime parameters at exact `/opgraphs/...` field paths: graph gain/note fields, operator ratio/level/feedback/envelope fields, and route index fields. Keep compact `ops=`/`edges=` as importer scaffolding and avoid graph-wide macro routing.","source":"operator graph field-site binding slice on 2026-05-13"}

View file

@ -53,7 +53,7 @@
"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. Readable authoring uses operator/route/carrier declarations; compact ops/edges tuples are scaffolding only.",
"next": "Refine operator graph parameter binding and Faust MIDI integration details; exact DX7 feedback-register timing remains separate pressure."
"next": "Add a neutral DX7 envelope approximation path and refine Faust MIDI integration details; exact DX7 feedback-register timing remains separate pressure."
},
"note_and_envelope_model": {
"rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.",
@ -64,11 +64,11 @@
"rule": "Patch parameters are runtime controls, not compile-time constants.",
"faust_surface": "Faust UI controls expose parameter zones that hosts can update with setParamValue/getParamValue-style APIs.",
"aquarium_requirement": "Aquarium DSL patches declare stable exposed parameters with path/default/min/max/step metadata so callers can vary sound without recompiling.",
"binding_rule": "Parameter binding uses field-site references such as `lpf=@/macro/brightness`; the field owns meaning and the parameter owns the runtime value. Avoid target-routing semantics unless a new graph authority is deliberately mapped."
"binding_rule": "Parameter binding uses field-site references such as `lpf=@/macro/brightness` and `/opgraphs/0/operators/2/level`; the field owns meaning and the parameter owns the runtime value. Avoid target-routing semantics unless a new graph authority is deliberately mapped."
},
"last_verified": {
"command": "dotnet test AquariumSynthCSharp.slnx --no-restore",
"result": "41 passed",
"commit": "pending Faust polyphony playback commit"
"result": "42 passed",
"commit": "pending operator graph parameter binding commit"
}
}

View file

@ -7,6 +7,17 @@ the synth library while Aquarium work continues elsewhere.
Completed this slice:
- Added field-site parameter binding for readable operator graph authoring:
graph gain/note fields, operator ratio/level/feedback/envelope fields, and
route index fields now accept `@/param`.
- Operator graph Faust emission now substitutes bound parameter expressions at
the exact `/opgraphs/...` field paths.
- Fixed AD operator envelope binding to use `/env/decay` for the second AD
value, matching the ADSR model instead of calling it release by accident.
- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 42 passed.
Previous slice:
- Added a patch-level `Playback` contract for `OneShot`, `Mono`, and `Poly`
playback with Faust MIDI polyphony settings.
- `instrument midi=true polyphony=8` now lowers to Faust's standard

View file

@ -1,6 +1,6 @@
project: AquariumSynthCSharp
spine_version: 1
updated: 2026-05-13T12:36:00+01:00
updated: 2026-05-13T12:55:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -36,6 +36,7 @@ live_invariants:
- Reference-patch correctness and synth parity tests live in AquariumSynthCSharp, not Aquarium.
- Exposed patch parameters are runtime controls, not compile-time constants.
- Parameter binding uses field-site references like `lpf=@/macro/brightness`; do not add target-routing semantics unless a later map proves a new authority is needed.
- Operator graph parameter binding also uses exact field-site paths under `/opgraphs/...`; do not add graph-wide macro routing.
- Recompile only for graph shape changes; vary declared parameters through the hosted DSP control API.
- Reference fixtures must preserve provenance, license status, source hash, and feature pressure.
- Audio metrics are evidence, not final judgment.
@ -49,7 +50,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 Faust MIDI integration details."
- "Add a neutral DX7 envelope approximation path."
- "Add rendered/audio comparison once a render path is explicit."
zynaddsubfx:
priority: 2
@ -84,9 +85,8 @@ reference_targets:
next_agent_start_here:
recommended_first_task: >
Refine operator graph parameter binding and Faust MIDI integration details.
Keep readable authoring syntax as operator/route/carrier
declarations.
Add a neutral DX7 envelope approximation path, keeping readable operator
graph authoring syntax as operator/route/carrier declarations.
must_read:
- AGENTS.md
- docs/audio-doctrine.md
@ -98,6 +98,13 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-13
summary: >
Added field-site parameter binding for readable operator graphs. Graph
gain/note fields, operator ratio/level/feedback/envelope fields, and route
index fields now bind through exact `/opgraphs/...` paths, and Faust
emission substitutes those bound expressions in operator graph output.
verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 42 passed"
- date: 2026-05-13
summary: >
Added a patch-level Playback contract and `instrument` authoring surface

View file

@ -306,6 +306,31 @@ public sealed class PatchScriptTests
Assert.Equal(.21f, op2.Note.GateSeconds, 5);
}
[Fact]
public void ReadableOperatorGraphSyntaxBindsParametersAtFieldSites()
{
var patch = PatchScript.Parse("""
param path=/macro/brightness default=.6 min=0 max=1 step=.001
param path=/macro/strike default=.08 min=.01 max=.5 step=.001
opgraph name=pair freq=220 gain=@/macro/brightness
operator name=op2 ratio=2 level=@/macro/brightness env=ad:.01:@/macro/strike
operator name=op1 ratio=1 level=1 env=adsr:.02:.1:.65:.3
route from=op2 to=op1 index=@/macro/brightness
carrier name=op1
""");
var export = FaustEmitter.Emit(patch);
Assert.Equal(4, patch.ParameterBindings.Count);
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/opgraphs/0/gain" && binding.ParameterPath == "/macro/brightness");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/opgraphs/0/operators/2/level" && binding.ParameterPath == "/macro/brightness");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/opgraphs/0/operators/2/env/decay" && binding.ParameterPath == "/macro/strike");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/opgraphs/0/routes/2>1/index" && binding.ParameterPath == "/macro/brightness");
Assert.Contains("opgraph_0_op_2 * patch_param_0", export.Source);
Assert.Contains("oneshot_adsr(0.01, patch_param_1, 0, 0", export.Source);
Assert.Contains("opgraph_0 = (opgraph_0_op_1) * patch_param_0;", export.Source);
Assert.Empty(export.Warnings);
}
[Fact]
public void FaustEmitterProducesWobbleSource()
{