diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index 3f47abf..4f04f0d 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -78,7 +78,10 @@ First deliverable: public-domain fixture. Aquarium candidate rendering and `dexed-py` reference rendering exist, and `analog1.syx` voice 13 has the first thresholded parity rebuild. The next pressure is a harder voice that actually needs audible - operator routing rather than a sine-like behavioral match. + operator routing rather than a sine-like behavioral match. Staged operator + envelopes now exist as readable `env=rl rates=... levels=...` declarations; + direct DX7 rate/level lowering on `PRC SYNTH1` still fails hard enough to + make EG timing/gain calibration the live pressure. Source: [Dexed](https://github.com/asb2m10/dexed) diff --git a/src/AquariumSynth.Dsl/Dx7SysEx.cs b/src/AquariumSynth.Dsl/Dx7SysEx.cs index c6da538..ce010df 100644 --- a/src/AquariumSynth.Dsl/Dx7SysEx.cs +++ b/src/AquariumSynth.Dsl/Dx7SysEx.cs @@ -46,6 +46,18 @@ public sealed record Dx7OperatorLevelApproximation( int VelocityOffset, string Notes); +public sealed record Dx7RateLevelEnvelopeApproximation( + RateLevelEnvelope Envelope, + float GateSeconds, + string Notes) +{ + public string ToScriptSpec() => + $"env=rl rates={F(Envelope.Rate1Seconds)},{F(Envelope.Rate2Seconds)},{F(Envelope.Rate3Seconds)},{F(Envelope.Rate4Seconds)} " + + $"levels={F(Envelope.Level1)},{F(Envelope.Level2)},{F(Envelope.Level3)},{F(Envelope.Level4)} gate={F(GateSeconds)}"; + + private static string F(float value) => value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); +} + public sealed record Dx7Operator( int Number, Dx7Envelope Envelope, @@ -199,6 +211,23 @@ public static class Dx7SysEx "Approximation: DX7 output level uses nonlinear scaling plus key/velocity offsets before envelope gain."); } + public static Dx7RateLevelEnvelopeApproximation ApproximateRateLevelEnvelope(Dx7Envelope envelope) + { + var l1 = Level(envelope.Level1); + var l2 = Level(envelope.Level2); + var l3 = Level(envelope.Level3); + var l4 = Level(envelope.Level4); + var r1 = SegmentSeconds(envelope.Rate1, Math.Abs(l1 - l4)); + var r2 = SegmentSeconds(envelope.Rate2, Math.Abs(l1 - l2)); + var r3 = SegmentSeconds(envelope.Rate3, Math.Abs(l2 - l3)); + var r4 = SegmentSeconds(envelope.Rate4, Math.Abs(l3 - l4)); + + return new Dx7RateLevelEnvelopeApproximation( + new RateLevelEnvelope(r1, l1, r2, l2, r3, l3, r4, l4), + Math.Max(r1 + r2 + r3, 0.02f), + "Approximation: DX7 EG uses four rate/level segments; Aquarium staged envelopes preserve the intermediate target levels."); + } + public static Dx7VoiceBank ParseBank(ReadOnlySpan bytes) { var data = PackedBankData(bytes); diff --git a/src/AquariumSynth.Dsl/FaustExport.cs b/src/AquariumSynth.Dsl/FaustExport.cs index b931f63..03d64ad 100644 --- a/src/AquariumSynth.Dsl/FaustExport.cs +++ b/src/AquariumSynth.Dsl/FaustExport.cs @@ -57,6 +57,9 @@ public static class FaustEmitter source.AppendLine("fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;"); source.AppendLine("release_start(a,d,g) = max(g, a + d);"); source.AppendLine("oneshot_adsr(a,d,s,r,g) = select2(age < a, select2(age < a + d, select2(age < release_start(a,d,g), select2(age < release_start(a,d,g) + r, 0.0, s * (1.0 - (age - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((age - a) / max(0.0001, d))), age / max(0.0001, a));"); + source.AppendLine("seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));"); + source.AppendLine("rl_release_start(r1,r2,r3,g) = max(g, r1 + r2 + r3);"); + source.AppendLine("rl4_env(r1,l1,r2,l2,r3,l3,r4,l4,g) = select2(age < r1, select2(age < r1 + r2, select2(age < r1 + r2 + r3, select2(age < rl_release_start(r1,r2,r3,g), select2(age < rl_release_start(r1,r2,r3,g) + r4, l4, seg(age, rl_release_start(r1,r2,r3,g), r4, l3, l4)), l3), seg(age, r1 + r2, r3, l2, l3)), seg(age, r1, r2, l1, l2)), seg(age, 0, r1, 0, l1));"); source.AppendLine("lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));"); source.AppendLine("lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);"); source.AppendLine("lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);"); @@ -279,6 +282,9 @@ public static class FaustEmitter : $"oneshot_adsr({attack}, {decay}, {sustain}, {release}, {gate})"; } + private static string RateLevelEnvelopeExpression(RateLevelEnvelope envelope, string gate) => + $"rl4_env({F(envelope.Rate1Seconds)}, {F(envelope.Level1)}, {F(envelope.Rate2Seconds)}, {F(envelope.Level2)}, {F(envelope.Rate3Seconds)}, {F(envelope.Level3)}, {F(envelope.Rate4Seconds)}, {F(envelope.Level4)}, {gate})"; + private static string OscillatorExpression(SynthPatch patch, Voice voice, int voiceIndex, string frequency, string duty, string fmIndex, ParameterMap parameters) { var hasFmMod = patch.Controls.Any(control => control.Modulator.Target == ModTarget.FmIndex) || @@ -346,18 +352,22 @@ public static class FaustEmitter .Select(edge => $"{name}_op_{edge.SourceId} * {parameters.Expression($"{graphPath}/routes/{edge.SourceId}>{edge.TargetId}/index", edge.Index)}") .ToList(); var externalPhaseMod = incoming.Count == 0 ? "0.0" : string.Join(" + ", incoming); - var envelope = EnvelopeExpression( - op.Envelope, - UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host ? graphNoteGate : F(op.Note.GateSeconds), - UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host, - field => field switch - { - "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) - }); + var envelope = op.RateLevelEnvelope is not null + ? RateLevelEnvelopeExpression( + op.RateLevelEnvelope, + UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host ? graphNoteGate : F(op.Note.GateSeconds)) + : EnvelopeExpression( + op.Envelope, + UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host ? graphNoteGate : F(op.Note.GateSeconds), + UsesHostPlayback(playback) || op.Note.Source == NoteSource.Host, + field => field switch + { + "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) + }); if (op.Feedback != 0) { var feedback = parameters.Expression($"{operatorPath}/feedback", op.Feedback); diff --git a/src/AquariumSynth.Dsl/Model.cs b/src/AquariumSynth.Dsl/Model.cs index 0c8caeb..e872035 100644 --- a/src/AquariumSynth.Dsl/Model.cs +++ b/src/AquariumSynth.Dsl/Model.cs @@ -40,6 +40,16 @@ public sealed record Envelope( public float DurationSeconds => AttackSeconds + DecaySeconds + ReleaseSeconds; } +public sealed record RateLevelEnvelope( + float Rate1Seconds, + float Level1, + float Rate2Seconds, + float Level2, + float Rate3Seconds, + float Level3, + float Rate4Seconds, + float Level4); + public enum NoteSource { OneShot, @@ -120,7 +130,8 @@ public sealed record OperatorNode( float Level = 1, float Feedback = 0, Note Note = null!, - Envelope Envelope = null!) + Envelope Envelope = null!, + RateLevelEnvelope? RateLevelEnvelope = null) { public Note Note { get; init; } = Note ?? new(); public Envelope Envelope { get; init; } = Envelope ?? new(); diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index 5b9a865..7a6f336 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -380,7 +380,7 @@ public static class PatchScript 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, operatorPath) + ? ParseEnvelopeSpec(envSpec, fields, line, operatorPath) : new ParsedEnvelope( new Envelope( GetBoundFloat(fields, line, 0, $"{operatorPath}/env/attack", "attack", "a"), @@ -395,7 +395,8 @@ public static class PatchScript GetBoundFloat(fields, line, 1, $"{operatorPath}/level", "level", "l"), GetBoundFloat(fields, line, 0, $"{operatorPath}/feedback", "feedback", "fb"), graph.Note with { GateSeconds = envelopeSpec.GateSeconds }, - envelopeSpec.Envelope)); + envelopeSpec.Envelope, + envelopeSpec.RateLevelEnvelope)); } private void AddOperatorRoute(IReadOnlyDictionary fields, int line) @@ -585,7 +586,7 @@ public static class PatchScript .Select(part => ParseInt(part, line)) .ToList(); - private ParsedEnvelope ParseEnvelopeSpec(string value, int line, string fieldPath) + private ParsedEnvelope ParseEnvelopeSpec(string value, IReadOnlyDictionary fields, int line, string fieldPath) { var pieces = value.Split(':'); return pieces[0].ToLowerInvariant() switch @@ -599,7 +600,9 @@ public static class PatchScript 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), + GateSeconds(fields, line, 0.1f, fieldPath)), + "rl" or "ratelevel" when pieces.Length == 1 => ParseRateLevelEnvelope(fields, line, fieldPath), + "rl" or "ratelevel" when pieces.Length == 9 => ParseRateLevelEnvelope(pieces, fields, line, fieldPath), _ => throw new PatchScriptException(line, $"bad envelope `{value}`") }; } @@ -607,6 +610,57 @@ public static class PatchScript private static ParsedEnvelope AdEnvelope(float attackSeconds, float decaySeconds) => new(new Envelope(attackSeconds, decaySeconds, 0, 0), attackSeconds + decaySeconds); + private ParsedEnvelope ParseRateLevelEnvelope(IReadOnlyDictionary fields, int line, string fieldPath) + { + var rates = ParseFloatList(Required(fields, "rates", line), line, "rates"); + var levels = ParseFloatList(Required(fields, "levels", line), line, "levels"); + if (rates.Count != 4 || levels.Count != 4) + { + throw new PatchScriptException(line, "rate/level envelope needs four rates and four levels"); + } + + return RateLevelParsedEnvelope(rates, levels, fields, line, fieldPath); + } + + private ParsedEnvelope ParseRateLevelEnvelope(string[] pieces, IReadOnlyDictionary fields, int line, string fieldPath) + { + var rates = new[] { pieces[1], pieces[3], pieces[5], pieces[7] } + .Select(part => ParseFloat(part, line)) + .ToArray(); + var levels = new[] { pieces[2], pieces[4], pieces[6], pieces[8] } + .Select(part => ParseFloat(part, line)) + .ToArray(); + return RateLevelParsedEnvelope(rates, levels, fields, line, fieldPath); + } + + private ParsedEnvelope RateLevelParsedEnvelope(IReadOnlyList rates, IReadOnlyList levels, IReadOnlyDictionary fields, int line, string fieldPath) + { + var envelope = new RateLevelEnvelope( + Math.Max(0, rates[0]), Math.Clamp(levels[0], 0, 1.5f), + Math.Max(0, rates[1]), Math.Clamp(levels[1], 0, 1.5f), + Math.Max(0, rates[2]), Math.Clamp(levels[2], 0, 1.5f), + Math.Max(0, rates[3]), Math.Clamp(levels[3], 0, 1.5f)); + var defaultGate = Math.Max(envelope.Rate1Seconds + envelope.Rate2Seconds + envelope.Rate3Seconds, 0.02f); + return new ParsedEnvelope( + new Envelope(envelope.Rate1Seconds, envelope.Rate2Seconds + envelope.Rate3Seconds, envelope.Level3, envelope.Rate4Seconds), + GateSeconds(fields, line, defaultGate, fieldPath), + envelope); + } + + private float GateSeconds(IReadOnlyDictionary fields, int line, float defaultValue, string fieldPath) => + GetBoundFloat(fields, line, defaultValue, $"{fieldPath}/note/gate", "gate", "hold", "duration"); + + private static IReadOnlyList ParseFloatList(string value, int line, string fieldName) + { + var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) + { + throw new PatchScriptException(line, $"{fieldName} list cannot be empty"); + } + + return parts.Select(part => ParseFloat(part, line)).ToArray(); + } + private static int ParseOperatorId(string value, int line) { var normalized = value.StartsWith("op", StringComparison.OrdinalIgnoreCase) @@ -684,7 +738,7 @@ public static class PatchScript public List Carriers { get; } = []; } - private sealed record ParsedEnvelope(Envelope Envelope, float GateSeconds); + private sealed record ParsedEnvelope(Envelope Envelope, float GateSeconds, RateLevelEnvelope? RateLevelEnvelope = null); } private static readonly (string[] Keys, ModTarget Target)[] ModTargets = diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5fb5b50..b503f13 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -25,3 +25,4 @@ {"kind":"dx7_rendered_parity","lesson":"The first thresholded rendered DX7 parity target is analog1.syx voice 13, MC-MM 5-3. Aquarium matches the rendered behavior with a short sine-like patch and explicit score/log-mel/envelope/duration/RMS/zero-crossing/centroid thresholds. Treat this as proof of the parity loop, not proof of exact DX7 operator execution; next target should audibly require operator routing.","source":"first thresholded DX7 rendered parity slice on 2026-05-13"} {"kind":"operator_feedback","lesson":"A harder DX7 algorithm-8 target exposed direct operator self-reference as non-renderable Faust. Operator feedback now emits as delayed Faust recursion, which keeps the render path alive and is closer to DX7 feedback pressure; exact DX7 feedback scaling remains unsolved.","source":"PRC SYNTH1 parity probe and delayed feedback render fix on 2026-05-13"} {"kind":"dx7_operator_levels","lesson":"DX7 operator output level is nonlinear and includes key/velocity offsets before envelope gain. Added a reference-import helper to approximate effective Aquarium operator levels, but PRC SYNTH1 still only improved to ~0.34 with static levels and ~0.42 with envelope tweaks; hard parity now points at DX7-style operator envelope/gain evolution rather than static level mapping alone.","source":"DX7 effective operator level probe on 2026-05-13"} +{"kind":"staged_operator_envelopes","lesson":"Operator graphs now have a readable staged envelope surface, env=rl rates=... levels=..., backed by RateLevelEnvelope and Faust rl4_env rendering. This exercises the DSL with DX7-style multi-target contours without changing the general ADSR voice envelope. Direct DX7 rate/level lowering on PRC SYNTH1 peaked around ~0.316 in quick probes, so the next parity pressure is calibrated DX7 EG timing/gain rather than more syntax.","source":"staged operator envelope slice and PRC SYNTH1 probe on 2026-05-13"} diff --git a/state/memory.json b/state/memory.json index bc5a67d..0dc3dad 100644 --- a/state/memory.json +++ b/state/memory.json @@ -21,6 +21,7 @@ "DX7 SysEx structural parsing", "DX7 algorithm topology metadata", "DX7 effective operator-level approximation", + "staged operator envelope DSL", "DX7 envelope-to-ADSR approximation", "standard ADSR envelope model", "note frequency/gate model", @@ -58,9 +59,9 @@ "rule": "Add DSL abstractions only when reference rebuilds prove the missing invariant. Do not consume references that cannot be backed by parity tests; parsing alone is inventory, not evidence of expressivity." }, "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, envelope-to-ADSR approximations, effective operator-level approximations, two Aquarium DSL rebuild attempts, first-class OperatorGraph support, public-domain SysEx fixture coverage, dexed-py reference rendering, Aquarium candidate rendering through Faust-generated C#, one thresholded rendered parity test for analog1.syx voice 13, and renderable delayed operator feedback.", - "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. The first rendered parity rung is behavioral and sine-like, not exact DX7 operator execution. A harder algorithm-8 target exposed that feedback must be delayed recursion in Faust, not a direct cyclic self-reference. Static DX7 output-level approximation helps import/rebuild math but does not solve hard parity alone; PRC SYNTH1 still points at DX7-style operator envelope/gain evolution. Readable authoring uses operator/route/carrier declarations; compact ops/edges tuples are scaffolding only.", - "next": "Choose whether to add a DX7-style four-rate/four-level operator envelope surface or a reference-rebuild-only gain-envelope approximation before attempting the next hard parity threshold; exact DX7 feedback scaling remains separate pressure." + "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, envelope-to-ADSR approximations, effective operator-level approximations, staged operator envelopes, two Aquarium DSL rebuild attempts, first-class OperatorGraph support, public-domain SysEx fixture coverage, dexed-py reference rendering, Aquarium candidate rendering through Faust-generated C#, one thresholded rendered parity test for analog1.syx voice 13, and renderable delayed operator feedback.", + "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. The first rendered parity rung is behavioral and sine-like, not exact DX7 operator execution. A harder algorithm-8 target exposed that feedback must be delayed recursion in Faust, not a direct cyclic self-reference. Static DX7 output-level approximation helps import/rebuild math but does not solve hard parity alone. Staged operator envelopes now let Aquarium express four-rate/four-level contours in readable DSL, but direct DX7 lowering on PRC SYNTH1 only reached ~0.316 in quick probes; the next problem is DX7 EG timing/gain calibration, not more surface.", + "next": "Calibrate DX7 EG timing/gain lowering against PRC SYNTH1 before trying another hard DX7 parity threshold; exact DX7 feedback scaling remains separate pressure." }, "note_and_envelope_model": { "rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.", @@ -75,7 +76,7 @@ }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "50 passed", - "commit": "pending DX7 operator level approximation" + "result": "53 passed", + "commit": "pending staged operator envelopes" } } diff --git a/state/scratch.md b/state/scratch.md index bac85f2..a542637 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -15,6 +15,23 @@ Doctrine update: Completed this slice: +- Added staged operator envelopes for operator graphs: + `env=rl rates=.004,.12,.2,.4 levels=1,.7,.25,0`. This gives the DSL a + readable four-stage rate/level contour without infecting the general ADSR + voice envelope model. +- Added `RateLevelEnvelope` to the operator model, parser support for readable + and compact `env=rl` syntax, Faust `rl4_env(...)` rendering, and a DX7 helper + that emits staged envelope script specs from four-rate/four-level operator EG + data. +- Verified the surface structurally and through the render path: + `dotnet test AquariumSynthCSharp.slnx --no-restore`: 53 passed. +- Retried the hard `PRC SYNTH1` probe with direct DX7 rate/level lowering. The + best quick score was only ~0.316, worse than the hand-tweaked ADSR candidate. + The DSL can now express the missing contour shape, but DX7 EG timing/gain + calibration remains the next pressure. + +Previous slice: + - Added `Dx7OperatorLevelApproximation`, which distills DX7 operator output level plus key/velocity scaling into a normal Aquarium `level=`-style value. This remains DX7 reference-import knowledge, not a generic DSL feature. diff --git a/state/spine.yaml b/state/spine.yaml index 2dbc879..5002c3e 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-13T15:43:00+01:00 +updated: 2026-05-13T16:28:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -43,7 +43,8 @@ live_invariants: - Operator graph parameter binding also uses exact field-site paths under `/opgraphs/...`; do not add graph-wide macro routing. - Operator feedback must render through Faust as delayed recursion, not as a direct cyclic self-reference. - DX7 operator output level approximation belongs in reference import/rebuild helpers, not the generic DSL model. - - DX7 rate/level envelopes may lower to labeled ADSR approximations, but exact DX7 EG execution remains missing pressure. + - Staged operator envelopes own multi-target operator level contours through `env=rl rates=... levels=...`; do not force DX7-style contours back into the general ADSR voice envelope model. + - DX7 rate/level envelopes may lower to staged operator envelopes or labeled ADSR approximations, but exact DX7 EG timing/gain calibration remains missing pressure. - 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. - Public-domain reference fixtures and dexed-py helpers are test/development material only; the NuGet package must ship only AquariumSynth.Dsl. @@ -57,7 +58,7 @@ reference_targets: priority: 1 purpose: "Structured FM/operator graph pressure test." source: "https://github.com/asb2m10/dexed" - status: "DX7 SysEx parser, public-domain SysEx fixture, dexed-py reference rendering, first thresholded rendered parity test for analog1.syx voice 13, renderable delayed operator feedback, DX7 effective operator-level approximation, algorithm topology extraction, envelope-to-ADSR approximation, two Aquarium DSL rebuild attempts, first-class operator graph support, and Aquarium candidate rendering through Faust-generated C# exist." + status: "DX7 SysEx parser, public-domain SysEx fixture, dexed-py reference rendering, first thresholded rendered parity test for analog1.syx voice 13, renderable delayed operator feedback, DX7 effective operator-level approximation, staged operator envelopes, algorithm topology extraction, envelope-to-ADSR approximation, two Aquarium DSL rebuild attempts, first-class operator graph support, and Aquarium candidate rendering through Faust-generated C# exist." first_slice: - "Add a harder public-domain DX7 parity target that audibly needs operator routing." zynaddsubfx: @@ -93,10 +94,10 @@ reference_targets: next_agent_start_here: recommended_first_task: > - Choose a harder public-domain DX7 fixture voice whose rendered audio needs - audible operator routing, rebuild it with a readable Aquarium operator - graph, and compare dexed-py reference audio against Faust-rendered Aquarium - candidate audio with explicit parity thresholds. + Calibrate DX7 EG timing/gain approximation against the harder public-domain + `PRC SYNTH1` target before trying to bless another hard threshold. The DSL + now has staged operator envelopes; the missing work is making the DX7 + lowering land close enough to prove the emulation path. must_read: - AGENTS.md - docs/audio-doctrine.md @@ -108,6 +109,16 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-13 + summary: > + Added staged operator envelopes for operator graphs through + `env=rl rates=... levels=...`, backed by a `RateLevelEnvelope` model, + parser support, Faust `rl4_env(...)` rendering, and DX7 helper output + from four-rate/four-level operator EG data. Direct DX7 rate/level + lowering on `PRC SYNTH1` only reached ~0.316 in quick probes, so the DSL + surface exists but DX7 EG timing/gain calibration remains the next + pressure. + verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 53 passed" - date: 2026-05-13 summary: > Added `Dx7OperatorLevelApproximation`, which maps DX7 operator output diff --git a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs index c634717..ba8c9db 100644 --- a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs @@ -199,6 +199,28 @@ public sealed class Dx7SysExTests Assert.Contains("nonlinear", operatorLevel.Notes); } + [Fact] + public void ApproximatesDx7RateLevelEnvelopeAsStagedEnvelope() + { + var approximation = Dx7SysEx.ApproximateRateLevelEnvelope(new Dx7Envelope( + Rate1: 98, + Rate2: 72, + Rate3: 75, + Rate4: 61, + Level1: 99, + Level2: 89, + Level3: 99, + Level4: 0)); + + Assert.Equal(1, approximation.Envelope.Level1); + Assert.Equal(89 / 99f, approximation.Envelope.Level2, 5); + Assert.Equal(1, approximation.Envelope.Level3); + Assert.Equal(0, approximation.Envelope.Level4); + Assert.True(approximation.GateSeconds >= approximation.Envelope.Rate1Seconds + approximation.Envelope.Rate2Seconds + approximation.Envelope.Rate3Seconds); + Assert.StartsWith("env=rl rates=", approximation.ToScriptSpec()); + Assert.Contains("intermediate target levels", approximation.Notes); + } + private static byte[] InitVoice(string name, int algorithm, int feedback) { var data = new byte[Dx7SysEx.VoiceEditBufferLength]; diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index 4ef3fde..bff12f2 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -308,6 +308,34 @@ public sealed class PatchScriptTests Assert.Equal(.21f, op2.Note.GateSeconds, 5); } + [Fact] + public void OperatorGraphSyntaxParsesReadableRateLevelEnvelope() + { + var patch = PatchScript.Parse(""" + opgraph name=pair freq=220 gain=.2 + operator name=op2 ratio=2 level=.8 env=rl rates=.004,.12,.2,.4 levels=1,.7,.25,0 gate=.75 + operator name=op1 ratio=1 level=1 env=adsr:.02:.1:.65:.3 + route from=op2 to=op1 index=.8 + carrier name=op1 + """); + var export = FaustEmitter.Emit(patch); + + var op2 = Assert.Single(patch.OperatorGraphs[0].Operators, op => op.Id == 2); + Assert.NotNull(op2.RateLevelEnvelope); + var envelope = op2.RateLevelEnvelope; + + Assert.Equal(.004f, envelope.Rate1Seconds, 5); + Assert.Equal(1, envelope.Level1); + Assert.Equal(.12f, envelope.Rate2Seconds, 5); + Assert.Equal(.7f, envelope.Level2, 5); + Assert.Equal(.2f, envelope.Rate3Seconds, 5); + Assert.Equal(.25f, envelope.Level3, 5); + Assert.Equal(.4f, envelope.Rate4Seconds, 5); + Assert.Equal(0, envelope.Level4); + Assert.Equal(.75f, op2.Note.GateSeconds, 5); + Assert.Contains("rl4_env(0.004, 1, 0.12, 0.7, 0.2, 0.25, 0.4, 0, 0.75)", export.Source); + } + [Fact] public void ReadableOperatorGraphSyntaxBindsParametersAtFieldSites() { @@ -528,4 +556,25 @@ public sealed class PatchScriptTests Assert.True(render.Samples.Length > 1000, render.Stderr); Assert.True(render.Samples.Max(MathF.Abs) > 0.001f, render.Stderr); } + + [Fact] + public async Task FaustCompilerRendersRateLevelOperatorEnvelopeWhenInstalled() + { + var export = FaustEmitter.EmitScript(""" + opgraph name=rl freq=220 gain=.2 + operator name=op2 ratio=2 level=.8 env=rl rates=.004,.08,.12,.16 levels=1,.7,.25,0 + operator name=op1 ratio=1 level=1 env=adsr:.004:.08:.7:.12 + route from=op2 to=op1 index=.4 + carrier name=op1 + """); + var render = await FaustCompiler.RenderAsync(export.Source, new FaustRenderOptions(DurationSeconds: .25f)); + + if (render is null) + { + return; + } + + Assert.True(render.Samples.Length > 1000, render.Stderr); + Assert.True(render.Samples.Max(MathF.Abs) > 0.001f, render.Stderr); + } }