Bind operator graph parameters
This commit is contained in:
parent
3e0f424d98
commit
5b6eb15fe3
7 changed files with 97 additions and 38 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue