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