Add PRC SYNTH1 listening parity probe
This commit is contained in:
parent
85e5413daa
commit
0a1cf97119
7 changed files with 237 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,6 +3,7 @@ obj/
|
|||
.vs/
|
||||
.idea/
|
||||
TestResults/
|
||||
artifacts/
|
||||
target/
|
||||
*.user
|
||||
*.suo
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<bundled python> dotnet test AquariumSynthCSharp.slnx --no-restore --filter PublicDomainDx7PrcSynth1WritesListeningWavsWhenInstalled: passed"
|
||||
- date: 2026-05-13
|
||||
summary: >
|
||||
Added staged operator envelopes for operator graphs through
|
||||
|
|
|
|||
|
|
@ -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<float> reference,
|
||||
IReadOnlyList<float> 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<float> 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<float> 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue