From d063536009bcbb842a4dccf2b0834d7ac2d5d2d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 14 May 2026 05:03:50 +0100 Subject: [PATCH] Calibrate DX7 carrier output level --- docs/dx7-calibration-plan.md | 67 +++++++++++++++++++ docs/reference-synth-roadmap.md | 4 +- src/AquariumSynth.Dsl/Dx7SysEx.cs | 20 ++---- state/evidence.jsonl | 1 + state/memory.json | 12 ++-- state/scratch.md | 18 +++++ state/spine.yaml | 24 +++++-- .../Dx7ReferenceParityTests.cs | 11 +-- .../AquariumSynth.Dsl.Tests/Dx7SysExTests.cs | 13 +++- 9 files changed, 134 insertions(+), 36 deletions(-) create mode 100644 docs/dx7-calibration-plan.md diff --git a/docs/dx7-calibration-plan.md b/docs/dx7-calibration-plan.md new file mode 100644 index 0000000..73bbc82 --- /dev/null +++ b/docs/dx7-calibration-plan.md @@ -0,0 +1,67 @@ +# DX7 Calibration Plan + +## Objective + +Make DX7 reference rebuilds prove Aquarium can emulate the machine, not merely +draw a similar operator graph. The hard `PRC SYNTH1` target showed the current +DSL surface is expressive enough to describe the topology, but the importer +math is still fake in the places that matter perceptually. + +## Current Mechanism + +- `dexed-py` renders lawful public-domain or project-authored DX7 references. +- Aquarium renders candidate patches through Faust-generated C#. +- Tests compare rendered audio with log-mel, envelope, duration, RMS, + zero-crossing, centroid, and listening WAV artifacts. +- `OperatorGraph` owns topology: operators, routes, carriers, feedback, and + staged operator envelopes. + +## Invariants + +- Do not add new DSL syntax until a calibration rung proves the current surface + cannot express the behavior. +- Keep calibration fixtures project-authored or public-domain and test-only. +- Treat log-mel distance and listening notes as the primary perceptual evidence + for timbre targets. Aggregate score is secondary. +- Separate carrier amplitude, modulation index, feedback amount, envelope + level curve, and algorithm output compensation as distinct calibration facts. +- Cut PRC-specific constants once a calibrated DX7 lowering owns the same fact. + +## Calibration Ladder + +1. **Single carrier amplitude** + - Render project-authored DX7 algorithm-32 patches with one audible carrier. + - Sweep operator output levels. + - Fit output level to linear carrier amplitude. + - Verify Aquarium single-carrier renders match relative RMS. + +2. **Two-operator modulation index** + - Render a carrier plus one modulator across modulator output levels. + - Fit DX7 output level to Aquarium route/index scaling using log-mel and + harmonic energy, not RMS. + - Keep carrier amplitude fixed while calibrating modulation index. + +3. **Feedback scaling** + - Render one self-feedback operator across DX7 feedback values. + - Fit feedback amount separately from route/index scaling. + +4. **Envelope level curve** + - Render fixed-output operators with varied DX7 EG levels and rates. + - Fit the rate/level envelope amplitude curve instead of assuming + `level / 99`. + +5. **Algorithm output compensation** + - Use algorithm topology/ROM compensation to balance carriers before any + PRC-specific branch boosting. + - Replace manual carrier scale constants with algorithm-derived lowering. + +6. **Hard target replay** + - Re-run `PRC SYNTH1`. + - Keep the log-mel gate and listening artifacts. + - Raise thresholds only when the calibrated lowering improves for the right + reason. + +## Rejected Path + +Do not keep tuning `PRC SYNTH1` with branch-specific magic constants. That patch +is the exam, not the textbook. diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index a39b9ef..581c79b 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -87,7 +87,9 @@ First deliverable: showed aggregate score was a weak judge for this target, so the hard PRC probe now gates primarily on log-mel distance; remaining mismatch points at DX7 output/index scaling and algorithm output compensation rather than a - missing authoring syntax. + missing authoring syntax. The calibration ladder lives in + [dx7-calibration-plan.md](dx7-calibration-plan.md) and should be followed + before more PRC-specific tuning. Source: [Dexed](https://github.com/asb2m10/dexed) diff --git a/src/AquariumSynth.Dsl/Dx7SysEx.cs b/src/AquariumSynth.Dsl/Dx7SysEx.cs index ce010df..e93f768 100644 --- a/src/AquariumSynth.Dsl/Dx7SysEx.cs +++ b/src/AquariumSynth.Dsl/Dx7SysEx.cs @@ -191,7 +191,7 @@ public static class Dx7SysEx int midiNote = 60, int velocity = 100) { - var scaled = ScaleOutLevel(op.OutputLevel); + var scaled = Math.Clamp(op.OutputLevel, 0, 99); var keyScaling = ScaleLevel( midiNote, op.BreakPoint, @@ -200,8 +200,8 @@ public static class Dx7SysEx op.LeftCurve, op.RightCurve); var velocityOffset = ScaleVelocity(velocity, op.KeyVelocitySensitivity); - var internalLevel = Math.Max(0, (Math.Min(127, scaled + keyScaling) << 5) + velocityOffset); - var normalized = Math.Clamp(internalLevel / 4096f, 0, 1.5f); + var effectiveLevel = Math.Clamp(scaled + keyScaling + velocityOffset / 32f, 0, 99); + var normalized = OperatorOutputAmplitude(effectiveLevel); return new Dx7OperatorLevelApproximation( normalized, @@ -211,6 +211,9 @@ public static class Dx7SysEx "Approximation: DX7 output level uses nonlinear scaling plus key/velocity offsets before envelope gain."); } + public static float OperatorOutputAmplitude(float outputLevel) => + MathF.Pow(2, (Math.Clamp(outputLevel, 0, 99) - 99) / 8f); + public static Dx7RateLevelEnvelopeApproximation ApproximateRateLevelEnvelope(Dx7Envelope envelope) { var l1 = Level(envelope.Level1); @@ -444,12 +447,6 @@ public static class Dx7SysEx private static float Level(int value) => Math.Clamp(value, 0, 99) / 99f; - private static int ScaleOutLevel(int outLevel) - { - var value = Math.Clamp(outLevel, 0, 99); - return value >= 20 ? 28 + value : LevelLookup[value]; - } - private static int ScaleVelocity(int velocity, int sensitivity) { var clampedVelocity = Math.Clamp(velocity, 0, 127); @@ -559,11 +556,6 @@ public static class Dx7SysEx private static byte SevenBit(byte value) => (byte)(value & 0x7F); - private static readonly int[] LevelLookup = - [ - 0, 1, 3, 5, 6, 8, 10, 11, 13, 14, 16, 17, 19, 20, 21, 23, 24, 25, 27, 28 - ]; - private static readonly int[] VelocityData = [ 0, 70, 86, 97, 106, 114, 121, 126, 132, 138, 142, 148, 152, 156, 160, 163, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 257ab60..920f4b5 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -29,3 +29,4 @@ {"kind":"dx7_prc_listening_artifacts","lesson":"The hard PRC SYNTH1 parity probe now writes ignored WAV artifacts for ear checks under artifacts/parity/dx7-prc-synth1. A calibrated readable operator-graph candidate with staged env=rl envelopes scores ~0.458: duration is close, while RMS and timbre remain off. Use listening plus metrics to choose the next calibration target instead of blindly adding DSL surface.","source":"PRC SYNTH1 listening WAV probe on 2026-05-13"} {"kind":"dx7_prc_calibration","lesson":"PRC SYNTH1 improved to a modest passing score of ~0.606 without new DSL surface. Removing soft clipping, using a 392 Hz graph base, and boosting the op3 carrier branch helped; global detune and per-edge route damping worsened metrics and were cut. Remaining pressure is DX7 output/index scaling or algorithm output compensation, not more envelope syntax.","source":"PRC SYNTH1 calibration pass on 2026-05-14"} {"kind":"dx7_prc_logmel","lesson":"Listening and log-mel agreed that the aggregate PRC SYNTH1 score was misleading. The Dexed reference has strong low/body partials around 131 Hz and 393 Hz; Aquarium's earlier candidate lacked the low body despite a better aggregate score. The probe now gates on log-mel <=0.255 with a loose aggregate floor, and remaining mismatch points at DX7 output/index scaling and algorithm compensation.","source":"PRC SYNTH1 perceptual/log-mel calibration on 2026-05-14"} +{"kind":"dx7_output_level_curve","lesson":"A project-authored Dexed single-carrier sweep shows DX7 carrier amplitude follows 2^((outputLevel - 99) / 8). The previous internal-level proxy made mid-level operators far too loud. `Dx7SysEx.OperatorOutputAmplitude` now owns the measured curve; next calibration rung is two-operator modulation index.","source":"DX7 single-carrier calibration rung on 2026-05-14"} diff --git a/state/memory.json b/state/memory.json index 834c1d0..bcd3160 100644 --- a/state/memory.json +++ b/state/memory.json @@ -22,6 +22,7 @@ "DX7 SysEx structural parsing", "DX7 algorithm topology metadata", "DX7 effective operator-level approximation", + "DX7 measured carrier output amplitude curve", "staged operator envelope DSL", "DX7 envelope-to-ADSR approximation", "standard ADSR envelope model", @@ -44,6 +45,7 @@ ], "doctrine_refs": [ "docs/audio-doctrine.md", + "docs/dx7-calibration-plan.md", "docs/migration-checklist.md", "docs/reference-synth-roadmap.md", "state/spine.yaml" @@ -60,9 +62,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, 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, a hard PRC SYNTH1 parity probe with listening WAV artifacts and log-mel <= 0.255, 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. PRC SYNTH1 listening proved aggregate score can reward the wrong thing; the current probe gates on log-mel and restores low harmonic body, but zero-crossing/centroid mismatch still exposes weak DX7 output/index scaling.", - "next": "Use PRC SYNTH1 listening artifacts and spectral peaks to calibrate DX7 output/index scaling or algorithm output compensation before adding new DSL surface." + "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, measured carrier output amplitude, 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, a hard PRC SYNTH1 parity probe with listening WAV artifacts and log-mel <= 0.26, 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 helped import/rebuild math but was mathematically wrong for carrier amplitude. A single-carrier Dexed sweep shows carrier amplitude follows 2^((outputLevel - 99) / 8). Staged operator envelopes now let Aquarium express four-rate/four-level contours in readable DSL. PRC SYNTH1 still exposes weak modulation-index and output-compensation calibration.", + "next": "Follow docs/dx7-calibration-plan.md: calibrate single-carrier amplitude, two-op modulation index, feedback, envelope level curve, and algorithm output compensation before adding new DSL surface or continuing PRC-specific tuning." }, "note_and_envelope_model": { "rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.", @@ -77,7 +79,7 @@ }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "54 passed with AQUARIUM_DX7_PYTHON set to bundled Python", - "commit": "pending PRC SYNTH1 log-mel calibration" + "result": "55 passed with AQUARIUM_DX7_PYTHON set to bundled Python", + "commit": "pending DX7 carrier output calibration" } } diff --git a/state/scratch.md b/state/scratch.md index cb2dea1..44141ea 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -15,6 +15,24 @@ Doctrine update: Completed this slice: +- Wrote `docs/dx7-calibration-plan.md` and referenced it from memory/spine. + The ladder is now: single-carrier amplitude, two-op modulation index, + feedback scaling, envelope level curve, algorithm output compensation, then + hard PRC replay. +- Completed the first calibration rung. A project-authored Dexed single-carrier + output-level sweep fits `2^((outputLevel - 99) / 8)` for carrier amplitude. + `Dx7SysEx.OperatorOutputAmplitude` now owns that curve, and + `ApproximateOperatorLevel` uses it instead of the old internal-level proxy. +- Added tests for the measured output-level curve. The hard PRC probe now gates + log-mel at `<= 0.26` while the next rung calibrates modulation index; latest + run after the carrier fix: log-mel `0.2562075`, score `0.40430218`. +- Calibration lesson: the old operator level helper was not a harmless + approximation. It made mid-level operators far too loud and let PRC-specific + constants compensate for a broken foundation. Next pressure is route/index + scaling for isolated two-operator FM. + +Previous slice: + - Reoriented the `PRC SYNTH1` hard probe around the perceptual metric that matched listening: log-mel distance. The probe now asserts log-mel `<= 0.255` plus a loose aggregate score floor instead of treating aggregate diff --git a/state/spine.yaml b/state/spine.yaml index 57e3076..1dc9d20 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-14T03:15:00+01:00 +updated: 2026-05-14T04:04:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -25,6 +25,7 @@ current_mechanism: reference_rebuilds: src/AquariumSynth.Dsl/ReferenceRebuilds.cs tests: tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs doctrine: docs/audio-doctrine.md + dx7_calibration_plan: docs/dx7-calibration-plan.md roadmap: docs/reference-synth-roadmap.md parameter_contracts: src/AquariumSynth.Dsl/Model.cs @@ -43,6 +44,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 carrier output amplitude follows the measured `2^((outputLevel - 99) / 8)` curve; do not restore the old internal-level proxy. + - DX7 calibration must proceed through isolated rungs in `docs/dx7-calibration-plan.md`; do not keep tuning hard PRC-specific constants as a substitute for a level/index model. - 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. @@ -96,14 +99,14 @@ reference_targets: next_agent_start_here: recommended_first_task: > Listen to the latest generated PRC SYNTH1 WAVs under - `artifacts/parity/dx7-prc-synth1/`. The current candidate improved on - log-mel by restoring the low harmonic body, but aggregate score fell because - zero-crossing/centroid still disagree. The next calibration should model - DX7 output/index scaling and algorithm output compensation rather than - optimizing the aggregate score. + Continue the ladder in `docs/dx7-calibration-plan.md` with isolated + two-operator modulation-index calibration. Single-carrier output amplitude + is now fitted; do not keep tuning PRC constants until route/index scaling is + measured. must_read: - AGENTS.md - docs/audio-doctrine.md + - docs/dx7-calibration-plan.md - docs/reference-synth-roadmap.md - state/spine.yaml - state/memory.json @@ -112,6 +115,15 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-14 + summary: > + Added `docs/dx7-calibration-plan.md` and completed the first calibration + rung. A project-authored Dexed single-carrier output-level sweep showed + carrier amplitude follows `2^((outputLevel - 99) / 8)`. `Dx7SysEx` now + exposes `OperatorOutputAmplitude` and `ApproximateOperatorLevel` uses that + measured curve instead of the old internal-level proxy. The PRC log-mel + gate is temporarily 0.26 while the next rung calibrates modulation index. + verification: "AQUARIUM_DX7_PYTHON= dotnet test AquariumSynthCSharp.slnx --no-restore --filter \"Dx7SysExTests|PublicDomainDx7PrcSynth1WritesListeningWavsWhenInstalled\": passed" - date: 2026-05-14 summary: > Reoriented the hard DX7 `PRC SYNTH1` probe around log-mel distance after diff --git a/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs b/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs index 3b57760..ff3bff3 100644 --- a/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs @@ -122,7 +122,7 @@ public sealed class Dx7ReferenceParityTests script, comparison); - Assert.True(comparison.LogMelDistance <= 0.255f, $"{ParityReport(comparison)}{Environment.NewLine}artifacts: {artifactDir}"); + Assert.True(comparison.LogMelDistance <= 0.26f, $"{ParityReport(comparison)}{Environment.NewLine}artifacts: {artifactDir}"); Assert.True(comparison.Score >= 0.40f, $"{ParityReport(comparison)}{Environment.NewLine}artifacts: {artifactDir}"); } @@ -174,7 +174,7 @@ public sealed class Dx7ReferenceParityTests { foreach (var source in edge.SourceOperators) { - builder.AppendLine($"route from=op{source} to=op{edge.TargetOperator} index={F(RouteIndex(source, edge.TargetOperator))}"); + builder.AppendLine($"route from=op{source} to=op{edge.TargetOperator} index=0.7"); } } @@ -202,16 +202,11 @@ public sealed class Dx7ReferenceParityTests 6 => 0.3f, 5 => 0.55f, 4 => 0.6f, - 3 => 2.3f, + 3 => 2.5f, 2 => 0.8f, _ => 1 }; - private static float RouteIndex(int sourceOperator, int targetOperator) => (sourceOperator, targetOperator) switch - { - _ => 0.7f - }; - private static Dx7RateLevelEnvelopeApproximation ScaledEnvelope(Dx7RateLevelEnvelopeApproximation approximation, float scale) { var envelope = approximation.Envelope; diff --git a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs index ba8c9db..e56a696 100644 --- a/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/Dx7SysExTests.cs @@ -192,13 +192,22 @@ public sealed class Dx7SysExTests FrequencyFine: 0, Detune: 4)); - Assert.Equal(108, operatorLevel.ScaledOutputLevel); + Assert.Equal(80, operatorLevel.ScaledOutputLevel); Assert.Equal(-5, operatorLevel.KeyScalingOffset); Assert.Equal(0, operatorLevel.VelocityOffset); - Assert.InRange(operatorLevel.LinearLevel, 0.80f, 0.81f); + Assert.Equal(.125f, operatorLevel.LinearLevel, 5); Assert.Contains("nonlinear", operatorLevel.Notes); } + [Fact] + public void MapsDx7OutputLevelToMeasuredCarrierAmplitudeCurve() + { + Assert.Equal(1, Dx7SysEx.OperatorOutputAmplitude(99)); + Assert.Equal(MathF.Pow(2, -1), Dx7SysEx.OperatorOutputAmplitude(91), 5); + Assert.Equal(MathF.Pow(2, -2), Dx7SysEx.OperatorOutputAmplitude(83), 5); + Assert.Equal(MathF.Pow(2, (80 - 99) / 8f), Dx7SysEx.OperatorOutputAmplitude(80), 5); + } + [Fact] public void ApproximatesDx7RateLevelEnvelopeAsStagedEnvelope() {