Add Pink Trombone log-mel parity harness
This commit is contained in:
parent
7a42c70809
commit
e3cc1e04f5
9 changed files with 611 additions and 60 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
275
src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs
Normal file
275
src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
132
tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
Normal file
132
tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue