From 788e72e177ebcb7b544db9ed26e67dfd79619b8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 17 May 2026 20:34:52 +0100 Subject: [PATCH] Add Zyn formant motion workout --- docs/zynaddsubfx-pressure-survey.md | 29 +++- src/AquaSynth.Core/FaustExport.cs | 34 ++++- src/AquaSynth.Core/Model.cs | 4 + src/AquaSynth.Core/PatchScript.cs | 10 ++ src/AquaSynth.Core/ZynInstrument.cs | 137 ++++++++++++++++++ state/evidence.jsonl | 1 + state/spine.yaml | 7 +- tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 16 ++ .../AquaSynth.Dsl.Tests/ZynInstrumentTests.cs | 25 ++++ 9 files changed, 248 insertions(+), 15 deletions(-) diff --git a/docs/zynaddsubfx-pressure-survey.md b/docs/zynaddsubfx-pressure-survey.md index 2e7a445..6911a84 100644 --- a/docs/zynaddsubfx-pressure-survey.md +++ b/docs/zynaddsubfx-pressure-survey.md @@ -92,9 +92,10 @@ Current upstream bank coverage: notch filters, PAD layers, named kit layers, PAD table modes/bandwidth, PAD source-table extraction for the implemented OscilGen subset, PAD LP/HP/BPF/notch cutoff/order/Q/envelopes, - and staged non-free envelopes. + staged non-free envelopes, and authored vowel-frame formant motion. - Counted but not translated: ADD, SUB, mixed-engine kits, free envelopes, - active LFO routing, formant filters/motion, and effects. + active LFO routing beyond explicit control lanes, exact Zyn formant position + drivers/smoothing, and effects. - Unknown PAD filter pressure: formant filters, peak filters, shelf filters, and other unimplemented PAD global filter categories/types such as `cat=1 type=0`, `cat=0 type=6`, `cat=0 type=8`, and smaller buckets. @@ -111,12 +112,13 @@ should own the behavior. ## Recommended Next Cut -Start with **named layer routing**, not PAD synthesis. +Start with **ADD/SUB voice extraction or free-envelope point curves**, not more +PAD synthesis. -Reason: the worst Zyn instruments repeatedly combine multiple kit items and -engines. A named layer surface would give additive banks, PAD sources, free -envelopes, and formant motion somewhere coherent to live later. Without it, the -next abstractions risk becoming loose fields on anonymous voices. +Reason: named layers, PAD spectral sources, staged envelopes, native +band-pass/notch filters, and a first vowel-frame formant-motion surface now +exist. The next counted Zyn pressure is no longer "where does this live?" for +formants; it is either ADD/SUB source detail or arbitrary envelope shape. ## First Responses @@ -168,3 +170,16 @@ a PADsynth-style authoring path: frequency-domain harmonic spreading, deterministic random phase, one inverse FFT, and normalization. This is not full Zyn PAD engine parity across every harmonic profile and pitch-zone behavior, but it is now real spectral-table synthesis rather than voice-count theater. + +Wide Bass promoted the first formant-motion rung: + +```text +voice layer=wide_bass formant_mix=.7 vowel_hz=.39 + vowels=600:90:1,1200:160:.7|500:80:1,900:120:.8 +``` + +The `vowels` field is an AquaSynth voice authority: each `|`-separated frame is +a formant bank, and Faust blends adjacent frames cyclically at `vowel_hz`. The +Zyn workout can seed frames from `FORMANT_FILTER` vowel sequences, but exact Zyn +filter-frequency position driving, slowness, and vowel clearness remain pressure +rather than hidden compatibility sludge. diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 28863e6..9457642 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -261,7 +261,7 @@ public static class FaustEmitter { source.AppendLine($"{name}_phased = {name}_filtered;"); } - source.AppendLine($"{name}_formants = {FormantExpression(name, voice)};"); + source.AppendLine($"{name}_formants = {FormantExpression(name, voice, ownerPath, parameters)};"); source.AppendLine($"{name} = (({name}_phased * (1.0 - {formantMix}) + {name}_formants * {formantMix}) * {envelope}{tremolo} * max(0.0, 1.0 + patch_mod_gain + {gain}) * {parameters.Expression(OwnerField(ownerPath, "gain"), voice.Gain)});"); source.AppendLine(); } @@ -495,11 +495,35 @@ public static class FaustEmitter return ordered; } - private static string FormantExpression(string name, Voice voice) + private static string FormantExpression(string name, Voice voice, string ownerPath, ParameterMap parameters) { - if (voice.Formants.Count == 0) return $"{name}_phased"; - var gainSum = Math.Max(voice.Formants.Sum(formant => Math.Abs(formant.Gain)), 0.001f); - var parts = voice.Formants.Select(formant => + if (voice.FormantFrames.Count > 0) + { + if (voice.FormantFrames.Count == 1) + { + return StaticFormantExpression(name, voice.FormantFrames[0].Formants); + } + + var frameCount = voice.FormantFrames.Count; + var rate = parameters.Expression(OwnerField(ownerPath, "color/vowel_rate"), voice.FormantFrameRateHz); + var position = $"wrap01(age * {rate}) * {F(frameCount)}"; + var frames = voice.FormantFrames.Select((frame, index) => + { + var distance = $"min(abs(({position}) - {F(index)}), {F(frameCount)} - abs(({position}) - {F(index)}))"; + var weight = $"max(0.0, 1.0 - {distance})"; + return $"({StaticFormantExpression(name, frame.Formants)}) * ({weight})"; + }); + return string.Join(" + ", frames); + } + + return StaticFormantExpression(name, voice.Formants); + } + + private static string StaticFormantExpression(string name, IReadOnlyList formants) + { + if (formants.Count == 0) return $"{name}_phased"; + var gainSum = Math.Max(formants.Sum(formant => Math.Abs(formant.Gain)), 0.001f); + var parts = formants.Select(formant => { var q = Math.Clamp(formant.FrequencyHz / Math.Max(formant.BandwidthHz, 10), 0.2f, 40); return $"({name}_phased : fi.resonbp({F(formant.FrequencyHz)}, {F(q)}, 1.0)) * {F(formant.Gain)}"; diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index e927ade..157f635 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -136,6 +136,8 @@ public sealed record VoiceColor( public sealed record Formant(float FrequencyHz, float BandwidthHz, float Gain); +public sealed record FormantFrame(IReadOnlyList Formants); + public sealed record Modulator( ModTarget Target, ModWaveform Waveform = ModWaveform.Sine, @@ -334,6 +336,8 @@ public sealed record Voice public FrequencyModulation Fm { get; init; } = new(); public VoiceColor Color { get; init; } = new(); public IReadOnlyList Formants { get; init; } = Array.Empty(); + public IReadOnlyList FormantFrames { get; init; } = Array.Empty(); + public float FormantFrameRateHz { get; init; } = 0.5f; public IReadOnlyList Modulators { get; init; } = Array.Empty(); public float Gain { get; init; } = 0.2f; } diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index c484c9c..9f890e2 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -397,6 +397,9 @@ public static class PatchScript var formants = TryGetAny(fields, ["formants", "fs"], out var formantSpec) ? ParseFormants(formantSpec, line) : []; + var formantFrames = TryGetAny(fields, ["vowels", "vowel_frames", "formant_frames"], out var vowelSpec) + ? ParseFormantFrames(vowelSpec, line) + : []; var modulators = TryGetAny(fields, ["mods", "m"], out var mods) ? ParseVoiceModulators(mods, line) @@ -470,6 +473,8 @@ public static class PatchScript GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo_hz"), "tremolo_hz", "th"), GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/formant_mix"), "formant_mix", "fmix")), Formants = formants, + FormantFrames = formantFrames, + FormantFrameRateHz = GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "color/vowel_rate"), "vowel_hz", "vowel_rate", "vowels_hz"), Modulators = modulators, Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale }; @@ -1232,6 +1237,11 @@ public static class PatchScript }) .ToList(); + private static List ParseFormantFrames(string value, int line) => + value.Split('|', StringSplitOptions.RemoveEmptyEntries) + .Select(frame => new FormantFrame(ParseFormants(frame, line))) + .ToList(); + private static List ParseVoiceModulators(string value, int line) => value.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(part => diff --git a/src/AquaSynth.Core/ZynInstrument.cs b/src/AquaSynth.Core/ZynInstrument.cs index 1892d81..1415259 100644 --- a/src/AquaSynth.Core/ZynInstrument.cs +++ b/src/AquaSynth.Core/ZynInstrument.cs @@ -109,6 +109,13 @@ public sealed record ZynPadRebuild( IReadOnlyList MatchedFeatures, IReadOnlyList MissingFeatures); +public sealed record ZynFormantMotionRebuild( + string InstrumentName, + int KitItemId, + string Script, + IReadOnlyList MatchedFeatures, + IReadOnlyList MissingFeatures); + public sealed record ZynPadTableRoot(float Sample0RootFrequencyHz, float? SelectedRootFrequencyHz = null); public enum ZynEngine @@ -164,6 +171,92 @@ public static class ZynInstrumentReader float playbackFrequencyHz = 261.6256f) => RebuildEnabledPadsAsAquaSynthScript(File.ReadAllBytes(path), tableRootFrequencyHzByKitItem, playbackFrequencyHz); + public static ZynFormantMotionRebuild RebuildFirstFormantMotionAsAquaSynthScript( + string path, + float playbackFrequencyHz = 110.0f) => + RebuildFirstFormantMotionAsAquaSynthScript(File.ReadAllBytes(path), playbackFrequencyHz); + + public static ZynFormantMotionRebuild RebuildFirstFormantMotionAsAquaSynthScript( + ReadOnlySpan bytes, + float playbackFrequencyHz = 110.0f) + { + if (playbackFrequencyHz <= 0) throw new ArgumentOutOfRangeException(nameof(playbackFrequencyHz)); + + var document = ParseDocument(bytes); + var instrument = document.Descendants("INSTRUMENT").FirstOrDefault() + ?? throw new ArgumentException("Zyn instrument XML is missing INSTRUMENT"); + var name = StringValue(instrument.Element("INFO"), "name", "unnamed"); + var item = instrument + .Descendants("INSTRUMENT_KIT_ITEM") + .Where(item => BoolParam(item, "enabled")) + .FirstOrDefault(item => item.Descendants("FORMANT_FILTER").Any()) + ?? throw new ArgumentException("Zyn instrument XML has no enabled formant filter kit item"); + + var formantFilter = item + .Descendants("FORMANT_FILTER") + .FirstOrDefault(filter => filter.Ancestors("FILTER_PARAMETERS") + .Elements("FILTER_LFO") + .Any(lfo => IntParam(lfo, "intensity", 0) > 0)) + ?? item.Descendants("FORMANT_FILTER").First(); + var filterLfo = formantFilter + .Ancestors("FILTER_PARAMETERS") + .Elements("FILTER_LFO") + .FirstOrDefault(lfo => IntParam(lfo, "intensity", 0) > 0); + + var frameCount = Math.Clamp(IntParam(formantFilter, "sequence_size", 1), 1, 8); + var formantCount = Math.Clamp(IntParam(formantFilter, "num_formants", 3), 1, 6); + var sequence = formantFilter + .Elements("SEQUENCE_POS") + .OrderBy(element => IntAttribute(element, "id")) + .Select(element => IntParam(element, "vowel_id", 0)) + .Take(frameCount) + .ToArray(); + if (sequence.Length == 0) + { + sequence = Enumerable.Range(0, frameCount).ToArray(); + } + + var frames = sequence + .Select(vowelId => ParseZynVowelFrame(formantFilter, vowelId, formantCount)) + .Where(frame => frame.Count > 0) + .ToList(); + if (frames.Count == 0) + { + throw new ArgumentException("Zyn formant filter has no vowel frame formants"); + } + + var safeName = SafeIdentifier(name); + var vowelText = string.Join("|", frames.Select(frame => string.Join(",", frame.Select(formant => $"{F(formant.FrequencyHz)}:{F(formant.BandwidthHz)}:{F(formant.Gain)}")))); + var vowelRate = filterLfo is null ? 0.5f : Math.Clamp(RealParam(filterLfo, "freq", 0.5f), 0.01f, 20.0f); + var engines = EnabledEnginesForReader(item).ToArray(); + var engine = engines.Contains(ZynEngine.AddSynth) ? "add" : + engines.Contains(ZynEngine.SubSynth) ? "sub" : + engines.Contains(ZynEngine.PadSynth) ? "pad" : "formant"; + + var script = new StringBuilder(); + script.AppendLine("# Generated from a ZynAddSubFX formant-motion fixture for parity pressure."); + script.AppendLine("# Scope: first enabled kit item with a FORMANT_FILTER vowel sequence."); + script.AppendLine("patch gain=0.45 soft_clip=true"); + script.AppendLine($"layer name={safeName} engine={engine} gain=0.25"); + script.AppendLine($"voice layer={safeName} wave=saw freq={F(playbackFrequencyHz)} gain=0.18 gate=1.8 env=rl rates=0.04,0.18,0.9,0.3 levels=1,0.9,0.75,0 formant_mix=0.7 vowel_hz={F(vowelRate)} vowels={vowelText}"); + + return new ZynFormantMotionRebuild( + name, + IntAttribute(item, "id"), + script.ToString(), + [ + new("formant_filter_count", item.Descendants("FORMANT_FILTER").Count().ToString(CultureInfo.InvariantCulture), "Read Zyn FORMANT_FILTER blocks from the enabled kit item."), + new("formant_sequence", string.Join(",", sequence), "Lowered the Zyn vowel sequence into AquaSynth vowel frames."), + new("formant_frames", frames.Count.ToString(CultureInfo.InvariantCulture), "Each frame owns one static formant bank; Faust blends between frames at control/audio expression time."), + new("formant_frame_rate_hz", F(vowelRate), filterLfo is null ? "No active FILTER_LFO was found; the workout uses a slow audible scan." : "Seeded from the active Zyn FILTER_LFO frequency.") + ], + [ + new("zyn_formant_position_driver", "filter frequency to vowel position", "Zyn derives vowel position from the formant filter cutoff path; AquaSynth currently states an explicit frame rate."), + new("zyn_formant_smoothing", "formant_slowness/vowel_clearness", "AquaSynth blends adjacent frames with a simple cyclic triangular window."), + new("zyn_add_voice_detail", "full ADD voice oscillator/FM stack", "This workout keeps the source deliberately plain so the formant-motion authority is the thing under test.") + ]); + } + public static ZynPadRebuild RebuildFirstPadAsAquaSynthScript( ReadOnlySpan bytes, float tableRootFrequencyHz, @@ -473,6 +566,13 @@ public static class ZynInstrumentReader private static bool IsEnabledPadItem(XElement item) => BoolParam(item, "enabled") && EngineEnabled(item, "pad_enabled", "PAD_SYNTH_PARAMETERS"); + private static IEnumerable EnabledEnginesForReader(XElement item) + { + if (EngineEnabled(item, "add_enabled", "ADD_SYNTH_PARAMETERS")) yield return ZynEngine.AddSynth; + if (EngineEnabled(item, "sub_enabled", "SUB_SYNTH_PARAMETERS")) yield return ZynEngine.SubSynth; + if (EngineEnabled(item, "pad_enabled", "PAD_SYNTH_PARAMETERS")) yield return ZynEngine.PadSynth; + } + private static string StringValue(XElement? root, string name, string fallback) => root?.Elements("string") .FirstOrDefault(element => AttributeValue(element, "name") == name) @@ -1433,6 +1533,43 @@ public static class ZynInstrumentReader return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; } + private static float RealParam(XElement? root, string name, float fallback) + { + var value = root?.Elements("par_real") + .FirstOrDefault(element => AttributeValue(element, "name") == name) + ?.Attribute("value") + ?.Value; + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + } + + private static IReadOnlyList ParseZynVowelFrame(XElement formantFilter, int vowelId, int formantCount) + { + var center = IntParam(formantFilter, "center_freq", 64); + var octaves = IntParam(formantFilter, "octaves_freq", 64); + return formantFilter + .Elements("VOWEL") + .FirstOrDefault(vowel => IntAttribute(vowel, "id") == vowelId) + ?.Elements("FORMANT") + .OrderBy(formant => IntAttribute(formant, "id")) + .Take(formantCount) + .Select(formant => + { + var frequency = ZynFormantFrequencyHz(IntParam(formant, "freq", 64), center, octaves); + var q = Math.Clamp(MathF.Pow(25.0f, (IntParam(formant, "q", 64) - 32.0f) / 64.0f), 0.2f, 40.0f); + var gain = MathF.Pow(0.1f, (1.0f - IntParam(formant, "amp", 127) / 127.0f) * 4.0f); + return new Formant(frequency, Math.Max(10.0f, frequency / q), gain); + }) + .ToArray() ?? []; + } + + private static float ZynFormantFrequencyHz(int value, int center, int octaves) + { + var centerHz = 10000.0f * MathF.Pow(10.0f, -(1.0f - center / 127.0f) * 2.0f); + var octaveSpan = 0.25f + 10.0f * octaves / 127.0f; + var octaveFactor = MathF.Pow(2.0f, octaveSpan); + return centerHz / MathF.Sqrt(octaveFactor) * MathF.Pow(octaveFactor, Math.Clamp(value, 0, 127) / 127.0f); + } + private static float EnvelopeTime(XElement? envelope, string name, float fallback) { var value = IntParam(envelope, name, -1); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index e6d6999..e3b0fb7 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -100,5 +100,6 @@ {"date":"2026-05-17","topic":"render-session-ownership","lesson":"`AquaSynthRenderSession` owns lazy native Faust compiler loading and one-shot render convenience. Engine hosts should hold a render session and request compiled products; they should not expose their own smoke-render helper or call `AquaSynthNativeCompiler.TryLoad` directly.","evidence":"Added `AquaSynthRenderSession` to AquaSynth.Faust and changed native render tests to use it. This removes the remaining reason for AquariumSynthHost to keep compiler-load policy."} {"date":"2026-05-17","topic":"package-versioning","lesson":"Use normal SemVer prerelease labels for AquaSynth packages. Do not encode the repo name into the version suffix; `0.1.0-alpha.2` is a real prerelease version, while `0.1.0-aquasynth.2` is project metadata cosplay.","evidence":"Renamed AquaSynth.Core and AquaSynth.Faust package versions from `0.1.0-aquasynth.2` to `0.1.0-alpha.2` before repacking Aquarium's local feed."} {"date":"2026-05-17","topic":"solution-format","lesson":"AquaSynth uses a normal Visual Studio `.sln` solution because Rider does not reliably like `.slnx`. Keep tests, state, and docs looking for `AquaSynth.sln`, not `AquaSynth.slnx`.","evidence":"Generated `AquaSynth.sln` with `dotnet new sln --format sln`, added Core/Faust/tests projects, removed `AquaSynth.slnx`, and replaced solution references across tests/state."} +{"date":"2026-05-17","topic":"zyn-formant-motion","lesson":"AquaSynth now owns a first vowel-frame motion surface instead of treating Zyn formants as static filters or loose counted pressure. Voice `vowels=` stores `|`-separated formant banks and `vowel_hz` scans them; Zyn `FORMANT_FILTER` sequences can seed this, while exact Zyn cutoff-position driving, formant slowness, and vowel clearness remain named pressure.","evidence":"Added `FormantFrame`, voice `FormantFrames`/`FormantFrameRateHz`, parser support for `vowels=`/`vowel_hz`, Faust cyclic frame blending, and `ZynInstrumentReader.RebuildFirstFormantMotionAsAquaSynthScript`. `UpstreamWideBassExportsFormantMotionWorkoutWhenAvailable` exercises `Companion/0056-Wide Bass.xiz`; focused PatchScript/ZynInstrument tests passed 73/73 and non-reference tests passed 103/103."} diff --git a/state/spine.yaml b/state/spine.yaml index 7ab0411..60cdd43 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -91,6 +91,7 @@ live_invariants: - Zyn OscilGen base-function modulation is source-table authority: apply it as phase modulation before base-function sampling, not as later oscillator modulation or filter/EQ compensation. - Zyn adaptive harmonics remap the prepared oscillator spectrum at the PAD table root frequency before PAD bandwidth/profile spreading. Do not skip it for sources whose wind/body character depends on sparse remapped harmonics. - Zyn coverage is now tracked as handled/counted/unknown buckets by `ZynInstrumentSurvey.CoverageDirectory`; do not treat broad parse counts as expressivity. Unknown PAD filter/waveshaper/OscilGen buckets and counted ADD/SUB/LFO/free-envelope/formant/effect buckets need parity workouts before they graduate into AquaSynth model changes. + - Voice `vowels=` owns authored formant-frame motion: each frame is a static formant bank and `vowel_hz` scans/blends the frames in Faust. Zyn `FORMANT_FILTER` vowel sequences may seed this surface, but Zyn's exact cutoff-position driver, formant slowness, and vowel clearness are still pressure, not hidden compatibility logic. - Filter damping is first-class: `lpf_q`/`lpq` owns explicit low-pass Q when a reference synth exposes analog filter damping. Low-pass order remains slope authority even when Q is present; Faust lowering cascades resonant low-pass stages instead of replacing order with one generic resonant filter. Do not fake Q by secretly moving cutoff, slope, or layer gain. - Structured `.aqua` stock lives under `patches/` as development source material. Every library patch must parse/export, and the patch library must stay out of the NuGet package unless intentionally promoted. - Project-authored Dexed patches may be generated inside tests for calibration; do not ship them in the NuGet package or treat them as runtime dependencies. @@ -113,11 +114,11 @@ reference_targets: priority: 2 purpose: "Additive, subtractive, PAD, formant, kit, and layered-instrument pressure test." source: "https://github.com/zynaddsubfx/zynaddsubfx" - status: "Initial ZynInstrumentReader reads plain or gzip-compressed .xiz XML from project-authored fixtures and classifies active ADD/SUB/PAD engines, enabled kit items, layering, envelopes, free envelopes, LFOs, filters, formants, and effects as ReferencePatch features. `ZynInstrumentSurvey.CoverageDirectory` now classifies observed Zyn features into handled/counted/unknown buckets and writes `artifacts/parity/zyn-upstream-bank-survey/coverage.md` from the upstream GPL bank. First AquaSynth rebuilds exist for additive lead, PAD texture, and vocal/formant layer and now use named `layer` declarations. Additive rebuilds can use `harmonics` banks attached to layers, PAD rebuilds can use `spectrum` PADsynth-style FFT wavetable banks attached to layers, and PAD/vocal rebuilds use staged voice `env=rl` envelopes through layer defaults. `ZynInstrumentReader.RebuildFirstPadAsAquaSynthScript` now emits per-preset PAD `.aqua` rebuilds for enabled PAD kit items with OSCIL harmonic expansion, oracle-reported table base frequency, PAD detune, Zyn PAD profile/bandwidth/position semantics, static/filter-envelope low-pass mapping, low-pass note tracking, explicit low-pass Q, logarithmic Zyn envelope times, and calibrated PAD output gain. ZynAddSubFX source is pinned under `external/zynaddsubfx` at 3ab608c432996ba4d582176572c0b0f82328c825 for test-only parity work, and `tools/zyn-reference/build-zyn-reference.ps1` now builds an ignored patched source copy with MSYS2/MinGW, proves `PadNoteTest`, and builds `ZynPadReference`, including a test-only `harmonics` mode for dumping normalized OscilGen amplitudes before PAD generation. The optional PAD reports emit note and source-table WAV/listening artifacts. Latest accepted upstream PAD pressure has DoublePadBass close enough by ear with log-mel 0.247748 / score 0.650029 after Q/order cascade, while Ghost Ensemble improves to log-mel 0.369402 / score 0.57229. The remaining Ghost gap is no longer first-32 OscilGen harmonic extraction; oracle harmonic dumps match those ratios. The upstream GPL instrument bank is initialized at `external/zynaddsubfx/instruments` commit c5c912131b31df5fdf372d2f06a25aaf2375837f; survey tests parse 1318/1318 .xiz files, write a pressure report and coverage map, and render a five-patch upstream PAD listening batch under ignored artifacts." + status: "Initial ZynInstrumentReader reads plain or gzip-compressed .xiz XML from project-authored fixtures and classifies active ADD/SUB/PAD engines, enabled kit items, layering, envelopes, free envelopes, LFOs, filters, formants, and effects as ReferencePatch features. `ZynInstrumentSurvey.CoverageDirectory` now classifies observed Zyn features into handled/counted/unknown buckets and writes `artifacts/parity/zyn-upstream-bank-survey/coverage.md` from the upstream GPL bank. First AquaSynth rebuilds exist for additive lead, PAD texture, and vocal/formant layer and now use named `layer` declarations. Additive rebuilds can use `harmonics` banks attached to layers, PAD rebuilds can use `spectrum` PADsynth-style FFT wavetable banks attached to layers, and PAD/vocal rebuilds use staged voice `env=rl` envelopes through layer defaults. Voices can now use `vowels=` formant frames plus `vowel_hz`; `ZynInstrumentReader.RebuildFirstFormantMotionAsAquaSynthScript` seeds that surface from Zyn `FORMANT_FILTER` vowel sequences for Wide Bass-style workouts while keeping exact Zyn position-driver/smoothing as named missing features. `ZynInstrumentReader.RebuildFirstPadAsAquaSynthScript` now emits per-preset PAD `.aqua` rebuilds for enabled PAD kit items with OSCIL harmonic expansion, oracle-reported table base frequency, PAD detune, Zyn PAD profile/bandwidth/position semantics, static/filter-envelope low-pass mapping, low-pass note tracking, explicit low-pass Q, logarithmic Zyn envelope times, and calibrated PAD output gain. ZynAddSubFX source is pinned under `external/zynaddsubfx` at 3ab608c432996ba4d582176572c0b0f82328c825 for test-only parity work, and `tools/zyn-reference/build-zyn-reference.ps1` now builds an ignored patched source copy with MSYS2/MinGW, proves `PadNoteTest`, and builds `ZynPadReference`, including a test-only `harmonics` mode for dumping normalized OscilGen amplitudes before PAD generation. The optional PAD reports emit note and source-table WAV/listening artifacts. Latest accepted upstream PAD pressure has DoublePadBass close enough by ear with log-mel 0.247748 / score 0.650029 after Q/order cascade, while Ghost Ensemble improves to log-mel 0.369402 / score 0.57229. The remaining Ghost gap is no longer first-32 OscilGen harmonic extraction; oracle harmonic dumps match those ratios. The upstream GPL instrument bank is initialized at `external/zynaddsubfx/instruments` commit c5c912131b31df5fdf372d2f06a25aaf2375837f; survey tests parse 1318/1318 .xiz files, write a pressure report and coverage map, and render a five-patch upstream PAD listening batch under ignored artifacts." first_slice: - "Use the Zyn PAD oracle report to isolate source-table, playback/gain, envelope, and fixture-mapping mismatch before further Zyn DSL golf." - - "Use named layers as the owner before promoting richer formant morphing syntax." - - "Additive harmonic banks, PAD spectral clouds, and staged voice envelopes now exist; prove any next syntax across more than one target with parity, not parser-only fixtures." + - "Use Wide Bass formant-frame output as the foothold before attempting exact Zyn formant cutoff-position driving." + - "Additive harmonic banks, PAD spectral clouds, staged voice envelopes, and vowel-frame motion now exist; prove any next syntax across more than one target with parity, not parser-only fixtures." - "Do not add all remaining pressures at once." faust: priority: 3 diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 6603121..8795ac4 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -65,6 +65,22 @@ public sealed class PatchScriptTests Assert.Equal(.01f, lane.Modulator.Bias, 5); } + [Fact] + public void ParserSupportsVoiceVowelFrames() + { + var patch = PatchScript.Parse("v w=saw f=90 fmix=.7 vowel_hz=.8 vowels=600:90:1,1200:160:.7|500:80:1,900:120:.8"); + + var voice = Assert.Single(patch.Voices); + Assert.Equal(2, voice.FormantFrames.Count); + Assert.Equal(2, voice.FormantFrames[0].Formants.Count); + Assert.Equal(.8f, voice.FormantFrameRateHz, 5); + + var faust = FaustEmitter.Emit(patch).Source; + Assert.Contains("wrap01(age * 0.8)", faust); + Assert.Contains("fi.resonbp(600", faust); + Assert.Contains("fi.resonbp(500", faust); + } + [Fact] public void ParserPreservesDeclaredPatchParameters() { diff --git a/tests/AquaSynth.Dsl.Tests/ZynInstrumentTests.cs b/tests/AquaSynth.Dsl.Tests/ZynInstrumentTests.cs index 4576ec0..5b05919 100644 --- a/tests/AquaSynth.Dsl.Tests/ZynInstrumentTests.cs +++ b/tests/AquaSynth.Dsl.Tests/ZynInstrumentTests.cs @@ -225,6 +225,31 @@ public sealed class ZynInstrumentTests Assert.True(octavePartials[4] < 0.001f); } + [Fact] + public void UpstreamWideBassExportsFormantMotionWorkoutWhenAvailable() + { + var root = RepositoryRoot(); + var path = Path.Combine(root, "external", "zynaddsubfx", "instruments", "banks", "Companion", "0056-Wide Bass.xiz"); + if (!File.Exists(path)) + { + return; + } + + var rebuild = ZynInstrumentReader.RebuildFirstFormantMotionAsAquaSynthScript(path); + var patch = PatchScript.Parse(rebuild.Script); + + var voice = Assert.Single(patch.Voices); + Assert.Equal("Wide Bass", rebuild.InstrumentName); + Assert.True(voice.FormantFrames.Count >= 3); + Assert.All(voice.FormantFrames, frame => Assert.True(frame.Formants.Count >= 3)); + Assert.Contains("vowels=", rebuild.Script); + Assert.Contains(rebuild.MatchedFeatures, feature => feature.Name == "formant_sequence"); + Assert.Contains(rebuild.MissingFeatures, feature => feature.Name == "zyn_formant_position_driver"); + + var faust = FaustEmitter.Emit(patch).Source; + Assert.Contains("wrap01(age *", faust); + } + [Fact] public void RebuildMapsZynStateVariablePadLowPassToAquaFilterAuthority() {