diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs index c8db295..cdbed47 100644 --- a/src/AquariumSynth.Dsl/FaustExport.cs +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -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 warnings) + private static void EmitOperatorGraph(StringBuilder source, Playback playback, OperatorGraph graph, string name, ParameterMap parameters, List 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 diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index 7c4b29d..5b9a865 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -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 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, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f2f766b..88cb8d4 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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"} diff --git a/state/memory.json b/state/memory.json index f781e2e..db4e6c3 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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" } } diff --git a/state/scratch.md b/state/scratch.md index 5135c98..cbda7b1 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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 diff --git a/state/spine.yaml b/state/spine.yaml index f54ab3c..a8ba6ae 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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 diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 42bbdea..338b2f5 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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() {