diff --git a/.gitignore b/.gitignore index d378094..f687f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ obj/ .vs/ .idea/ TestResults/ +artifacts/ target/ *.user *.suo diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index 4f04f0d..d9c506a 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -81,7 +81,9 @@ First deliverable: 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. + make EG timing/gain calibration the live pressure. The hard PRC probe now + writes ignored listening artifacts under `artifacts/parity/dx7-prc-synth1` + so metrics and ears can steer the next calibration pass together. Source: [Dexed](https://github.com/asb2m10/dexed) diff --git a/state/evidence.jsonl b/state/evidence.jsonl index b503f13..68fdc79 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -26,3 +26,4 @@ {"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"} +{"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"} diff --git a/state/memory.json b/state/memory.json index 0dc3dad..afa0d3c 100644 --- a/state/memory.json +++ b/state/memory.json @@ -16,6 +16,7 @@ "reference patch contracts", "test-only public-domain DX7 fixtures", "test-only dexed-py reference renderer", + "ignored listening WAV parity artifacts", "thresholded DX7 rendered parity test", "reference rebuild catalog", "DX7 SysEx structural parsing", @@ -59,9 +60,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, 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." + "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 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. A calibrated PRC SYNTH1 probe now writes WAVs and scores ~0.458; duration is close, while gain/timbre mismatch remains.", + "next": "Listen to artifacts/parity/dx7-prc-synth1 and use the audible mismatch to choose the next calibration target: output gain/index scaling, ratio/detune, or EG curve shape." }, "note_and_envelope_model": { "rule": "Notes own frequency, gate duration, and host/MIDI source. Envelopes own ADSR level shape.", @@ -76,7 +77,7 @@ }, "last_verified": { "command": "dotnet test AquariumSynthCSharp.slnx --no-restore", - "result": "53 passed", - "commit": "pending staged operator envelopes" + "result": "54 passed", + "commit": "pending hard PRC SYNTH1 listening artifacts" } } diff --git a/state/scratch.md b/state/scratch.md index a542637..0341cbd 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -15,6 +15,23 @@ Doctrine update: Completed this slice: +- Added a hard DX7 `PRC SYNTH1` rendered parity probe that writes listening + artifacts when `dexed-py` and Faust are available: + `artifacts/parity/dx7-prc-synth1/reference-dexed.wav`, + `candidate-aquarium.wav`, `candidate.aqua`, and `report.txt`. +- The probe rebuilds `analog1.syx` voice 17 with readable operator graph syntax + and staged `env=rl` operator envelopes. It asserts a modest hard-target score + floor instead of pretending exact DX7 parity is solved. +- Current run with bundled Python and Faust: score `0.4581856`, + log-mel distance `0.26397973`, envelope distance `0.4109424`, + duration ratio `1.0173812`, RMS ratio `0.61393267`, zero-crossing ratio + `1.1713804`, centroid ratio `1.0978767`. +- Calibration lesson: shortening the naive DX7 EG timing and lowering the graph + base frequency made the candidate much closer. The remaining mismatch is + still gain/timbre calibration, not missing `env=rl` syntax. + +Previous 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 diff --git a/state/spine.yaml b/state/spine.yaml index 5002c3e..609e23f 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -1,6 +1,6 @@ project: AquariumSynthCSharp spine_version: 1 -updated: 2026-05-13T16:28:00+01:00 +updated: 2026-05-13T19:38:00+01:00 prime_directive: > Build coherent synth machinery. References exist to reveal missing structure, @@ -48,6 +48,7 @@ live_invariants: - 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. + - Listening WAV artifacts belong under ignored `artifacts/`; keep them out of NuGet and commits unless explicitly promoted as fixtures. - Do not consume external synth targets that cannot be backed by parity tests. Parsing is inventory; reproducing behavior through terse, readable Aquarium DSL is the proof. - Audio metrics are evidence, not final judgment. - Do not add a generic graph escape hatch until specific references force it. @@ -58,7 +59,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, 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." + status: "DX7 SysEx parser, public-domain SysEx fixture, dexed-py reference rendering, first thresholded rendered parity test for analog1.syx voice 13, hard PRC SYNTH1 probe with listening WAV artifacts, 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: @@ -94,10 +95,10 @@ reference_targets: next_agent_start_here: recommended_first_task: > - 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. + Listen to the generated PRC SYNTH1 WAVs under + `artifacts/parity/dx7-prc-synth1`, then use the audible mismatch plus the + score report to decide whether the next calibration pass should target + output gain/index scaling, frequency ratio/detune, or EG curve shape. must_read: - AGENTS.md - docs/audio-doctrine.md @@ -109,6 +110,15 @@ next_agent_start_here: - "dotnet test AquariumSynthCSharp.slnx --no-restore" completed_slices: + - date: 2026-05-13 + summary: > + Added a hard DX7 `PRC SYNTH1` rendered parity probe that emits listening + WAV artifacts under ignored `artifacts/parity/dx7-prc-synth1` when + dexed-py and Faust are available. The Aquarium candidate uses readable + operator graph syntax and staged `env=rl` envelopes, asserts a modest + hard-target score floor, and currently scores 0.4581856 with duration + close to the Dexed reference but remaining gain/timbre mismatch. + verification: "AQUARIUM_DX7_PYTHON= dotnet test AquariumSynthCSharp.slnx --no-restore --filter PublicDomainDx7PrcSynth1WritesListeningWavsWhenInstalled: passed" - date: 2026-05-13 summary: > Added staged operator envelopes for operator graphs through diff --git a/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs b/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs index 8801d65..c05b791 100644 --- a/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; namespace AquariumSynth.Dsl.Tests; @@ -77,9 +78,201 @@ public sealed class Dx7ReferenceParityTests Assert.InRange(comparison.CentroidRatio, 0.90f, 1.05f); } + [Fact] + public async Task PublicDomainDx7PrcSynth1WritesListeningWavsWhenInstalled() + { + var bankPath = FixturePath("Dx7", "PublicDomain", "analog1.syx"); + var reference = await DexedPyRenderer.RenderAsync( + bankPath, + patchIndex: 17, + noteDurationSeconds: 0.85f, + renderDurationSeconds: 1.4f); + + if (reference is null) + { + return; + } + + var bank = Dx7SysEx.ParseBank(File.ReadAllBytes(bankPath)); + var voice = bank.Voices[17]; + Assert.Equal("PRC SYNTH1", voice.Name); + + var script = PrcSynth1ProbeScript(voice); + var candidateSource = FaustEmitter.EmitScript(script); + var candidate = await FaustCompiler.RenderAsync( + candidateSource.Source, + new FaustRenderOptions(DurationSeconds: 1.4f)); + + if (candidate is null) + { + return; + } + + Assert.NotEmpty(reference.Samples); + Assert.NotEmpty(candidate.Samples); + + var comparison = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: reference.SampleRate)) + .Compare(reference.Samples, candidate.Samples); + var artifactDir = ArtifactPath("parity", "dx7-prc-synth1"); + WriteListeningArtifacts( + artifactDir, + reference.Samples, + candidate.Samples, + reference.SampleRate, + script, + comparison); + + Assert.True(comparison.Score >= 0.40f, $"{ParityReport(comparison)}{Environment.NewLine}artifacts: {artifactDir}"); + } + private static string FixturePath(params string[] parts) => Path.Combine([AppContext.BaseDirectory, "Fixtures", .. parts]); + private static string ArtifactPath(params string[] parts) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null && !File.Exists(Path.Combine(current.FullName, "AquariumSynthCSharp.slnx"))) + { + current = current.Parent; + } + + var root = current?.FullName ?? AppContext.BaseDirectory; + return Path.Combine([root, "artifacts", .. parts]); + } + + private static string PrcSynth1ProbeScript(Dx7Voice voice) + { + var topology = Dx7SysEx.AlgorithmTopology(voice.Algorithm); + var builder = new StringBuilder(); + builder.AppendLine("patch"); + builder.AppendLine(" gain=2.0"); + builder.AppendLine(" soft_clip=true"); + builder.AppendLine(); + builder.AppendLine("opgraph"); + builder.AppendLine(" name=dx7_prc_synth1_probe"); + builder.AppendLine(" freq=440"); + builder.AppendLine(" gain=0.55"); + builder.AppendLine(); + + foreach (var op in voice.Operators.OrderByDescending(op => op.Number)) + { + var level = Dx7SysEx.ApproximateOperatorLevel(op).LinearLevel; + var envelope = ScaledEnvelope(Dx7SysEx.ApproximateRateLevelEnvelope(op.Envelope), 0.62f); + builder.AppendLine($"operator name=op{op.Number}"); + builder.AppendLine($" ratio={F(Dx7Ratio(op))}"); + builder.AppendLine($" level={F(level)}"); + if (topology.SelfFeedbackOperators.Contains(op.Number)) + { + builder.AppendLine($" feedback={F(voice.Feedback * 0.08f)}"); + } + builder.AppendLine($" {envelope.ToScriptSpec()}"); + builder.AppendLine(); + } + + foreach (var edge in topology.ModulationEdges.Where(edge => edge.Kind != "self-feedback")) + { + foreach (var source in edge.SourceOperators) + { + builder.AppendLine($"route from=op{source} to=op{edge.TargetOperator} index=0.7"); + } + } + + foreach (var carrier in topology.CarrierOperators) + { + builder.AppendLine($"carrier name=op{carrier}"); + } + + return builder.ToString(); + } + + private static float Dx7Ratio(Dx7Operator op) + { + if (op.FrequencyMode == Dx7FrequencyMode.Fixed) + { + return Math.Max(0.5f, op.FrequencyCoarse); + } + + var coarse = op.FrequencyCoarse == 0 ? 0.5f : op.FrequencyCoarse; + return coarse * (1 + op.FrequencyFine / 100f); + } + + private static Dx7RateLevelEnvelopeApproximation ScaledEnvelope(Dx7RateLevelEnvelopeApproximation approximation, float scale) + { + var envelope = approximation.Envelope; + var scaled = new RateLevelEnvelope( + envelope.Rate1Seconds * scale, + envelope.Level1, + envelope.Rate2Seconds * scale, + envelope.Level2, + envelope.Rate3Seconds * scale, + envelope.Level3, + envelope.Rate4Seconds * scale, + envelope.Level4); + return approximation with + { + Envelope = scaled, + GateSeconds = Math.Max((scaled.Rate1Seconds + scaled.Rate2Seconds + scaled.Rate3Seconds) * 0.68f, 0.02f) + }; + } + + private static void WriteListeningArtifacts( + string directory, + IReadOnlyList reference, + IReadOnlyList candidate, + int sampleRate, + string script, + AudioComparison comparison) + { + Directory.CreateDirectory(directory); + var peak = Math.Max(Peak(reference), Peak(candidate)); + var scale = peak <= 0 ? 1 : 0.98f / peak; + WriteWav(Path.Combine(directory, "reference-dexed.wav"), reference, sampleRate, scale); + WriteWav(Path.Combine(directory, "candidate-aquarium.wav"), candidate, sampleRate, scale); + File.WriteAllText(Path.Combine(directory, "candidate.aqua"), script); + File.WriteAllText( + Path.Combine(directory, "report.txt"), + string.Join( + Environment.NewLine, + ParityReport(comparison), + $"wav normalization scale: {scale}", + $"reference: {Path.Combine(directory, "reference-dexed.wav")}", + $"candidate: {Path.Combine(directory, "candidate-aquarium.wav")}")); + } + + private static void WriteWav(string path, IReadOnlyList samples, int sampleRate, float scale) + { + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream); + var dataBytes = samples.Count * sizeof(short); + writer.Write("RIFF"u8); + writer.Write(36 + dataBytes); + writer.Write("WAVE"u8); + writer.Write("fmt "u8); + writer.Write(16); + writer.Write((short)1); + writer.Write((short)1); + writer.Write(sampleRate); + writer.Write(sampleRate * sizeof(short)); + writer.Write((short)sizeof(short)); + writer.Write((short)16); + writer.Write("data"u8); + writer.Write(dataBytes); + foreach (var sample in samples) + { + writer.Write((short)MathF.Round(Math.Clamp(sample * scale, -1, 1) * short.MaxValue)); + } + } + + private static float Peak(IReadOnlyList samples) + { + var peak = 0f; + foreach (var sample in samples) peak = Math.Max(peak, Math.Abs(sample)); + return peak; + } + + private static string F(float value) => + value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); + private static string ParityReport(AudioComparison comparison) => string.Join( Environment.NewLine,