diff --git a/src/AquaSynth.Core/FaustExport.VocalLadder.cs b/src/AquaSynth.Core/FaustExport.VocalLadder.cs
index bac7867..fdc2b6a 100644
--- a/src/AquaSynth.Core/FaustExport.VocalLadder.cs
+++ b/src/AquaSynth.Core/FaustExport.VocalLadder.cs
@@ -37,9 +37,10 @@ public static partial class FaustEmitter
///
/// Emits the area, reflection and junction definitions and the recursive ladder for , 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).
///
- private static IReadOnlyDictionary EmitVocalLadder(
+ private static (IReadOnlyDictionary Lips, IReadOnlyDictionary 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(StringComparer.OrdinalIgnoreCase);
- for (var m = 0; m < members.Count; m++)
+ var glottalPressures = new Dictionary(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);
}
}
diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs
index bd98e0b..c897d51 100644
--- a/src/AquaSynth.Core/FaustExport.cs
+++ b/src/AquaSynth.Core/FaustExport.cs
@@ -647,8 +647,8 @@ public static partial class FaustEmitter
}
}
- var lips = members.Count == 0
- ? new Dictionary()
+ var (lips, glottalPressures) = members.Count == 0
+ ? (new Dictionary(), new Dictionary())
: 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,
diff --git a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs
index 6c66326..636af09 100644
--- a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs
@@ -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);
+ }
}