diff --git a/docs/audio-doctrine.md b/docs/audio-doctrine.md index 4eef355..5da7148 100644 --- a/docs/audio-doctrine.md +++ b/docs/audio-doctrine.md @@ -115,6 +115,9 @@ authorities. controls with `[midi:on][nvoices:n]` options, leaving MIDI decoding and voice allocation to Faust architectures unless a concrete target proves that boundary insufficient. +- DX7 rate/level envelopes may be lowered to labeled ADSR approximations for + rebuild work, but that is not exact DX7 envelope execution. Keep exactness + pressure visible in reference features instead of smuggling it into ADSR. ## Metrics diff --git a/src/AquariumSynth.Dsl/Dx7SysEx.cs b/src/AquariumSynth.Dsl/Dx7SysEx.cs index 2551446..7b60ed1 100644 --- a/src/AquariumSynth.Dsl/Dx7SysEx.cs +++ b/src/AquariumSynth.Dsl/Dx7SysEx.cs @@ -28,6 +28,17 @@ public sealed record Dx7Envelope( int Level3, int Level4); +public sealed record Dx7EnvelopeApproximation( + Envelope Envelope, + float GateSeconds, + string Notes) +{ + public string ToScriptSpec() => + $"env=adsr:{F(Envelope.AttackSeconds)}:{F(Envelope.DecaySeconds)}:{F(Envelope.SustainLevel)}:{F(Envelope.ReleaseSeconds)} gate={F(GateSeconds)}"; + + private static string F(float value) => value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); +} + public sealed record Dx7Operator( int Number, Dx7Envelope Envelope, @@ -136,6 +147,26 @@ public static class Dx7SysEx return ParseVoiceEditBuffer(data); } + public static Dx7EnvelopeApproximation ApproximateEnvelope(Dx7Envelope envelope) + { + var l1 = Level(envelope.Level1); + var l2 = Level(envelope.Level2); + var l3 = Level(envelope.Level3); + var l4 = Level(envelope.Level4); + var peak = Math.Max(0.0001f, l1); + var attack = SegmentSeconds(envelope.Rate1, Math.Abs(l1 - l4)); + var decay = SegmentSeconds(envelope.Rate2, Math.Abs(l1 - l2)) + + SegmentSeconds(envelope.Rate3, Math.Abs(l2 - l3)); + var release = SegmentSeconds(envelope.Rate4, Math.Abs(l3 - l4)); + var sustain = Math.Clamp(l3 / peak, 0, 1); + var gate = Math.Max(attack + decay, 0.02f); + + return new Dx7EnvelopeApproximation( + new Envelope(attack, decay, sustain, release), + gate, + "Approximation: DX7 EG uses four rate/level segments; ADSR keeps one sustain level and a key-off release."); + } + public static Dx7VoiceBank ParseBank(ReadOnlySpan bytes) { var data = PackedBankData(bytes); @@ -350,6 +381,15 @@ public static class Dx7SysEx Value(data, 144)); } + private static float Level(int value) => Math.Clamp(value, 0, 99) / 99f; + + private static float SegmentSeconds(int rate, float distance) + { + var normalizedRate = Math.Clamp(rate, 0, 99) / 99f; + var scaledDistance = Math.Clamp(distance, 0, 1); + return 0.004f + 12f * scaledDistance * MathF.Pow(1 - normalizedRate, 2.2f); + } + private static byte[] VoiceEditBuffer(ReadOnlySpan bytes) { if (bytes.Length == VoiceEditBufferLength) return bytes.ToArray(); diff --git a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs index 2c9eb32..f030709 100644 --- a/src/AquariumSynth.Dsl/ReferenceRebuilds.cs +++ b/src/AquariumSynth.Dsl/ReferenceRebuilds.cs @@ -23,10 +23,11 @@ public static class ReferenceRebuildCatalog [ new("carrier_operators", "1,2,3,4,5,6", "Six independent carriers map cleanly to six Aquarium voices."), new("modulation_edge_count", "0", "No operator-to-operator modulation is needed for the additive shape."), - new("self_feedback_operators", "6", "Approximated with fold/drive on the highest partial.") + new("self_feedback_operators", "6", "Approximated with fold/drive on the highest partial."), + new("operator_envelope_approximation", "DX7 EG -> ADSR", "DX7 four-rate/four-level envelopes can now be lowered to labeled ADSR approximations.") ], [ - new("operator_envelopes", "per-operator four-stage DX7 EG", "Aquarium now has ADSR envelopes, but not DX7 rate/level envelopes."), + new("operator_envelope_exactness", "per-operator four-stage DX7 EG", "Aquarium has an ADSR approximation helper, not exact DX7 rate/level envelope execution."), new("output_compensation", "algorithm ROM COM", "Aquarium voice gains are explicit and not derived from DX7 COM scaling.") ], "This is the honest easy case: DX7 algorithm 32 is mostly additive synthesis, so the current voice DSL can represent the carrier layout without pretending to own a full operator graph."), @@ -40,11 +41,12 @@ public static class ReferenceRebuildCatalog 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("runtime_macros", "brightness,strike", "Field-site parameters expose useful sound controls without recompilation."), + new("operator_envelope_approximation", "DX7 EG -> ADSR", "DX7 four-rate/four-level envelopes can now be lowered to labeled ADSR approximations.") ], [ 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.") + new("operator_envelope_exactness", "independent DX7 rate/level envelopes", "Aquarium has per-operator ADSR approximation, not exact DX7 EG execution.") ], "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.") ]; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 88cb8d4..86567b8 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -18,3 +18,4 @@ {"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"} +{"kind":"dx7_envelopes","lesson":"DX7 four-rate/four-level operator envelopes can be lowered to labeled Aquarium ADSR approximations plus gate duration, but this is not exact DX7 EG execution. Reference rebuilds should record approximation as matched pressure and exactness as still missing.","source":"DX7 envelope approximation slice on 2026-05-13; Yamaha DX7 manual describes EGs as four rates and four levels"} diff --git a/state/memory.json b/state/memory.json index db4e6c3..1b48548 100644 --- a/state/memory.json +++ b/state/memory.json @@ -16,6 +16,7 @@ "reference rebuild catalog", "DX7 SysEx structural parsing", "DX7 algorithm topology metadata", + "DX7 envelope-to-ADSR approximation", "standard ADSR envelope model", "note frequency/gate model", "Faust-managed playback/polyphony contract", @@ -51,9 +52,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, two Aquarium DSL rebuild attempts, and first-class OperatorGraph support.", + "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, 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": "Add a neutral DX7 envelope approximation path and refine Faust MIDI integration details; exact DX7 feedback-register timing remains separate pressure." + "next": "Add rendered/audio comparison once a render path is explicit; exact DX7 feedback-register timing and exact DX7 EG execution remain separate pressure." }, "note_and_envelope_model": { "rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.", @@ -68,7 +69,7 @@ }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "42 passed", - "commit": "pending operator graph parameter binding commit" + "result": "43 passed", + "commit": "pending DX7 envelope approximation commit" } } diff --git a/state/scratch.md b/state/scratch.md index cbda7b1..41f8ee7 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -7,6 +7,16 @@ the synth library while Aquarium work continues elsewhere. Completed this slice: +- Added `Dx7EnvelopeApproximation`, which lowers a DX7 four-rate/four-level EG + to a labeled Aquarium ADSR approximation plus gate duration. +- Reference rebuilds now record `operator_envelope_approximation` as matched + pressure and `operator_envelope_exactness` as still missing. +- Doctrine now states that DX7 EG approximation is not exact DX7 envelope + execution. +- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 43 passed. + +Previous 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`. diff --git a/state/spine.yaml b/state/spine.yaml index a8ba6ae..4db17ca 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-13T12:55:00+01:00 +updated: 2026-05-13T13:11:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -37,6 +37,7 @@ live_invariants: - 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. + - DX7 rate/level envelopes may lower to labeled ADSR approximations, but exact DX7 EG execution 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. - Audio metrics are evidence, not final judgment. @@ -48,9 +49,8 @@ reference_targets: priority: 1 purpose: "Structured FM/operator graph pressure test." 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." + status: "DX7 SysEx parser, algorithm topology extraction, envelope-to-ADSR approximation, two Aquarium DSL rebuild attempts, and first-class operator graph support exist." first_slice: - - "Add a neutral DX7 envelope approximation path." - "Add rendered/audio comparison once a render path is explicit." zynaddsubfx: priority: 2 @@ -85,8 +85,9 @@ reference_targets: next_agent_start_here: recommended_first_task: > - Add a neutral DX7 envelope approximation path, keeping readable operator - graph authoring syntax as operator/route/carrier declarations. + Add rendered/audio comparison once a render path is explicit, keeping + readable operator graph authoring syntax as operator/route/carrier + declarations. must_read: - AGENTS.md - docs/audio-doctrine.md @@ -98,6 +99,13 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-13 + summary: > + Added `Dx7EnvelopeApproximation`, a labeled helper that lowers DX7 + four-rate/four-level envelopes to Aquarium ADSR plus gate duration. + Reference rebuilds now distinguish approximated envelope lowering from + still-missing exact DX7 EG execution. + verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 43 passed" - date: 2026-05-13 summary: > Added field-site parameter binding for readable operator graphs. Graph diff --git a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs index 1f33ce8..ca57029 100644 --- a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs @@ -150,6 +150,28 @@ public sealed class Dx7SysExTests Assert.Equal([6], topology.SelfFeedbackOperators); } + [Fact] + public void ApproximatesDx7RateLevelEnvelopeAsAdsr() + { + var approximation = Dx7SysEx.ApproximateEnvelope(new Dx7Envelope( + Rate1: 99, + Rate2: 70, + Rate3: 55, + Rate4: 40, + Level1: 99, + Level2: 80, + Level3: 50, + Level4: 0)); + + Assert.InRange(approximation.Envelope.AttackSeconds, 0.003f, 0.006f); + Assert.InRange(approximation.Envelope.DecaySeconds, 0.2f, 2.5f); + Assert.Equal(50 / 99f, approximation.Envelope.SustainLevel, 5); + Assert.InRange(approximation.Envelope.ReleaseSeconds, 1f, 5f); + Assert.True(approximation.GateSeconds >= approximation.Envelope.AttackSeconds + approximation.Envelope.DecaySeconds); + Assert.Contains("Approximation", approximation.Notes); + Assert.StartsWith("env=adsr:", approximation.ToScriptSpec()); + } + 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 338b2f5..7248175 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -220,6 +220,8 @@ public sealed class PatchScriptTests Assert.Contains("process =", export.Source); Assert.NotEmpty(rebuild.MatchedFeatures); Assert.NotEmpty(rebuild.MissingFeatures); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "operator_envelope_approximation"); + Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "operator_envelope_exactness"); Assert.All(rebuild.MissingFeatures, feature => Assert.False(string.IsNullOrWhiteSpace(feature.Notes))); } }