Add Pink Trombone log-mel parity harness

This commit is contained in:
Meta Vens 2026-05-25 22:30:55 +01:00
commit e3cc1e04f5
9 changed files with 611 additions and 60 deletions

View file

@ -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

View file

@ -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:

View file

@ -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<string> warnings)
{
if (graph.Operators.Count == 0)

View file

@ -3,9 +3,26 @@ namespace AquaSynth.Dsl;
public sealed record PinkTromboneParityFixture(
string Id,
string Description,
PinkTromboneFixtureControls Controls,
string AquaScript,
IReadOnlyList<string> 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<PinkTromboneParityFixture> 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<string> 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);
}

View file

@ -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);
}
}
}

View file

@ -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"}

View file

@ -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

View file

@ -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.

View file

@ -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<string>();
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<float> reference,
IReadOnlyList<float> 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<float> 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));
}
}
}