Tract wall radiation: the first-cell pressure, band-limited to 90-300 Hz, radiates through the wall; a voiced closure carries a voice bar

A word-initial /b/ closure was 0.0000 (below 16 bits): the shut mouth passes nothing and no other path existed. Wall radiation reads R0+L0 of each glottal path (the ladder returns it beside the lip waves) and adds a gain-0.7 term: /b/ closure -30.0 dB re the following vowel, /p/ -40.9 dB, a vowel's f0 harmonics move under 1 dB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-09 13:27:49 +00:00
commit fd11f92cae
3 changed files with 65 additions and 12 deletions

View file

@ -37,9 +37,10 @@ public static partial class FaustEmitter
/// <summary>
/// Emits the area, reflection and junction definitions and the recursive ladder for <paramref name="members"/>, and returns,
/// per path, the identifier of its lip output (the right-going wave leaving its last cell).
/// per path, the identifier of its lip output (the right-going wave leaving its last cell) and, for each path that ends at a glottis,
/// the identifier of the pressure in its first cell (the tract wall radiation reads it).
/// </summary>
private static IReadOnlyDictionary<string, string> EmitVocalLadder(
private static (IReadOnlyDictionary<string, string> Lips, IReadOnlyDictionary<string, string> GlottalPressures) EmitVocalLadder(
StringBuilder source,
SynthPatch patch,
VocalNetwork network,
@ -137,22 +138,29 @@ public static partial class FaustEmitter
equations.AppendLine($" '{member.L(last)} = ({b} * {member.L(last)} + (1.0 - {b}) * ({lipReflection}) * {member.R(last)}) * {loss};");
}
var outputs = string.Join(", ", members.Select(member => member.R(member.Cells - 1)));
// Outputs: every path's lip wave, then the first-cell pressure R0 + L0 of each path that ends at a glottis (a branch path's first
// cell is the junction, not a glottis).
var glottal = members.Where(member => !targets.ContainsKey(member)).ToArray();
var outputs = string.Join(", ", members.Select(member => member.R(member.Cells - 1)).Concat(glottal.Select(member => $"({member.R(0)} + {member.L(0)})")));
var outputCount = members.Count + glottal.Length;
var ladder = $"{name}_primitive_ladder";
source.AppendLine($" {ladder} = ({outputs}) letrec {{");
source.Append(equations);
source.AppendLine(" };");
var lips = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var m = 0; m < members.Count; m++)
var glottalPressures = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var m = 0; m < outputCount; m++)
{
var lip = $"{members[m].Prefix}_lip";
source.AppendLine(members.Count == 1
? $" {lip} = {ladder};"
: $" {lip} = {ladder} : ba.selector({m}, {members.Count});");
lips[members[m].Path.Name] = lip;
var isLip = m < members.Count;
var owner = isLip ? members[m] : glottal[m - members.Count];
var tap = $"{owner.Prefix}_{(isLip ? "lip" : "glottal_pressure")}";
source.AppendLine(outputCount == 1
? $" {tap} = {ladder};"
: $" {tap} = {ladder} : ba.selector({m}, {outputCount});");
(isLip ? lips : glottalPressures)[owner.Path.Name] = tap;
}
return lips;
return (lips, glottalPressures);
}
}

View file

@ -647,8 +647,8 @@ public static partial class FaustEmitter
}
}
var lips = members.Count == 0
? new Dictionary<string, string>()
var (lips, glottalPressures) = members.Count == 0
? (new Dictionary<string, string>(), new Dictionary<string, string>())
: EmitVocalLadder(source, patch, network, name, members, inputs, admittances, parameters, warnings);
foreach (var member in members)
@ -684,6 +684,17 @@ public static partial class FaustEmitter
radiated.Add(PrimitiveRadiationExpression(source, patch, name, load, wave, parameters, options, keepAlive));
}
}
if (glottalPressures.TryGetValue(path.Name, out var glottalPressure))
{
// Tract wall radiation: the pressure inside the tract, low-passed, leaves through the cheeks and neck whether or not the mouth
// is open. It is the only sound a closed mouth makes (the voice bar of a voiced stop) and sits far under the mouth's output
// in a vowel.
var wall = $"{member.Prefix}_wall_radiation";
source.AppendLine($" {wall} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/wall_radiation", $"(({glottalPressure}) : fi.highpass(2, {WallRadiationHighpassHz}) : fi.lowpass(2, {WallRadiationLowpassHz})) * {WallRadiationGain}", -4, 4)};");
keepAlive.Add(wall);
radiated.Add(wall);
}
}
var output = radiated.Count == 0 ? "0.0" : string.Join(" + ", radiated);
@ -800,6 +811,14 @@ public static partial class FaustEmitter
// /u/'s F1 and put a 450 Hz hump in its envelope. The aperture stays the gain consonant closures use.
private const string RadiationHighpassHz = "200.0";
// Tract wall radiation (the voice bar). Corners: skin and soft tissue pass only the low end of the voice (below about 200-300 Hz), and
// a second-order highpass at 90 Hz keeps the slow pressure drift of a shutting tract out (a 40 Hz first-order corner left it in). The gain
// is a DESIGN VALUE set so a word-initial voiced closure reads 30 dB under the following vowel (/p/ 41 dB), while the wall term moves a
// vowel's f0 harmonics by under 1 dB (no primary level retrieved; docs/research/articulatory-vocal-tract-sources.md).
private const string WallRadiationHighpassHz = "90.0";
private const string WallRadiationLowpassHz = "300.0";
private const string WallRadiationGain = "0.7";
private static string PrimitiveRadiationExpression(
StringBuilder source,
SynthPatch patch,

View file

@ -175,4 +175,30 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output)
output.WriteLine($"mean {diffs.Average():F2} dB");
Assert.InRange(diffs.Average(), 1.8, 2.3);
}
// Standard deviation, so the slow offset a shutting tract leaves behind does not count as sound.
private static double Deviation(float[] x, double from, double to)
{
var seg = x.Skip((int)(from * Rate)).Take((int)((to - from) * Rate)).Select(v => (double)v).ToArray();
var mean = seg.Average();
return Math.Sqrt(seg.Sum(v => (v - mean) * (v - mean)) / seg.Length);
}
// A word-initial plosive's closure (0.03 to 0.058 s of a 0.095 s consonant shut to 0.062 s) against the vowel after it (0.15 to 0.25 s). The
// mouth is shut, so all that gets out is the tract wall radiation of the voiced source: the voice bar, about 30 dB under the vowel for /b/
// (a DESIGN value, no primary level retrieved; Stevens 1998 per secondary sources has voicing weaker as transglottal pressure falls), and
// near-silent for /p/, whose source is weak. The closure was 0.0000 (below 16 bits) before the wall radiation.
[Theory]
[InlineData("b", -34, -26)]
[InlineData("p", -60, -38)]
public void PlosiveClosureCarriesTheVoiceBarOnlyWhenVoiced(string ipa, double lowDb, double highDb)
{
var script = IpaGestureExperiment.BuildWordScript(
[(new IpaGestureExperimentTarget("w_0_c", ipa, "", 0.095f), 1f), (new IpaGestureExperimentTarget("w_1_a", "a", "", 0.18f), 1f)], 140);
var x = Render(script, 0.4f);
if (x is null) return;
var db = 20 * Math.Log10(Math.Max(1e-9, Deviation(x, 0.03, 0.058)) / Deviation(x, 0.15, 0.25));
output.WriteLine($"/{ipa}/ closure {db:F1} dB re the following vowel");
Assert.InRange(db, lowDb, highDb);
}
}