Add dexed-py DX7 reference fixture harness

This commit is contained in:
Meta Vens 2026-05-13 17:39:30 +01:00
commit 8011aec91f
12 changed files with 337 additions and 13 deletions

View file

@ -14,6 +14,11 @@ the Rust crate into the room by the ankle.
dotnet test
```
DX7 reference-render tests use `dexed-py` when a Python with `dexed` and
`numpy` is available. Set `AQUARIUM_DX7_PYTHON` to that interpreter to force a
specific runtime; otherwise the tests skip the render path and still parse the
vendored public-domain fixture.
## Package Boundary
Aquarium consumes this library as a pinned `AquariumSynth.Dsl` NuGet package,
@ -25,6 +30,9 @@ version is packed and tested.
dotnet pack src\AquariumSynth.Dsl\AquariumSynth.Dsl.csproj -c Release
```
The test suite verifies that development fixtures, Python render helpers, and
SysEx banks do not ship in `AquariumSynth.Dsl.nupkg`.
## Shape
- `PatchScript.Parse(script)` lowers terse script into `SynthPatch`.

View file

@ -38,6 +38,9 @@ credible parity test path should not be consumed.
- Aquarium Faust output can be rendered through Faust-generated C# for candidate
audio comparison. Reference audio still needs a real source fixture or renderer;
do not compare a rebuild only against itself and call that parity.
- DX7 reference audio uses `dexed-py` when available, with public-domain SysEx
fixtures stored under the test project. These are development references, not
package content.
- Do not add a generic graph escape hatch until a specific family of references
forces it.
- When a reference target exceeds the current voice-centric DSL, document the
@ -71,9 +74,10 @@ First deliverable:
- Convert one or two public-domain or project-authored DX7-style voices into
`ReferencePatch` records.
- Build an Aquarium DSL approximation using explicit FM templates.
- Add mel/envelope/spectral comparison against rendered Dexed output or a
captured, licensed reference fixture. Aquarium candidate rendering exists;
the missing DX7 parity surface is the reference side.
- Add mel/envelope/spectral comparison against rendered Dexed output from the
public-domain fixture. Aquarium candidate rendering and `dexed-py` reference
rendering exist; the remaining work is mapping selected patches to Aquarium
DSL candidates with explicit parity thresholds.
Source: [Dexed](https://github.com/asb2m10/dexed)

View file

@ -21,3 +21,4 @@
{"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"}
{"kind":"render_parity_harness","lesson":"Aquarium Faust output can now be compiled to Faust-generated C# and rendered into mono float samples for AudioAnalyzer comparisons. This proves the candidate side of parity; DX7 parity still requires a real Dexed/reference render or captured licensed fixture, not self-comparison.","source":"Faust render parity harness slice on 2026-05-13"}
{"kind":"reference_parity_doctrine","lesson":"External synth references exist to exercise Aquarium DSL expressiveness, not to clone foreign internals. Consume only references that can be backed by parity tests proving Aquarium can reproduce the behavior in terse, readable syntax; parsing a format is inventory, not evidence.","source":"user clarified reference-synth purpose and parity-test requirement on 2026-05-13"}
{"kind":"dx7_reference_rendering","lesson":"DX7 reference rendering now uses dexed-py as optional test tooling against a public-domain SysEx fixture. Keep fixtures and renderer helpers under the test project, preserve provenance and SHA-256 beside vendored banks, and keep AquariumSynth.Dsl.nupkg free of SysEx/Python/test assets.","source":"dexed-py public-domain fixture harness slice on 2026-05-13"}

View file

@ -14,12 +14,15 @@ surfaces:
library: src/AquariumSynth.Dsl
tests: tests/AquariumSynth.Dsl.Tests
reference_roadmap: docs/reference-synth-roadmap.md
dx7_public_domain_fixtures: tests/AquariumSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain
package: AquariumSynth.Dsl 0.1.0-aquarium.1
modules:
model: src/AquariumSynth.Dsl/Model.cs
parser: src/AquariumSynth.Dsl/PatchScript.cs
faust: src/AquariumSynth.Dsl/FaustExport.cs
faust_rendering: src/AquariumSynth.Dsl/FaustExport.cs
dx7_reference_render_tests: tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs
package_boundary_tests: tests/AquariumSynth.Dsl.Tests/PackageBoundaryTests.cs
sfxr: src/AquariumSynth.Dsl/SfxrParams.cs
analysis: src/AquariumSynth.Dsl/AudioAnalysis.cs
scoring: src/AquariumSynth.Dsl/ScriptMetrics.cs
@ -38,7 +41,11 @@ boundaries:
- analysis and comparison tools
- script scoring
- Faust source generation, target-code generation, and candidate rendering
dev_only:
- public-domain DX7 SysEx fixtures
- dexed-py reference renderer
checks:
default: dotnet test
package: dotnet pack src/AquariumSynth.Dsl/AquariumSynth.Dsl.csproj -c Release
faust_validation: automatic in tests when Faust is installed
dexed_py_reference: optional in tests when AQUARIUM_DX7_PYTHON or a Python with dexed-py is available

View file

@ -13,6 +13,8 @@
"operator graph model",
"patch parameter contracts",
"reference patch contracts",
"test-only public-domain DX7 fixtures",
"test-only dexed-py reference renderer",
"reference rebuild catalog",
"DX7 SysEx structural parsing",
"DX7 algorithm topology metadata",
@ -53,9 +55,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, two Aquarium DSL rebuild attempts, first-class OperatorGraph support, and Aquarium candidate rendering through Faust-generated C#.",
"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, first-class OperatorGraph support, public-domain SysEx fixture coverage, dexed-py reference rendering, and Aquarium candidate rendering through Faust-generated C#.",
"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": "Choose Dexed/reference audio fixtures and add rendered DX7 parity comparisons; exact DX7 feedback-register timing and exact DX7 EG execution remain separate pressure."
"next": "Map one selected public-domain DX7 fixture voice to a readable Aquarium operator graph and compare dexed-py reference audio against Faust-rendered Aquarium candidate audio with explicit parity thresholds; 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.",
@ -70,7 +72,7 @@
},
"last_verified": {
"command": "dotnet test AquariumSynthCSharp.slnx --no-restore",
"result": "44 passed",
"commit": "pending Faust render parity harness"
"result": "47 passed",
"commit": "pending dexed-py reference fixture harness"
}
}

View file

@ -15,6 +15,21 @@ Doctrine update:
Completed this slice:
- Added a public-domain DX7 SysEx fixture from Musical Artifacts artifact 152:
`tests/AquariumSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain/analog1.syx`, with
provenance and SHA-256 recorded beside it.
- Added a test-only `dexed-py` reference renderer. It uses
`AQUARIUM_DX7_PYTHON` when set, otherwise probes `py`, `python`, and
`python3`; if `dexed-py` is absent, the render test returns without turning
optional tooling into a hard dependency.
- Added a `.nupkg` boundary test that packs `AquariumSynth.Dsl` and asserts
test fixtures, SysEx banks, and Python helpers are not shipped.
- Verified with bundled Python plus `dexed-py`: the DX7 fixture renders through
Dexed successfully.
- Verified with `dotnet test AquariumSynthCSharp.slnx --no-restore`: 47 passed.
Previous slice:
- Added `FaustCompiler.RenderAsync`, which compiles Aquarium Faust output to
Faust-generated C#, runs it in a temp .NET project, and returns a mono float
sample buffer for analysis.

View file

@ -1,6 +1,6 @@
project: AquariumSynthCSharp
spine_version: 1
updated: 2026-05-13T13:40:00+01:00
updated: 2026-05-13T14:05:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -20,6 +20,8 @@ current_mechanism:
analysis: src/AquariumSynth.Dsl/AudioAnalysis.cs
built_ins: src/AquariumSynth.Dsl/BuiltInScripts.cs
dx7_references: src/AquariumSynth.Dsl/Dx7SysEx.cs
dx7_reference_fixtures: tests/AquariumSynth.Dsl.Tests/Fixtures/Dx7/PublicDomain
dx7_reference_renderer_tests: tests/AquariumSynth.Dsl.Tests/Dx7ReferenceParityTests.cs
reference_rebuilds: src/AquariumSynth.Dsl/ReferenceRebuilds.cs
tests: tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs
doctrine: docs/audio-doctrine.md
@ -42,6 +44,7 @@ live_invariants:
- 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.
- Public-domain reference fixtures and dexed-py helpers are test/development material only; the NuGet package must ship only AquariumSynth.Dsl.
- 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.
@ -52,9 +55,9 @@ reference_targets:
priority: 1
purpose: "Structured FM/operator graph pressure test."
source: "https://github.com/asb2m10/dexed"
status: "DX7 SysEx parser, 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, 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:
- "Choose Dexed/reference audio fixtures and add rendered DX7 parity comparisons."
- "Map selected public-domain DX7 patches to Aquarium DSL candidates and add explicit parity thresholds."
zynaddsubfx:
priority: 2
purpose: "Additive, subtractive, PAD, formant, kit, and layered-instrument pressure test."
@ -88,8 +91,9 @@ reference_targets:
next_agent_start_here:
recommended_first_task: >
Choose a lawful DX7 reference render source, then compare captured/reference
audio against Aquarium candidate renders produced through Faust-generated C#.
Map one selected public-domain DX7 fixture voice to a readable Aquarium
operator graph and compare dexed-py reference audio against Faust-rendered
Aquarium candidate audio with explicit parity thresholds.
must_read:
- AGENTS.md
- docs/audio-doctrine.md
@ -101,6 +105,14 @@ next_agent_start_here:
- "dotnet test AquariumSynthCSharp.slnx --no-restore"
completed_slices:
- date: 2026-05-13
summary: >
Added a public-domain DX7 SysEx fixture from Musical Artifacts artifact
152, provenance metadata, and a test-only dexed-py renderer. The render
test uses `AQUARIUM_DX7_PYTHON` when set and otherwise stays optional.
Added a package-boundary test that packs `AquariumSynth.Dsl` and asserts
fixtures, SysEx banks, and Python helpers do not ship in the NuGet package.
verification: "dotnet test AquariumSynthCSharp.slnx --no-restore: 47 passed; dexed-py render test passed with bundled Python"
- date: 2026-05-13
summary: >
Added `FaustCompiler.RenderAsync`, which compiles Aquarium Faust source to

View file

@ -18,8 +18,12 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" Pack="false" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AquariumSynth.Dsl\AquariumSynth.Dsl.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
namespace AquariumSynth.Dsl.Tests;
public sealed class Dx7ReferenceParityTests
{
[Fact]
public void PublicDomainDx7FixtureParsesAsReferenceBank()
{
var path = FixturePath("Dx7", "PublicDomain", "analog1.syx");
var data = File.ReadAllBytes(path);
var bank = Dx7SysEx.ParseBank(data);
Assert.Equal(Dx7SysEx.PackedBankVoiceCount, bank.Voices.Count);
Assert.Contains(bank.Voices, voice => !string.IsNullOrWhiteSpace(voice.Name));
}
[Fact]
public async Task DexedPyRendersPublicDomainDx7FixtureWhenInstalled()
{
var bankPath = FixturePath("Dx7", "PublicDomain", "analog1.syx");
var render = await DexedPyRenderer.RenderAsync(bankPath, patchIndex: 0);
if (render is null)
{
return;
}
Assert.True(render.Samples.Length > 1000, render.Stderr);
Assert.True(render.Samples.Max(MathF.Abs) > 0.001f, render.Stderr);
var analysis = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: render.SampleRate))
.Analyze(render.Samples);
Assert.True(analysis.Features.Rms > 0.0001f);
}
private static string FixturePath(params string[] parts) =>
Path.Combine([AppContext.BaseDirectory, "Fixtures", .. parts]);
}
internal sealed record DexedPyRender(float[] Samples, int SampleRate, string Stdout, string Stderr);
internal static class DexedPyRenderer
{
public static async Task<DexedPyRender?> RenderAsync(
string bankPath,
int patchIndex,
int midiNote = 60,
int velocity = 100,
float noteDurationSeconds = 1,
float renderDurationSeconds = 1.5f,
int sampleRate = 44100,
CancellationToken cancellationToken = default)
{
var python = await FindPythonWithDexedAsync(cancellationToken);
if (python is null) return null;
var tempDir = Path.Combine(Path.GetTempPath(), $"aquarium-dx7-render-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var scriptPath = Path.Combine(tempDir, "render_dx7.py");
var outputPath = Path.Combine(tempDir, "reference.f32");
await File.WriteAllTextAsync(scriptPath, ScriptSource(), cancellationToken);
var result = await RunAsync(
python.FileName,
[
.. python.PrefixArguments,
scriptPath,
bankPath,
patchIndex.ToStringInvariant(),
outputPath,
midiNote.ToStringInvariant(),
velocity.ToStringInvariant(),
noteDurationSeconds.ToStringInvariant(),
renderDurationSeconds.ToStringInvariant(),
sampleRate.ToStringInvariant()
],
cancellationToken);
if (result.ExitCode != 0 || !File.Exists(outputPath))
{
return new DexedPyRender([], sampleRate, result.Stdout, result.Stderr);
}
var bytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
var samples = new float[bytes.Length / sizeof(float)];
Buffer.BlockCopy(bytes, 0, samples, 0, samples.Length * sizeof(float));
return new DexedPyRender(samples, sampleRate, result.Stdout, result.Stderr);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static async Task<PythonCommand?> FindPythonWithDexedAsync(CancellationToken cancellationToken)
{
var configuredPython = Environment.GetEnvironmentVariable("AQUARIUM_DX7_PYTHON");
var candidates = new List<PythonCommand>();
if (!string.IsNullOrWhiteSpace(configuredPython))
{
candidates.Add(new PythonCommand(configuredPython, []));
}
candidates.AddRange(
[
new PythonCommand("py", ["-3"]),
new PythonCommand("python", []),
new PythonCommand("python3", [])
]);
foreach (var candidate in candidates)
{
var result = await RunAsync(
candidate.FileName,
[.. candidate.PrefixArguments, "-c", "import dexed, numpy"],
cancellationToken);
if (result.ExitCode == 0) return candidate;
}
return null;
}
private static string ScriptSource() =>
"""
import sys
import numpy as np
from dexed import Patch, DexedSynth
bank_path = sys.argv[1]
patch_index = int(sys.argv[2])
output_path = sys.argv[3]
midi_note = int(sys.argv[4])
velocity = int(sys.argv[5])
note_duration = float(sys.argv[6])
render_duration = float(sys.argv[7])
sample_rate = int(sys.argv[8])
patches = Patch.load_bank(bank_path)
synth = DexedSynth(sample_rate=sample_rate)
synth.load_patch(patches[patch_index])
audio = synth.render(
midi_note=midi_note,
velocity=velocity,
note_duration=note_duration,
render_duration=render_duration)
np.asarray(audio, dtype=np.float32).tofile(output_path)
""";
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken)
{
try
{
var start = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start);
if (process is null) return (-1, "", $"failed to start `{fileName}`");
var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
return (process.ExitCode, await stdout, await stderr);
}
catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or FileNotFoundException)
{
return (-1, "", exception.Message);
}
}
private sealed record PythonCommand(string FileName, IReadOnlyList<string> PrefixArguments);
}
internal static class TestFormat
{
public static string ToStringInvariant(this int value) =>
value.ToString(System.Globalization.CultureInfo.InvariantCulture);
public static string ToStringInvariant(this float value) =>
value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,13 @@
# Public-Domain DX7 Fixture
`analog1.syx` is a single 32-voice DX7 SysEx bank selected from Musical
Artifacts artifact 152, "DX7 patches 1".
- Source page: https://musical-artifacts.com/artifacts/152
- Download used: https://musical-artifacts.com/artifacts/152/dx7patch.zip
- Source license label: Work in the Public Domain
- Local file: `analog1.syx`
- SHA-256: `5AC120DF3A538DFB9A4B17A218F71B1D136269D2C30A33E676C1D6DDE2AC3AD2`
This fixture is test/development material only. It must not be packed into
`AquariumSynth.Dsl.nupkg`.

View file

@ -0,0 +1,67 @@
using System.Diagnostics;
using System.IO.Compression;
namespace AquariumSynth.Dsl.Tests;
public sealed class PackageBoundaryTests
{
[Fact]
public async Task NuGetPackageContainsOnlyPublishedSynthSurface()
{
var root = RepositoryRoot();
var outputDir = Path.Combine(Path.GetTempPath(), $"aquarium-pack-{Guid.NewGuid():N}");
Directory.CreateDirectory(outputDir);
try
{
var result = await RunAsync(
"dotnet",
["pack", Path.Combine(root, "src", "AquariumSynth.Dsl", "AquariumSynth.Dsl.csproj"), "-c", "Release", "--no-restore", "-o", outputDir]);
Assert.Equal(0, result.ExitCode);
var package = Assert.Single(Directory.GetFiles(outputDir, "AquariumSynth.Dsl.*.nupkg"));
using var archive = ZipFile.OpenRead(package);
var entries = archive.Entries.Select(entry => entry.FullName).ToArray();
Assert.Contains(entries, entry => entry.StartsWith("lib/net10.0/AquariumSynth.Dsl.dll", StringComparison.Ordinal));
Assert.Contains("README.md", entries);
Assert.DoesNotContain(entries, entry => entry.StartsWith("tests/", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(entries, entry => entry.StartsWith("Fixtures/", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(entries, entry => entry.EndsWith(".syx", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(entries, entry => entry.EndsWith(".py", StringComparison.OrdinalIgnoreCase));
}
finally
{
Directory.Delete(outputDir, recursive: true);
}
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AquariumSynthCSharp.slnx")))
{
directory = directory.Parent;
}
return directory?.FullName ?? throw new InvalidOperationException("could not find repository root");
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync(
string fileName,
IReadOnlyList<string> arguments)
{
var start = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new InvalidOperationException($"failed to start `{fileName}`");
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, await stdout, await stderr);
}
}