Nasal junction scatters on its own interface (cells 10 and 11, not 11 and 12): back-vowel F1 12-26% high becomes 1.5-9% high; five skipped tests run

The velopharyngeal junction summed the areas of cells cell and cell+1 and used their coefficients for R(cell) and L(cell-1), which sit on the interface between cell-1 and cell, so the pharynx carried the next step's reflection with the wrong sign (/o/ at cells 10-12: .52 .33 .52). Isolated by the numpy copy of the ladder against the lip wave of the rendered ladder under an impulse. F1 render against tube chain, uh 580/549, ah 690/650, aw 550/516, o 520/477, oo_short 500/478; they pass the 10 percent band and the Skip is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-09 13:40:42 +00:00
commit bec55d0937
2 changed files with 10 additions and 28 deletions

View file

@ -72,10 +72,11 @@ public static partial class FaustEmitter
// The velopharyngeal port area, cm^2: opening * coupling * the design maximum (NasalTractAreas); it is the nasal ladder's first cell.
var velum = $"{name}_primitive_branch_{SafeIdentifier(branch.Name)}_velum_area";
source.AppendLine($" {velum} = max(0.000001, {admittances[branch.Name]} * {F(NasalTractAreas.VelopharyngealPortMaximumAreaSquareCentimeters)});");
var sum = $"({oral.A(cell)} + {oral.A(cell + 1)} + {velum})";
// The junction sits on the interface between oral cells cell - 1 and cell: R(cell) is entered from R(cell - 1), L(cell - 1) from L(cell).
var sum = $"({oral.A(cell - 1)} + {oral.A(cell)} + {velum})";
var key = $"{name}_primitive_branch_{SafeIdentifier(branch.Name)}";
source.AppendLine($" {key}_refl_left = (2.0 * {oral.A(cell)} - {sum}) / {sum};");
source.AppendLine($" {key}_refl_right = (2.0 * {oral.A(cell + 1)} - {sum}) / {sum};");
source.AppendLine($" {key}_refl_left = (2.0 * {oral.A(cell - 1)} - {sum}) / {sum};");
source.AppendLine($" {key}_refl_right = (2.0 * {oral.A(cell)} - {sum}) / {sum};");
source.AppendLine($" {key}_refl_nose = (2.0 * {velum} - {sum}) / {sum};");
junctions.Add(new LadderJunction(oral, nasal, cell, velum, $"{key}_refl_left", $"{key}_refl_right", $"{key}_refl_nose"));
}

View file

@ -380,37 +380,18 @@ public sealed class VocalTractFormantTests(ITestOutputHelper output)
// transfer-matrix estimate in the frequency domain): python3 tools/formant-check/tube_formants.py docs/research/data/story2008-area-functions.csv --length-cm 17.52
//
// Bands: the front vowels (i ih e eh ae) are held to 6% on F1 and F2. The back and rounded vowels (uh ah aw o oo_short u) are held to 10%:
// that is the measured disagreement between two lip radiation models (the ladder's one-pole Levine-Schwinger-form reflectance with g = 1,
// the tube chain's baffled piston R||L) plus the ladder's cell quantisation. The ladder reads systematically high against the tube chain
// (F1 +4 to +9%, F2 +3 to +8%); that bias is diagnosed (the glottal end moves only /aw/ F1, the end correction alone does not close it),
// not isolated. Do not tune it away in a test: it is the open item recorded in docs/research/articulatory-vocal-tract-sources.md.
// the measured disagreement between two lip radiation models (the ladder's one-pole Levine-Schwinger-form reflectance with g = 1, the tube
// chain's baffled piston R||L) plus the ladder's cell quantisation. The nasal junction used to take the reflection coefficients of the
// interface one cell further on (sum over cells 11 and 12 instead of 10 and 11), which put the wrong step in the pharynx and read the
// back vowels' F1 12 to 26% high (uh +14.8%, ah +10.8%, aw +12.4%, o +25.8%, oo_short +13.0% against this table, five tests skipped on those
// numbers); the ladder now agrees with the numpy copy (tools/formant-check/ladder_formants.py --filtered --lip-reflection -1) to 10 Hz.
public static TheoryData<string, double, double, double> StoryTubeChain => new()
{
{ "i", 251, 2063, .06 }, { "ih", 346, 1987, .06 }, { "e", 432, 1827, .06 }, { "eh", 527, 1571, .06 }, { "ae", 729, 1523, .06 },
{ "uh", 549, 809, .10 }, { "ah", 650, 913, .10 }, { "aw", 516, 692, .10 }, { "o", 477, 787, .10 }, { "oo_short", 478, 850, .10 },
{ "u", 271, 761, .10 },
};
// The rendered back vowels outside the 10% band (harmonic-line and LPC readings agree, so it is not the picker): F1 reads uh 630 (549 tube, +14.8%),
// ah 720 (650, +10.8%), aw 580 (516, +12.4%), o 600 (477, +25.8%), oo_short 540 (478, +13.0%); the F2 of each is within 7%. The numpy copy of the ladder
// (tools/formant-check/ladder_formants.py --filtered) reads those F1 20-85 Hz lower than the render, so the excess is in the rendered chain, not in the
// lip reflectance (untested suspect: the glottal tissue-valve source's spectral shape and load feedback; load_coupling=0 changed nothing).
// Skipped with the numbers, not widened.
public static TheoryData<string, double, double, double> StoryTubeChainOutsideBand => new()
{
{ "uh", 549, 809, .10 }, { "ah", 650, 913, .10 }, { "aw", 516, 692, .10 }, { "o", 477, 787, .10 }, { "oo_short", 478, 850, .10 },
};
[Theory(Skip = "rendered F1 outside the 10% band: uh +14.8%, ah +10.8%, aw +12.4%, o +25.8%, oo_short +13.0% (see comment above, cut B1 report)")]
[MemberData(nameof(StoryTubeChainOutsideBand))]
public void LadderRenderReproducesTheTubeChainFormantsOfTheBackVowelsOutsideTheBand(string id, double f1, double f2, double band)
{
var peaks = StoryPeaks(id);
if (peaks is null) return;
Assert.InRange(peaks[0], f1 * (1 - band), f1 * (1 + band));
Assert.InRange(peaks[1], f2 * (1 - band), f2 * (1 + band));
}
[Theory]
[MemberData(nameof(StoryTubeChain))]
public void LadderReproducesTheTubeChainFormantsOfEachStoryVowel(string id, double f1, double f2, double band)