From e3cc1e04f5062384dd7d3f8fb3f8e93186fd7d0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 22:30:55 +0100 Subject: [PATCH] Add Pink Trombone log-mel parity harness --- docs/pink-trombone-dsl-parity-plan.md | 26 +- docs/pink-trombone-parity.md | 17 +- src/AquaSynth.Core/FaustExport.cs | 142 ++++++--- .../PinkTromboneParityFixtures.cs | 74 ++++- .../PinkTromboneReferenceRenderer.cs | 275 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 2 + state/spine.yaml | 2 +- .../PinkTromboneLogMelParityTests.cs | 132 +++++++++ 9 files changed, 611 insertions(+), 60 deletions(-) create mode 100644 src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs create mode 100644 tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index 38a35a0..2ed60a3 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -85,8 +85,11 @@ named failure against Pink Trombone, but it does not own the anatomy: moving tongue/constriction gestures. - Renders Aqua DSL reconstructions. - Scores log-mel cosine similarity plus targeted feature probes. - - First fixture catalog exists in `PinkTromboneParityFixtures`; reference - rendering/audio thresholds remain the next pass. + - First fixture catalog exists in `PinkTromboneParityFixtures`. + - `PinkTromboneReferenceRenderer` now renders deterministic test-only + traveling-wave references for the fixture controls. + - `PinkTromboneLogMelParityTests` writes reference/candidate WAVs and report + artifacts under `artifacts/parity/pink-trombone-logmel/`. ## First Cut @@ -100,11 +103,20 @@ pressure instead of silently inventing all of them inside one helper. The waveguide lowering consumes the derived oral reflection field as right/left section state equations. -The next cut is timing exactness and proof: the oral/nasal tube now derives moving -per-section diameter/area/reflection fields, distributes frication into section -updates, derives release bursts from obstruction history, and carries substep -clock intent. Exact two recursive tract state updates per output sample and -reference-rendered parity thresholds still need proof. +The first log-mel rung is now real but not flattering. The compileable Aqua +tract proxy baseline against the deterministic PT-style renderer currently +lands around cosine 0.51 open vowel, 0.54 front vowel, 0.39 nasal, 0.12 +sibilant, and -0.18 closure-release. Those are pressure readings, not parity. + +The attempted Faust waveguide backend exposed a harder boundary: named +mutually recursive right/left state equations are not accepted by Faust in the +obvious generated form, and even simplified large waveguide expressions can +make the Faust compiler churn. The current automated parity harness therefore +uses the compileable resonator/proxy lowering for numeric baselines while the +waveguide-Faust backend remains explicit debt. Exact two recursive tract state +updates per output sample still need a coherent backend strategy, likely a +proper vector feedback component or native DSP renderer rather than more named +equation sprawl. ## Cut Line diff --git a/docs/pink-trombone-parity.md b/docs/pink-trombone-parity.md index db44d83..ffc9904 100644 --- a/docs/pink-trombone-parity.md +++ b/docs/pink-trombone-parity.md @@ -109,8 +109,14 @@ Trombone anatomy: updates. - `PinkTromboneParityFixtures` defines fixed Aqua DSL workouts for open, front, nasal, sibilant, and closure cases using the reusable low-level - primitives. The reference-rendered PT audio still needs to be captured or - generated before thresholds can be honest. + primitives. +- `PinkTromboneReferenceRenderer` renders deterministic test-only traveling-wave + references for those fixture controls. +- `PinkTromboneLogMelParityTests` renders compileable Aqua proxy candidates, + writes listening WAV/report artifacts under + `artifacts/parity/pink-trombone-logmel/`, and reports the current ugly + baseline: open vowel cosine 0.5129, front vowel 0.5405, nasal 0.3882, + sibilant 0.1156, closure-release -0.1830. - The proxy script parses and emits Faust with live controls, but its missing features explicitly name the waveguide authorities AquaSynth does not own. - `tools/vocal-tract-playground` exposes the same control surface for fast @@ -130,8 +136,11 @@ of these: DSP model in AquaSynth.Faust/Core: typed tract sections, diameter curves, reflection calculation, glottal source, nasal branch, and a Faust emitter for that structure. -2. Add a test-only Pink Trombone oracle renderer and compare AquaSynth's first - tract-DSP candidate against fixed vowel/constriction fixtures. +2. Repair the waveguide backend so the low-level tract primitive can render + through either a proper Faust vector feedback component or a native DSP + renderer without compiler churn. +3. Use the log-mel artifact loop to golf fixture-by-fixture once the backend + can render the real tract path instead of the proxy baseline. The new owner should be explainable as: diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 6b379c8..4b1b408 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -37,7 +37,7 @@ public static class FaustEmitter } source.AppendLine("clip01(x) = min(1.0, max(0.0, x));"); source.AppendLine("wrap01(x) = x - floor(x);"); - source.AppendLine("slew(rate,x) = x : *(1.0 - exp(-max(0.0001, rate) / ma.SR)) : + ~ *(exp(-max(0.0001, rate) / ma.SR));"); + source.AppendLine("slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));"); source.AppendLine("softclip(x) = ma.tanh(x * 1.35);"); source.AppendLine("fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;"); source.AppendLine("release_start(a,d,g) = max(g, a + d);"); @@ -402,18 +402,24 @@ public static class FaustEmitter var constrictionSlew = parameters.Expression(OwnerField(tractPath, "motion/constriction_slew"), motion?.ConstrictionSlewPerSecond ?? 24); var velumSlew = parameters.Expression(OwnerField(tractPath, "motion/velum_slew"), motion?.VelumSlewPerSecond ?? 16); var obstructionThreshold = parameters.Expression(OwnerField(tractPath, "motion/obstruction_threshold"), motion?.ObstructionThreshold ?? 0.05f); - var tongueIndexRaw = parameters.Expression(OwnerField(tractPath, "tongue_index"), tract.TongueIndex); - var tongueDiameterRaw = parameters.Expression(OwnerField(tractPath, "tongue_diameter"), tract.TongueDiameter); - var velumRaw = $"clip01(({parameters.Expression(OwnerField(tractPath, "velum"), tract.Velum)} - 0.01) / 0.39)"; - var constrictionIndexRaw = parameters.Expression(OwnerField(tractPath, "constriction_index"), tract.ConstrictionIndex); - var constrictionDiameterRaw = parameters.Expression(OwnerField(tractPath, "constriction_diameter"), tract.ConstrictionDiameter); - var tongueIndex = motion is null ? tongueIndexRaw : $"slew({diameterSlew}, {tongueIndexRaw})"; - var tongueDiameter = motion is null ? tongueDiameterRaw : $"slew({diameterSlew}, {tongueDiameterRaw})"; - var velum = motion is null ? velumRaw : $"slew({velumSlew}, {velumRaw})"; - var constrictionIndex = motion is null ? constrictionIndexRaw : $"slew({constrictionSlew}, {constrictionIndexRaw})"; - var constrictionDiameter = motion is null ? constrictionDiameterRaw : $"slew({constrictionSlew}, {constrictionDiameterRaw})"; + var tongueIndexPath = OwnerField(tractPath, "tongue_index"); + var tongueDiameterPath = OwnerField(tractPath, "tongue_diameter"); + var velumPath = OwnerField(tractPath, "velum"); + var constrictionIndexPath = OwnerField(tractPath, "constriction_index"); + var constrictionDiameterPath = OwnerField(tractPath, "constriction_diameter"); + var lipOpeningPath = OwnerField(tractPath, "lip_opening"); + var tongueIndexRaw = parameters.Expression(tongueIndexPath, tract.TongueIndex); + var tongueDiameterRaw = parameters.Expression(tongueDiameterPath, tract.TongueDiameter); + var velumRaw = $"clip01(({parameters.Expression(velumPath, tract.Velum)} - 0.01) / 0.39)"; + var constrictionIndexRaw = parameters.Expression(constrictionIndexPath, tract.ConstrictionIndex); + var constrictionDiameterRaw = parameters.Expression(constrictionDiameterPath, tract.ConstrictionDiameter); + var tongueIndexValue = SmoothControl(motion, parameters, tongueIndexPath, diameterSlew, tongueIndexRaw); + var tongueDiameterValue = SmoothControl(motion, parameters, tongueDiameterPath, diameterSlew, tongueDiameterRaw); + var velumValue = SmoothControl(motion, parameters, velumPath, velumSlew, velumRaw); + var constrictionIndexValue = SmoothControl(motion, parameters, constrictionIndexPath, constrictionSlew, constrictionIndexRaw); + var constrictionDiameterValue = SmoothControl(motion, parameters, constrictionDiameterPath, constrictionSlew, constrictionDiameterRaw); var turbulence = $"clip01({parameters.Expression(OwnerField(tractPath, "turbulence"), tract.Turbulence)})"; - var lipOpening = parameters.Expression(OwnerField(tractPath, "lip_opening"), tract.LipOpening); + var lipOpening = parameters.Expression(lipOpeningPath, tract.LipOpening); var glottalReflection = parameters.Expression(OwnerField(tractPath, "glottal_reflection"), tract.GlottalReflection); var lipReflection = parameters.Expression(OwnerField(tractPath, "lip_reflection"), tract.LipReflection); var areaFunction = tract.AreaFunction; @@ -426,15 +432,31 @@ public static class FaustEmitter var reflectionEnergy = F(areaFunction is null ? 0.12f : MathF.Min(1, areaFunction.ReflectionCoefficients.Sum(reflection => MathF.Abs(reflection)) / Math.Max(1, areaFunction.ReflectionCoefficients.Count))); var aspiration = $"clip01({parameters.Expression(OwnerField(tractPath, "glottis/aspiration"), glottis?.Aspiration ?? 0.08f)})"; var glottalSkew = $"clip01({parameters.Expression(OwnerField(tractPath, "glottis/skew"), glottis?.Skew ?? 0.42f)})"; - var injectionPositionRaw = parameters.Expression(OwnerField(tractPath, "injection/position"), injection?.Position ?? tract.ConstrictionIndex); - var injectionDiameterRaw = parameters.Expression(OwnerField(tractPath, "injection/diameter"), injection?.Diameter ?? tract.ConstrictionDiameter); - var injectionPosition = motion is null ? injectionPositionRaw : $"slew({constrictionSlew}, {injectionPositionRaw})"; - var injectionDiameter = motion is null ? injectionDiameterRaw : $"slew({constrictionSlew}, {injectionDiameterRaw})"; + var injectionPositionPath = OwnerField(tractPath, "injection/position"); + var injectionDiameterPath = OwnerField(tractPath, "injection/diameter"); + var injectionPositionRaw = parameters.Expression(injectionPositionPath, injection?.Position ?? tract.ConstrictionIndex); + var injectionDiameterRaw = parameters.Expression(injectionDiameterPath, injection?.Diameter ?? tract.ConstrictionDiameter); + var injectionPositionValue = SmoothControl(motion, parameters, injectionPositionPath, constrictionSlew, injectionPositionRaw); + var injectionDiameterValue = SmoothControl(motion, parameters, injectionDiameterPath, constrictionSlew, injectionDiameterRaw); var injectionTurbulence = $"clip01({parameters.Expression(OwnerField(tractPath, "injection/turbulence"), injection?.Turbulence ?? tract.Turbulence)})"; var injectionBurst = $"clip01({parameters.Expression(OwnerField(tractPath, "injection/burst"), injection?.Burst ?? 0)})"; var injectionWidth = $"max(0.05, {parameters.Expression(OwnerField(tractPath, "injection/width"), injection?.Width ?? 1)})"; source.AppendLine($"{name}_tract_phase = os.phasor(1.0, {frequency});"); + source.AppendLine($"{name}_tract_tongue_index = {tongueIndexValue};"); + source.AppendLine($"{name}_tract_tongue_diameter = {tongueDiameterValue};"); + source.AppendLine($"{name}_tract_velum = {velumValue};"); + source.AppendLine($"{name}_tract_constriction_index = {constrictionIndexValue};"); + source.AppendLine($"{name}_tract_constriction_diameter = {constrictionDiameterValue};"); + source.AppendLine($"{name}_tract_injection_index = {injectionPositionValue};"); + source.AppendLine($"{name}_tract_injection_diameter = {injectionDiameterValue};"); + var tongueIndex = $"{name}_tract_tongue_index"; + var tongueDiameter = $"{name}_tract_tongue_diameter"; + var velum = $"{name}_tract_velum"; + var constrictionIndex = $"{name}_tract_constriction_index"; + var constrictionDiameter = $"{name}_tract_constriction_diameter"; + var injectionPosition = $"{name}_tract_injection_index"; + var injectionDiameter = $"{name}_tract_injection_diameter"; source.AppendLine($"{name}_tract_tongue_pos = clip01({tongueIndex} / {F(sections)});"); source.AppendLine($"{name}_tract_constriction_pos = clip01({constrictionIndex} / {F(sections)});"); source.AppendLine($"{name}_tract_injection_pos = clip01({injectionPosition} / {F(sections)});"); @@ -480,7 +502,13 @@ public static class FaustEmitter constrictionIndex, constrictionDiameter, lipOpening, - shapeReturn); + shapeReturn, + motion is not null && ( + parameters.IsBound(tongueIndexPath) || + parameters.IsBound(tongueDiameterPath) || + parameters.IsBound(constrictionIndexPath) || + parameters.IsBound(constrictionDiameterPath) || + parameters.IsBound(lipOpeningPath))); } source.AppendLine($"{name}_tract_oral = {name}_tract_raw * 0.18 + ({name}_tract_raw : fi.resonbp({name}_tract_f1, {name}_tract_q, 1.0)) * (0.75 + {name}_tract_lip * 0.65) + ({name}_tract_raw : fi.resonbp({name}_tract_f2, {name}_tract_q, 1.0)) * (0.85 + {name}_tract_tongue_close) + ({name}_tract_raw : fi.resonbp({name}_tract_f3, {name}_tract_q, 1.0)) * (0.35 + {name}_tract_constriction_close);"); @@ -496,6 +524,9 @@ public static class FaustEmitter return $"{name}_tract_radiated"; } + private static string SmoothControl(TractMotion? motion, ParameterMap parameters, string fieldPath, string rate, string expression) => + motion is not null && parameters.IsBound(fieldPath) ? $"slew({rate}, {expression})" : expression; + private static string TractWaveguideExpression( StringBuilder source, VocalTract tract, @@ -510,9 +541,10 @@ public static class FaustEmitter string constrictionIndex, string constrictionDiameter, string lipOpening, - string shapeReturn) + string shapeReturn, + bool smoothShape) { - var sections = areaFunction.Sections; + var sections = Math.Min(areaFunction.Sections, 24); var loss = F(Math.Clamp(tract.WaveguideLoss, 0, 1)); var substeps = Math.Max(1, tract.Substeps); source.AppendLine($"{name}_wg_loss = {loss};"); @@ -525,12 +557,22 @@ public static class FaustEmitter var rest = F(areaFunction.Diameters[i]); var tongueWidth = F(Math.Max(1, sections * 0.18)); var constrictionWidth = F(Math.Max(1, sections * 0.09)); - var target = i == sections - 1 - ? lipOpening - : $"{rest} + ({tongueDiameter} - {rest}) * exp(-pow(({F(i)} - {tongueIndex}) / {tongueWidth}, 2.0))"; - target = $"min({target}, {constrictionDiameter} + max(0.0, ({target}) - {constrictionDiameter}) * (1.0 - exp(-pow(({F(i)} - {constrictionIndex}) / {constrictionWidth}, 2.0))))"; - source.AppendLine($"{name}_wg_diameter_target_{i} = max(0.0, {target});"); - source.AppendLine($"{name}_wg_diameter_{i} = slew({shapeReturn}, {name}_wg_diameter_target_{i});"); + if (smoothShape) + { + source.AppendLine($"{name}_wg_tongue_weight_{i} = exp(0.0 - pow(({F(i)} - {tongueIndex}) / {tongueWidth}, 2.0));"); + source.AppendLine($"{name}_wg_constriction_weight_{i} = exp(0.0 - pow(({F(i)} - {constrictionIndex}) / {constrictionWidth}, 2.0));"); + source.AppendLine(i == sections - 1 + ? $"{name}_wg_diameter_base_{i} = {lipOpening};" + : $"{name}_wg_diameter_base_{i} = {rest} + ({tongueDiameter} - {rest}) * {name}_wg_tongue_weight_{i};"); + source.AppendLine($"{name}_wg_diameter_target_{i} = max(0.0, min({name}_wg_diameter_base_{i}, {constrictionDiameter} + max(0.0, {name}_wg_diameter_base_{i} - {constrictionDiameter}) * (1.0 - {name}_wg_constriction_weight_{i})));"); + } + else + { + source.AppendLine($"{name}_wg_diameter_target_{i} = {F(StaticWaveguideDiameter(tract, areaFunction, i, sections))};"); + } + source.AppendLine(smoothShape + ? $"{name}_wg_diameter_{i} = slew({shapeReturn}, {name}_wg_diameter_target_{i});" + : $"{name}_wg_diameter_{i} = {name}_wg_diameter_target_{i};"); source.AppendLine($"{name}_wg_area_{i} = max(0.000001, {name}_wg_diameter_{i} * {name}_wg_diameter_{i});"); source.AppendLine($"{name}_wg_inject_{i} = {name}_tract_injection_pressure * {name}_wg_substep_drive * clip01(1.0 - abs({name}_wg_injection_cell - {F(i)}) / {injectionWidth});"); } @@ -541,28 +583,28 @@ public static class FaustEmitter var nasal = tract.Nasal is { AreaFunction: { } nasalShape } ? tract.Nasal : null; var junction = nasal is null ? -1 : Math.Clamp(nasal.JunctionIndex, 1, sections - 2); - source.AppendLine($"{name}_wg_r_0 = ({name}_tract_excitation * {name}_wg_substep_drive + {name}_wg_inject_0 * 0.5 + {name}_wg_l_0 * {glottalReflection}) : mem;"); - source.AppendLine($"{name}_wg_l_{sections - 1} = ({name}_wg_r_{sections - 1} * {lipReflection}) : mem;"); + source.AppendLine($"{name}_wg_r_0 = ({name}_tract_excitation * {name}_wg_substep_drive + {name}_wg_inject_0 * 0.5 + 0.0 * {glottalReflection}) : mem;"); for (var i = 1; i < sections; i++) { - if (i == junction) - { - continue; - } - source.AppendLine($"{name}_wg_scatter_{i} = {name}_wg_k_{i} * ({name}_wg_r_{i - 1} + {name}_wg_l_{i});"); + source.AppendLine($"{name}_wg_scatter_{i} = {name}_wg_k_{i} * {name}_wg_r_{i - 1};"); source.AppendLine($"{name}_wg_r_{i} = (({name}_wg_r_{i - 1} - {name}_wg_scatter_{i} + {name}_wg_inject_{i} * 0.5) * {name}_wg_substep_loss) : mem;"); + } + + source.AppendLine($"{name}_wg_l_{sections - 1} = ({name}_wg_r_{sections - 1} * {lipReflection}) : mem;"); + for (var i = sections - 1; i >= 1; i--) + { source.AppendLine($"{name}_wg_l_{i - 1} = (({name}_wg_l_{i} + {name}_wg_scatter_{i} + {name}_wg_inject_{i - 1} * 0.5) * {name}_wg_substep_loss) : mem;"); } var noseOutput = "0.0"; if (nasal is not null && nasal.AreaFunction is { } nasalFunction) { - var noseSections = nasalFunction.Sections; + var noseSections = Math.Min(nasalFunction.Sections, 16); var noseReflections = nasalFunction.ReflectionCoefficients; var noseLoss = F(Math.Clamp(nasal.Loss, 0, 1)); var noseReflection = F(nasal.Reflection); source.AppendLine($"{name}_nose_loss = {noseLoss};"); - for (var i = 0; i < noseReflections.Count; i++) + for (var i = 0; i < Math.Min(noseReflections.Count, noseSections - 1); i++) { source.AppendLine($"{name}_nose_k_{i + 1} = {F(noseReflections[i])};"); } @@ -571,14 +613,15 @@ public static class FaustEmitter source.AppendLine($"{name}_nose_reflect_left = (2.0 * {name}_wg_area_{junction - 1} - {name}_nose_sum) / {name}_nose_sum;"); source.AppendLine($"{name}_nose_reflect_right = (2.0 * {name}_wg_area_{junction} - {name}_nose_sum) / {name}_nose_sum;"); source.AppendLine($"{name}_nose_reflect_nose = (2.0 * {name}_nose_area - {name}_nose_sum) / {name}_nose_sum;"); - source.AppendLine($"{name}_wg_l_{junction - 1} = ((({name}_nose_reflect_left * {name}_wg_r_{junction - 1}) + (1.0 + {name}_nose_reflect_left) * ({name}_wg_l_{junction} + {name}_nose_l_0) + {name}_wg_inject_{junction - 1} * 0.5) * {name}_wg_substep_loss) : mem;"); - source.AppendLine($"{name}_wg_r_{junction} = ((({name}_nose_reflect_right * {name}_wg_l_{junction}) + (1.0 + {name}_nose_reflect_right) * ({name}_wg_r_{junction - 1} + {name}_nose_l_0) + {name}_wg_inject_{junction} * 0.5) * {name}_wg_substep_loss) : mem;"); - source.AppendLine($"{name}_nose_r_0 = (({name}_nose_reflect_nose * {name}_nose_l_0) + (1.0 + {name}_nose_reflect_nose) * ({name}_wg_l_{junction} + {name}_wg_r_{junction - 1})) * {name}_nose_loss : mem;"); - source.AppendLine($"{name}_nose_l_{noseSections - 1} = ({name}_nose_r_{noseSections - 1} * {noseReflection}) : mem;"); + source.AppendLine($"{name}_nose_r_0 = (((1.0 + {name}_nose_reflect_nose) * ({name}_wg_l_{junction} + {name}_wg_r_{junction - 1})) * {name}_nose_loss) : mem;"); for (var i = 1; i < noseSections; i++) { - source.AppendLine($"{name}_nose_scatter_{i} = {name}_nose_k_{i} * ({name}_nose_r_{i - 1} + {name}_nose_l_{i});"); + source.AppendLine($"{name}_nose_scatter_{i} = {name}_nose_k_{i} * {name}_nose_r_{i - 1};"); source.AppendLine($"{name}_nose_r_{i} = (({name}_nose_r_{i - 1} - {name}_nose_scatter_{i}) * {name}_nose_loss) : mem;"); + } + source.AppendLine($"{name}_nose_l_{noseSections - 1} = ({name}_nose_r_{noseSections - 1} * {noseReflection}) : mem;"); + for (var i = noseSections - 1; i >= 1; i--) + { source.AppendLine($"{name}_nose_l_{i - 1} = (({name}_nose_l_{i} + {name}_nose_scatter_{i}) * {name}_nose_loss) : mem;"); } source.AppendLine($"{name}_tract_nose_waveguide = {name}_nose_r_{noseSections - 1};"); @@ -590,6 +633,27 @@ public static class FaustEmitter return $"{name}_tract_radiated"; } + private static float StaticWaveguideDiameter(VocalTract tract, TractAreaFunction areaFunction, int index, int emittedSections) + { + var restIndex = Math.Min(index, areaFunction.Diameters.Count - 1); + var diameter = areaFunction.Diameters[restIndex]; + if (index == emittedSections - 1) + { + diameter = tract.LipOpening; + } + else + { + var tongueWidth = Math.Max(1, emittedSections * 0.18f); + var tongueWeight = MathF.Exp(0 - MathF.Pow((index - tract.TongueIndex) / tongueWidth, 2)); + diameter = diameter + (tract.TongueDiameter - diameter) * tongueWeight; + } + + var constrictionWidth = Math.Max(1, emittedSections * 0.09f); + var constrictionWeight = MathF.Exp(0 - MathF.Pow((index - tract.ConstrictionIndex) / constrictionWidth, 2)); + diameter = Math.Min(diameter, tract.ConstrictionDiameter + Math.Max(0, diameter - tract.ConstrictionDiameter) * (1 - constrictionWeight)); + return Math.Max(0, diameter); + } + private static void EmitOperatorGraph(StringBuilder source, Playback playback, OperatorGraph graph, string name, ParameterMap parameters, List warnings) { if (graph.Operators.Count == 0) diff --git a/src/AquaSynth.Core/PinkTromboneParityFixtures.cs b/src/AquaSynth.Core/PinkTromboneParityFixtures.cs index a53346e..bf57d9e 100644 --- a/src/AquaSynth.Core/PinkTromboneParityFixtures.cs +++ b/src/AquaSynth.Core/PinkTromboneParityFixtures.cs @@ -3,9 +3,26 @@ namespace AquaSynth.Dsl; public sealed record PinkTromboneParityFixture( string Id, string Description, + PinkTromboneFixtureControls Controls, string AquaScript, IReadOnlyList ReferenceFeatures); +public sealed record PinkTromboneFixtureControls( + float Frequency = 140, + float Intensity = 0.72f, + float Tenseness = 0.6f, + float TongueIndex = 13, + float TongueDiameter = 2.7f, + float ConstrictionIndex = 32, + float ConstrictionDiameter = 1, + float Turbulence = 0.1f, + float Velum = 0.01f, + float LipOpening = 1.5f, + float GlottalReflection = 0.75f, + float LipReflection = -0.85f, + float Gain = 0.7f, + float Burst = 0.25f); + public static class PinkTromboneParityFixtures { public static IReadOnlyList All { get; } = @@ -13,27 +30,63 @@ public static class PinkTromboneParityFixtures Fixture( "open-vowel", "Sustained open oral vowel with modal glottis and quiet constriction.", - "tongue_index=13 tongue_diameter=2.7 constriction_index=32 constriction_diameter=1.4 turbulence=0.02 velum=0.01 lip=1.7", + new PinkTromboneFixtureControls( + TongueIndex: 13, + TongueDiameter: 2.7f, + ConstrictionIndex: 32, + ConstrictionDiameter: 1.4f, + Turbulence: 0.02f, + Velum: 0.01f, + LipOpening: 1.7f), ["glottal_source", "main_tract_waveguide_cells", "diameter_to_reflection_coefficients"]), Fixture( "front-vowel", "Front constricted vowel pressure for tongue-body movement.", - "tongue_index=27 tongue_diameter=1.05 constriction_index=34 constriction_diameter=1.2 turbulence=0.03 velum=0.01 lip=1.1", + new PinkTromboneFixtureControls( + TongueIndex: 27, + TongueDiameter: 1.05f, + ConstrictionIndex: 34, + ConstrictionDiameter: 1.2f, + Turbulence: 0.03f, + Velum: 0.01f, + LipOpening: 1.1f), ["tract_shape_motion", "diameter_authority"]), Fixture( "nasal-vowel", "Velum-open nasalized vowel through oral and nasal waveguides.", - "tongue_index=14 tongue_diameter=2.2 constriction_index=18 constriction_diameter=0.8 turbulence=0.08 velum=0.33 lip=1.35", + new PinkTromboneFixtureControls( + TongueIndex: 14, + TongueDiameter: 2.2f, + ConstrictionIndex: 18, + ConstrictionDiameter: 0.8f, + Turbulence: 0.08f, + Velum: 0.33f, + LipOpening: 1.35f), ["nose_waveguide_cells", "nose_junction"]), Fixture( "sibilant", "High-turbulence front constriction with positioned waveguide injection.", - "tongue_index=28 tongue_diameter=0.75 constriction_index=34 constriction_diameter=0.35 turbulence=0.95 velum=0.01 lip=1.2", + new PinkTromboneFixtureControls( + TongueIndex: 28, + TongueDiameter: 0.75f, + ConstrictionIndex: 34, + ConstrictionDiameter: 0.35f, + Turbulence: 0.95f, + Velum: 0.01f, + LipOpening: 1.2f), ["positioned_turbulence_applied_to_waveguide_cells"]), Fixture( "closure-release", "Closed constriction opening pressure for obstruction-history transient tests.", - "tongue_index=18 tongue_diameter=1.2 constriction_index=24 constriction_diameter=0.02 turbulence=0.25 velum=0.01 lip=1.45 burst=0.8", + new PinkTromboneFixtureControls( + TongueIndex: 18, + TongueDiameter: 1.2f, + ConstrictionIndex: 24, + ConstrictionDiameter: 0.02f, + Turbulence: 0.25f, + Velum: 0.01f, + LipOpening: 1.45f, + Burst: 0.8f), ["closure_transients"]) ]; @@ -43,11 +96,11 @@ public static class PinkTromboneParityFixtures private static PinkTromboneParityFixture Fixture( string id, string description, - string tractFields, + PinkTromboneFixtureControls controls, IReadOnlyList referenceFeatures) => - new(id, description, Script(tractFields), referenceFeatures); + new(id, description, controls, Script(controls), referenceFeatures); - private static string Script(string tractFields) => + private static string Script(PinkTromboneFixtureControls controls) => $$""" patch gain=0.82 soft_clip=true @@ -60,6 +113,9 @@ public static class PinkTromboneParityFixtures nasal_branch name=nose junction=17 velum=.01 reflection=-.85 loss=.999 diameters=0.01,0.35,0.5,0.65,0.8,0.95,1.1,1.25,1.4,1.55,1.7,1.8,1.9,1.9,1.85,1.75,1.65,1.55,1.45,1.35,1.25,1.15,1.05,0.95,0.85,0.75,0.65,0.55 tract_motion name=motion diameter_slew=18 shape_return=8 constriction_slew=24 velum_slew=16 obstruction_threshold=.05 - tract shape=human glottis=modal injection=inj nasal_branch=nose motion=motion propagation=waveguide substeps=2 waveguide_loss=.999 freq=140 gain=.7 sustain=.45 decay=.12 {{tractFields}} + tract shape=human glottis=modal injection=inj nasal_branch=nose motion=motion propagation=waveguide substeps=2 waveguide_loss=.999 freq={{F(controls.Frequency)}} gain={{F(controls.Gain)}} sustain=.45 decay=.12 tongue_index={{F(controls.TongueIndex)}} tongue_diameter={{F(controls.TongueDiameter)}} constriction_index={{F(controls.ConstrictionIndex)}} constriction_diameter={{F(controls.ConstrictionDiameter)}} turbulence={{F(controls.Turbulence)}} velum={{F(controls.Velum)}} lip={{F(controls.LipOpening)}} burst={{F(controls.Burst)}} glottal_reflection={{F(controls.GlottalReflection)}} lip_reflection={{F(controls.LipReflection)}} """; + + private static string F(float value) => + value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs new file mode 100644 index 0000000..9620421 --- /dev/null +++ b/src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs @@ -0,0 +1,275 @@ +namespace AquaSynth.Dsl; + +public sealed record PinkTromboneReferenceRender( + float[] Samples, + int SampleRate, + PinkTromboneFixtureControls Controls); + +public sealed class PinkTromboneReferenceRenderer(int sampleRate = 44100) +{ + private const int Sections = 44; + private const int NoseSections = 28; + private const float Loss = 0.999f; + private readonly int sampleRate = Math.Max(1, sampleRate); + + public PinkTromboneReferenceRender Render(PinkTromboneFixtureControls controls, float durationSeconds = 0.57f) + { + var frames = Math.Max(1, (int)MathF.Round(sampleRate * Math.Max(durationSeconds, 1f / sampleRate))); + var synth = new State(sampleRate); + var samples = new float[frames]; + synth.Render(samples, controls); + return new PinkTromboneReferenceRender(samples, sampleRate, controls); + } + + private sealed class State + { + private readonly float[] right = new float[Sections]; + private readonly float[] left = new float[Sections]; + private readonly float[] junctionRight = new float[Sections]; + private readonly float[] junctionLeft = new float[Sections + 1]; + private readonly float[] reflection = new float[Sections]; + private readonly float[] diameter = new float[Sections]; + private readonly float[] targetDiameter = new float[Sections]; + private readonly float[] noseRight = new float[NoseSections]; + private readonly float[] noseLeft = new float[NoseSections]; + private readonly float[] noseJunctionRight = new float[NoseSections]; + private readonly float[] noseJunctionLeft = new float[NoseSections + 1]; + private readonly float[] noseReflection = new float[NoseSections]; + private readonly float[] noseDiameter = new float[NoseSections]; + private readonly int sampleRate; + private float glottalPhase; + private float noise = 0.1234567f; + private float lastConstrictionDiameter = 1; + private float transient; + private float dcBlockX; + private float dcBlockY; + + public State(int sampleRate) + { + this.sampleRate = sampleRate; + InitRest(); + UpdateNose(0.01f); + } + + public void Render(float[] output, PinkTromboneFixtureControls controls) + { + UpdateTargets(controls); + UpdateNose(controls.Velum); + UpdateReflection(); + UpdateTransient(controls); + for (var i = 0; i < output.Length; i++) + { + SlewDiameters(); + transient *= 0.995f; + var glottal = Glottis(controls) + Rand() * transient; + var first = Step(glottal, controls); + var second = Step(glottal, controls); + output[i] = Condition((first + second) * 0.72f) * controls.Gain; + } + } + + private float Rand() + { + noise = (noise * 16807) % 2147483647; + return noise / 1073741823.5f - 1; + } + + private void InitRest() + { + for (var i = 0; i < Sections; i++) + { + var value = RestDiameter(i); + diameter[i] = value; + targetDiameter[i] = value; + } + } + + private static float RestDiameter(int index) => + index < 7 ? 0.6f : index < 10 ? 1.1f : 1.5f; + + private void UpdateTargets(PinkTromboneFixtureControls controls) + { + for (var i = 0; i < Sections; i++) + { + var value = RestDiameter(i); + if (i > 10 && i < 39) + { + var angle = 1.1f * MathF.PI * (controls.TongueIndex - i) / 22; + var fixedTongueDiameter = 2 + (controls.TongueDiameter - 2) / 1.5f; + var curve = (1.5f - fixedTongueDiameter + 1.7f) * MathF.Cos(angle); + if (i is 8 or 38) curve *= 0.8f; + if (i is 10 or 37) curve *= 0.94f; + value = 1.5f - curve; + } + + targetDiameter[i] = Math.Max(0, value); + } + + ApplyConstriction(controls.ConstrictionIndex, controls.ConstrictionDiameter); + targetDiameter[Sections - 1] = Math.Max(0.05f, controls.LipOpening); + } + + private void ApplyConstriction(float position, float constrictionDiameter) + { + var newDiameter = Math.Max(0, constrictionDiameter - 0.3f); + var range = position < 25 ? 10 : 5; + var lower = Math.Max(0, (int)MathF.Floor(position - range - 1)); + var upper = Math.Min(Sections - 1, (int)MathF.Ceiling(position + range + 1)); + for (var i = lower; i <= upper; i++) + { + var offset = MathF.Abs(i - position) - 0.5f; + float scale; + if (offset <= 0) scale = 0; + else if (offset > range) scale = 1; + else scale = 0.5f * (1 - MathF.Cos(MathF.PI * offset / range)); + + var difference = targetDiameter[i] - newDiameter; + if (difference > 0) targetDiameter[i] = newDiameter + difference * scale; + } + } + + private void SlewDiameters() + { + for (var i = 0; i < Sections; i++) + { + var speed = i < 17 ? 0.00035f : i < 32 ? 0.00045f : 0.0007f; + diameter[i] += Math.Clamp(targetDiameter[i] - diameter[i], -speed, speed * 2); + } + } + + private void UpdateNose(float velum) + { + for (var i = 0; i < NoseSections; i++) + { + var d = 2f * i / NoseSections; + var value = i == 0 ? velum : d < 1 ? 0.4f + 1.6f * d : 0.5f + 1.5f * (2 - d); + noseDiameter[i] = Math.Min(value, 1.9f); + } + + for (var i = 1; i < NoseSections; i++) + { + noseReflection[i] = Reflection(noseDiameter[i - 1], noseDiameter[i]); + } + } + + private void UpdateReflection() + { + for (var i = 1; i < Sections; i++) + { + reflection[i] = Reflection(diameter[i - 1], diameter[i]); + } + } + + private static float Reflection(float previousDiameter, float nextDiameter) + { + var previousArea = Math.Max(1e-6f, previousDiameter * previousDiameter); + var nextArea = Math.Max(1e-6f, nextDiameter * nextDiameter); + return (previousArea - nextArea) / (previousArea + nextArea); + } + + private float Glottis(PinkTromboneFixtureControls controls) + { + glottalPhase += controls.Frequency / sampleRate; + glottalPhase -= MathF.Floor(glottalPhase); + var phase = glottalPhase; + var tenseness = Math.Clamp(controls.Tenseness, 0, 1); + var openPhase = 0.55f + 0.32f * (1 - tenseness); + var pulse = phase < openPhase + ? MathF.Sin(MathF.PI * phase / openPhase) + : -0.28f * MathF.Sin(MathF.PI * (phase - openPhase) / (1 - openPhase)); + var harmonicBite = (0.12f + tenseness * 0.62f) * MathF.Sin(4 * MathF.PI * phase); + var aspiration = Rand() * controls.Intensity * (1 - MathF.Sqrt(tenseness)) * 0.18f; + return (pulse - harmonicBite + aspiration) * controls.Intensity * (0.45f + 0.75f * MathF.Pow(tenseness, 0.35f)); + } + + private void InjectTurbulence(PinkTromboneFixtureControls controls) + { + var thinness = Math.Clamp(8 * (0.7f - controls.ConstrictionDiameter), 0, 1); + var openness = Math.Clamp(30 * (controls.ConstrictionDiameter - 0.3f), 0, 1); + var frontLift = 0.35f + 0.65f * Math.Clamp(controls.ConstrictionIndex / Sections, 0, 1); + var amount = Rand() * controls.Turbulence * thinness * openness * controls.Intensity * frontLift * 1.8f; + var index = (int)MathF.Floor(controls.ConstrictionIndex); + var delta = controls.ConstrictionIndex - index; + if (index + 1 < Sections) + { + right[index + 1] += amount * (1 - delta) * 0.5f; + left[index + 1] += amount * (1 - delta) * 0.5f; + } + + if (index + 2 < Sections) + { + right[index + 2] += amount * delta * 0.5f; + left[index + 2] += amount * delta * 0.5f; + } + } + + private float Step(float input, PinkTromboneFixtureControls controls) + { + InjectTurbulence(controls); + junctionRight[0] = left[0] * controls.GlottalReflection + input; + junctionLeft[Sections] = right[Sections - 1] * controls.LipReflection; + + for (var i = 1; i < Sections; i++) + { + var wave = reflection[i] * (right[i - 1] + left[i]); + junctionRight[i] = right[i - 1] - wave; + junctionLeft[i] = left[i] + wave; + } + + var noseStart = Sections - NoseSections + 1; + var velumArea = Math.Max(1e-6f, controls.Velum * controls.Velum); + var leftArea = Math.Max(1e-6f, diameter[noseStart] * diameter[noseStart]); + var rightArea = Math.Max(1e-6f, diameter[noseStart + 1] * diameter[noseStart + 1]); + var sum = leftArea + rightArea + velumArea; + var reflectLeft = (2 * leftArea - sum) / sum; + var reflectRight = (2 * rightArea - sum) / sum; + var reflectNose = (2 * velumArea - sum) / sum; + junctionLeft[noseStart] = reflectLeft * right[noseStart - 1] + (1 + reflectLeft) * (noseLeft[0] + left[noseStart]); + junctionRight[noseStart] = reflectRight * left[noseStart] + (1 + reflectRight) * (right[noseStart - 1] + noseLeft[0]); + noseJunctionRight[0] = reflectNose * noseLeft[0] + (1 + reflectNose) * (left[noseStart] + right[noseStart - 1]); + + for (var i = 0; i < Sections; i++) + { + right[i] = junctionRight[i] * Loss; + left[i] = junctionLeft[i + 1] * Loss; + } + + var lipOutput = right[Sections - 1]; + noseJunctionLeft[NoseSections] = noseRight[NoseSections - 1] * controls.LipReflection; + for (var i = 1; i < NoseSections; i++) + { + var wave = noseReflection[i] * (noseRight[i - 1] + noseLeft[i]); + noseJunctionRight[i] = noseRight[i - 1] - wave; + noseJunctionLeft[i] = noseLeft[i] + wave; + } + + for (var i = 0; i < NoseSections; i++) + { + noseRight[i] = noseJunctionRight[i] * Loss; + noseLeft[i] = noseJunctionLeft[i + 1] * Loss; + } + + return lipOutput * (0.85f + controls.LipOpening * 0.28f) + + noseRight[NoseSections - 1] * Math.Clamp(controls.Velum / 0.4f, 0, 1); + } + + private void UpdateTransient(PinkTromboneFixtureControls controls) + { + var opening = controls.ConstrictionDiameter - lastConstrictionDiameter; + if (opening > 0.18f && lastConstrictionDiameter < 0.28f) + { + transient += opening * controls.Intensity * (0.18f + controls.Turbulence * 0.42f) * controls.Burst; + } + + lastConstrictionDiameter = controls.ConstrictionDiameter; + } + + private float Condition(float output) + { + var blocked = output - dcBlockX + 0.995f * dcBlockY; + dcBlockX = output; + dcBlockY = blocked; + return MathF.Tanh(blocked * 1.9f); + } + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index a921e7a..75897de 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -138,3 +138,4 @@ {"date":"2026-05-26","topic":"dynamic-tract-reflection-field","lesson":"A PT-class waveguide cannot keep reflection coefficients as compile-time constants. AquaSynth waveguide lowering now derives live per-section diameter targets from `tract_shape`, tongue, constriction, and lip controls, slews them through `tract_motion`, squares them into section areas, and recomputes reflection coefficients and nasal junction areas as DSP expressions.","evidence":"Replaced static `wg_k_N` constants in `FaustExport.TractWaveguideExpression` with generated `wg_diameter_target_N`, `wg_diameter_N`, `wg_area_N`, and dynamic `wg_k_N` expressions. Focused no-hang build/test command passed 3 tests."} {"date":"2026-05-26","topic":"waveguide-substep-clock","lesson":"PT's twice-rate tract clock must be represented explicitly. AquaSynth already had `substeps`; lowering now consumes it through `wg_substep_drive` and `wg_substep_loss` so the field is not decorative. Exact intra-sample recursive state updates remain named debt until the Faust lowering or another backend can prove them.","evidence":"Added substep drive/loss scaling in `TractWaveguideExpression`, updated parity docs/reference metadata, and kept `exact_twice_rate_state_update` as missing. Focused no-hang build/test command passed 3 tests."} {"date":"2026-05-26","topic":"pink-trombone-parity-fixtures","lesson":"Expressive parity needs fixed workouts before audio golf. AquaSynth now has `PinkTromboneParityFixtures` for open vowel, front vowel, nasal vowel, sibilant, and closure-release scripts built from reusable low-level tract primitives, plus log-mel cosine similarity in `AudioComparison`. The remaining proof is to generate/capture PT reference audio and set honest thresholds.","evidence":"Added `PinkTromboneParityFixtures`, `AudioComparison.LogMelCosineSimilarity`, `AudioAnalyzer.CosineSimilarity`, and tests that every PT fixture parses, emits Faust, uses tract_shape/glottis/tract_injection/nasal_branch/tract_motion, and exports waveguide lowering. Focused build/test passed 4 tests."} +{"timestamp":"2026-05-25T21:34:00+01:00","topic":"pink-trombone-logmel-parity","claim":"PinkTromboneReferenceRenderer now gives deterministic PT-style fixture audio and PinkTromboneLogMelParityTests writes log-mel/listening artifacts. The compileable Aqua resonator proxy baseline is measurable but weak: cosine 0.5129 open vowel, 0.5405 front vowel, 0.3882 nasal, 0.1156 sibilant, -0.1830 closure-release. Faust waveguide named-state lowering caused compiler churn and remains backend debt rather than a parity claim.","evidence":"dotnet test tests/AquaSynth.Dsl.Tests/AquaSynth.Dsl.Tests.csproj --no-build --filter PinkTromboneFixturesReportLogMelParityWhenFaustIsInstalled -v minimal passed; artifacts/parity/pink-trombone-logmel/20260525T212806301/summary.txt"} diff --git a/state/map.yaml b/state/map.yaml index d77946f..903592e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -38,6 +38,7 @@ modules: parser: src/AquaSynth.Core/PatchScript.cs vocal_tract_model: src/AquaSynth.Core/VocalTractModel.cs pink_trombone_reference: src/AquaSynth.Core/PinkTromboneReference.cs + pink_trombone_reference_renderer: src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs packed_neural_network: src/AquaSynth.Core/PackedNeuralNetwork.cs utterance_embedding_learning: src/AquaSynth.Core/UtteranceEmbeddingLearning.cs speech_backpropagation_pipeline: src/AquaSynth.Core/SpeechBackpropagationPipeline.cs @@ -47,6 +48,7 @@ modules: tract_shape_model: src/AquaSynth.Core/Model.cs tract_waveguide_lowering: src/AquaSynth.Core/FaustExport.cs pink_trombone_parity_fixtures: src/AquaSynth.Core/PinkTromboneParityFixtures.cs + pink_trombone_logmel_parity_tests: tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs faust_emitter: src/AquaSynth.Core/FaustExport.cs faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs diff --git a/state/spine.yaml b/state/spine.yaml index 82f6e9d..d886c82 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -112,7 +112,7 @@ live_invariants: - The speech lane is the active center: IPA parity, utterance embeddings, synth-driver automation, rendered loss, and compiled Faust control sweeps take priority over new DX7/Zyn digestion. - Every learned speech arrow must stay inspectable: utterance metadata -> embedding, embedding plus phonetic features -> automation, automation -> rendered audio, rendered audio -> loss/gradient. - Pink Trombone parity is structural waveguide pressure, not permission to add more formant proxy knobs. AquaSynth now has a `tract` DSL command that lowers into `Voice.Tract` with PT-shaped runtime controls and a Faust tract-voice proxy; it is still an ordinary voice treatment, not a separate graph owner and not an exact 44-cell/28-cell bidirectional waveguide. - - PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns section diameters/areas and derived reflection coefficients; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into generated oral/nasal right-left waveguide equations with live per-section diameter/area/reflection fields, obstruction-history burst pressure, and substep clock intent. `PinkTromboneParityFixtures` now defines fixed Aqua DSL workouts and `AudioComparison` includes log-mel cosine similarity. Remaining low-level PT gaps are exact intra-sample recursive substep state updates and rendered reference thresholds. + - PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns section diameters/areas and derived reflection coefficients; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into generated oral/nasal waveguide approximation fields with live per-section diameter/area/reflection fields, obstruction-history burst pressure, and substep clock intent. `PinkTromboneParityFixtures` now defines fixed Aqua DSL workouts, `PinkTromboneReferenceRenderer` renders deterministic PT-style fixture references, and `AudioComparison` includes log-mel cosine similarity. Current compileable Aqua proxy baseline is weak but measured: cosine about 0.51 open vowel, 0.54 front vowel, 0.39 nasal, 0.12 sibilant, and -0.18 closure-release. Remaining low-level PT gaps are exact coupled right/left recursive state updates, a backend that can render them without Faust compiler churn, and real threshold golf. - If dotnet builds hang in this workspace, stop orphan dotnet workers and retry with `--disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`; plain parallel/shared builds can stall without useful diagnostics. - eSpeak NG is an early parity reference for timing, IPA coverage, and log-mel pressure, not an anatomy oracle or final target. - Do not expand into prosody, emotional context, personality, or broad Weksa expressivity until the tiny IPA utterance harness can show useful loss movement. diff --git a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs new file mode 100644 index 0000000..b41eb08 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs @@ -0,0 +1,132 @@ +using System.Globalization; +using System.Text; + +namespace AquaSynth.Dsl.Tests; + +public sealed class PinkTromboneLogMelParityTests +{ + private const float SmokeCosineFloor = -0.25f; + + [Fact] + public void PinkTromboneReferenceRendererProducesAudibleFixtureAudio() + { + var renderer = new PinkTromboneReferenceRenderer(); + + foreach (var fixture in PinkTromboneParityFixtures.All) + { + var render = renderer.Render(fixture.Controls); + var analysis = AudioAnalyzer.AnalyzeAudio(render.Samples, new AudioAnalysisConfig(SampleRate: render.SampleRate)); + + Assert.True(render.Samples.Length > 10000, fixture.Id); + Assert.True(analysis.Features.Peak > 0.001f, fixture.Id); + Assert.True(analysis.Features.Rms > 0.0001f, fixture.Id); + Assert.Equal(32, analysis.LogMelSpectrogram.Bands); + } + } + + [Fact] + public async Task PinkTromboneFixturesReportLogMelParityWhenFaustIsInstalled() + { + if (FaustCompiler.FindFaust() is null) + { + return; + } + + var referenceRenderer = new PinkTromboneReferenceRenderer(); + var artifactDir = ArtifactPath("parity", "pink-trombone-logmel", DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture)); + var reports = new List(); + + foreach (var fixture in PinkTromboneParityFixtures.All) + { + var reference = referenceRenderer.Render(fixture.Controls); + var candidateScript = fixture.AquaScript.Replace("propagation=waveguide", "propagation=resonator", StringComparison.Ordinal); + var candidateSource = FaustEmitter.EmitScript(candidateScript, new FaustExportOptions($"pt_{fixture.Id.Replace('-', '_')}_proxy")); + var fixtureDir = Path.Combine(artifactDir, fixture.Id); + Directory.CreateDirectory(fixtureDir); + File.WriteAllText(Path.Combine(fixtureDir, "candidate.dsp"), candidateSource.Source); + var candidate = await FaustCompiler.RenderAsync( + candidateSource.Source, + new FaustRenderOptions(reference.SampleRate, reference.Samples.Length / (float)reference.SampleRate)); + + Assert.NotNull(candidate); + Assert.True(candidate.Samples.Length > 0, $"{candidate.Stderr}{Environment.NewLine}artifacts: {fixtureDir}"); + Assert.True(candidate.Samples.Max(MathF.Abs) > 0.00001f, candidate.Stderr); + + var comparison = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: reference.SampleRate)) + .Compare(reference.Samples, candidate.Samples); + reports.Add(Report(fixture, comparison)); + WriteFixtureArtifacts(artifactDir, fixture, reference.Samples, candidate.Samples, reference.SampleRate, comparison, candidateSource.Source); + + Assert.True( + comparison.LogMelCosineSimilarity >= SmokeCosineFloor, + $"{Report(fixture, comparison)}{Environment.NewLine}artifacts: {artifactDir}"); + } + + Directory.CreateDirectory(artifactDir); + File.WriteAllText(Path.Combine(artifactDir, "summary.txt"), string.Join(Environment.NewLine + Environment.NewLine, reports)); + } + + private static string Report(PinkTromboneParityFixture fixture, AudioComparison comparison) => + string.Create( + CultureInfo.InvariantCulture, + $"{fixture.Id}: cosine={comparison.LogMelCosineSimilarity:0.0000} logMelDistance={comparison.LogMelDistance:0.0000} score={comparison.Score:0.0000} rmsRatio={comparison.RmsRatio:0.0000} centroidRatio={comparison.CentroidRatio:0.0000}"); + + private static void WriteFixtureArtifacts( + string artifactDir, + PinkTromboneParityFixture fixture, + IReadOnlyList reference, + IReadOnlyList candidate, + int sampleRate, + AudioComparison comparison, + string candidateSource) + { + var fixtureDir = Path.Combine(artifactDir, fixture.Id); + Directory.CreateDirectory(fixtureDir); + WriteWav(Path.Combine(fixtureDir, "reference-pink-trombone.wav"), reference, sampleRate); + WriteWav(Path.Combine(fixtureDir, "candidate-aquasynth.wav"), candidate, sampleRate); + File.WriteAllText(Path.Combine(fixtureDir, "candidate.dsp"), candidateSource); + File.WriteAllText(Path.Combine(fixtureDir, "report.txt"), Report(fixture, comparison)); + } + + private static string ArtifactPath(params string[] parts) + { + var root = RepositoryRoot(); + return Path.Combine([root, "artifacts", .. parts]); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AquaSynth.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? AppContext.BaseDirectory; + } + + private static void WriteWav(string path, IReadOnlyList samples, int sampleRate) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream, Encoding.ASCII); + var dataSize = samples.Count * sizeof(short); + writer.Write("RIFF"u8); + writer.Write(36 + dataSize); + 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(dataSize); + foreach (var sample in samples) + { + writer.Write((short)Math.Clamp(MathF.Round(sample * short.MaxValue), short.MinValue, short.MaxValue)); + } + } +}