From 4eab1979bd32daaefed15401b26a29607baa5f2a Mon Sep 17 00:00:00 2001 From: Aqua Date: Mon, 9 Nov 2026 12:50:59 +0000 Subject: [PATCH] Nasal stops hold their oral closure to 0.8 of the gesture: /n/ murmur -3.8 to -8.8 dB re the flanking vowels (/m/ -12.8) The alveolar closure began to open at half the gesture while the velum and the contact held to 0.8, so the oral tract radiated over the last third of the murmur. verify_renders.py now measures the murmur on steady parts against the mean flanking-vowel power; the render test holds both nasals 8 to 16 dB under the vowels. Co-Authored-By: Claude Sonnet 5.5 --- src/AquaSynth.Core/PatchScript.cs | 6 +++- .../StressAndReleaseRenderTests.cs | 34 +++++++++++++++++++ tools/formant-check/verify_renders.py | 11 +++--- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 699170d..ff2f3db 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -1544,7 +1544,11 @@ public static class PatchScript AddAuthoredSpline($"{name}_constriction_index", "/vocal/areas/0/area/constriction_index", start, duration, (0, place), (1, place)); } - AddAuthoredSpline($"{name}_constriction_diameter", "/vocal/areas/0/area/constriction_diameter", start, duration, (0, constriction), (0.5f, constriction), (1, Math.Min(0.65f, constriction + 0.2f))); + // A nasal stop holds its oral closure for as long as the velum and the contact stay put (to 0.8 of the gesture): the murmur is the + // closed-mouth sound, and an alveolar closure that began to open at 0.5 let the oral tract radiate again over the last third (/n/ read + // -3.8 dB under its vowels against /m/ -13.2 dB, whose lips hold to 0.7). + var closureHold = tokens.Contains("nasal") ? 0.8f : 0.5f; + AddAuthoredSpline($"{name}_constriction_diameter", "/vocal/areas/0/area/constriction_diameter", start, duration, (0, constriction), (closureHold, constriction), (1, Math.Min(0.65f, constriction + 0.2f))); if (tokens.Contains("bilabial") || tokens.Contains("labiodental") || tokens.Contains("labial") || tokens.Contains("rounded")) { diff --git a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs index 7ac71b1..165be16 100644 --- a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs +++ b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs @@ -113,4 +113,38 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) Assert.True(burstAfter < burstPeak / 5, "the burst is over once the vowel is open"); Assert.True(burstPeak < vowelPeak / 2, "the burst stays well under the following vowel's peak (the b level fix)"); } + + // /ɑ C ɑ/ at the scaffold's own durations (vowel 0.18 s, consonant 0.095 s). Returns the render and the consonant's [start, end). + private (float[]? Samples, double From, double To) VowelConsonantVowel(string ipa, float frequency = 140) + { + var script = IpaGestureExperiment.BuildWordScript( + [(new IpaGestureExperimentTarget("w_0_a", "a", "", 0.18f), 1f), (new IpaGestureExperimentTarget("w_1_c", ipa, "", 0.095f), 1f), (new IpaGestureExperimentTarget("w_2_a", "a", "", 0.18f), 1f)], frequency); + return (Render(script, 0.5f), 0.18, 0.275); + } + + // Level of the consonant's middle 60 percent against the mean power of the steady parts of the two flanking vowels (the 70 ms before the + // consonant and 90 ms from 30 ms after it), in dB. + private static double ConsonantDbReVowels(float[] x, double from, double to) + { + var v1 = Rms(x, from - 0.08, from - 0.01); + var v2 = Rms(x, to + 0.03, to + 0.12); + var vowels = Math.Sqrt((v1 * v1 + v2 * v2) / 2); + var span = to - from; + return 20 * Math.Log10(Math.Max(1e-9, Rms(x, from + 0.2 * span, to - 0.2 * span)) / vowels); + } + + // Fujimura 1962 and later accounts put the nasal murmur well under the flanking vowels; the target band is 8 to 16 dB under (a DESIGN + // value: no primary level was retrieved, docs/research/articulatory-vocal-tract-sources.md). /m/ measured -12.8 dB; /n/ read -3.8 dB + // while its alveolar closure began to open at half the gesture, a third before the velum and the contact did. + [Theory] + [InlineData("m")] + [InlineData("n")] + public void NasalMurmurIsWellBelowTheFlankingVowels(string ipa) + { + var (x, from, to) = VowelConsonantVowel(ipa); + if (x is null) return; + var db = ConsonantDbReVowels(x, from, to); + output.WriteLine($"/{ipa}/ murmur {db:F1} dB re the flanking vowels"); + Assert.InRange(db, -16, -8); + } } diff --git a/tools/formant-check/verify_renders.py b/tools/formant-check/verify_renders.py index 65fe780..80c009b 100644 --- a/tools/formant-check/verify_renders.py +++ b/tools/formant-check/verify_renders.py @@ -61,10 +61,13 @@ def main(): p = os.path.join(a.dir, w, f"{w}.wav") if not os.path.exists(p): continue x, sr = wav(p) - seg = [(0, .18), (.18, .275), (.275, .455)] - r = [rms(x, sr, *s) for s in seg] - res[w] = {"rms": r, "murmur_db_re_vowel": round(20 * np.log10(r[1] / max(r[0], 1e-9)), 1), "burg_murmur": burg(p, .20, .26)} - print(f"{w}: rms V/{c}/V {['%.4f' % q for q in r]} murmur {res[w]['murmur_db_re_vowel']} dB re first vowel; Burg of the murmur {res[w]['burg_murmur']}") + # steady parts only: the consonant's middle 60 percent against the mean power of the 70 ms before it and the 90 ms from 30 ms after it + c0, c1 = .18, .275 + v1, v2 = rms(x, sr, c0 - .08, c0 - .01), rms(x, sr, c1 + .03, c1 + .12) + m = rms(x, sr, c0 + .2 * (c1 - c0), c1 - .2 * (c1 - c0)) + db = round(20 * np.log10(max(m, 1e-9) / np.sqrt((v1 ** 2 + v2 ** 2) / 2)), 1) + res[w] = {"rms": [v1, m, v2], "murmur_db_re_flanking_vowels": db, "burg_murmur": burg(p, .20, .26)} + print(f"{w}: steady rms V/{c}/V {['%.4f' % q for q in (v1, m, v2)]} murmur {db} dB re the flanking vowels; Burg of the murmur {res[w]['burg_murmur']}") # stress p0, p1 = os.path.join(a.dir, "nibu_flat", "nibu_flat.wav"), os.path.join(a.dir, "nibu", "nibu.wav") if os.path.exists(p0) and os.path.exists(p1):