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):