From 3d01ff4db107aa9f5a9ad132e16321b1aaf2ba96 Mon Sep 17 00:00:00 2001 From: Aqua Date: Sun, 8 Nov 2026 00:57:36 +0000 Subject: [PATCH] Speech: bare IPA n is an alveolar nasal; word script on the experiment scaffold; WordRender probe PatchScript.GestureDescriptor had no case for n, so it fell to the alveolar approximant (no velum). IpaGestureExperiment.BuildWordScript runs gestures back to back on the same scaffold as BuildCandidateScript (refactored onto one Scaffold). tools/WordRender renders a word offline through native libfaust, driving surface sliders from ControlSplineTimeline because the Faust exporter's summed spline offsets do not sequence gestures. Two tests. Nothing was heard: the render is a near-pure sine with no vowel quality. Co-Authored-By: Claude Sonnet 5.5 --- src/AquaSynth.Core/IpaGestureExperiment.cs | 63 ++++++++++---- src/AquaSynth.Core/PatchScript.cs | 1 + tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 41 +++++++++ tools/WordRender/Program.cs | 87 +++++++++++++++++++ tools/WordRender/WordRender.csproj | 12 +++ 5 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 tools/WordRender/Program.cs create mode 100644 tools/WordRender/WordRender.csproj diff --git a/src/AquaSynth.Core/IpaGestureExperiment.cs b/src/AquaSynth.Core/IpaGestureExperiment.cs index 27b94cc..146f1ef 100644 --- a/src/AquaSynth.Core/IpaGestureExperiment.cs +++ b/src/AquaSynth.Core/IpaGestureExperiment.cs @@ -196,27 +196,52 @@ public static class IpaGestureExperiment var duration = Math.Clamp(target.DurationSeconds * variant.DurationScale, 0.03f, 1.5f); var intensity = Math.Clamp(variant.IntensityScale, 0.05f, 2f); - var sustain = Math.Clamp(duration + 0.12f, 0.15f, 2f); - var descriptor = target.Descriptor.Replace(' ', '_'); - var frequency = DescriptorTokens(target.Descriptor).Contains("vowel") ? 150 : 135; - - return $$""" - patch gain=.45 - morphology name=oral length_cm=17 diameters=.55,.75,1.05,1.45,1.25,.8 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.2 - morphology name=nasal length_cm=12 diameters=.04,.28,.52,.72 - waveguide_path name=oral_path morphology=oral strategy=thiran order=1 max_delay=4096 loss=.998 - waveguide_path name=nasal_path morphology=nasal strategy=thiran order=1 max_delay=4096 loss=.997 - source_port name=folds path=oral_path kind=glottal position=0 pressure=.66 tension=.54 opening=.42 noise=.04 impedance=.32 flow_scale=.12 - branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening=.01 coupling=1 - constriction_contact name=contact path=oral_path position=.92 opening=.5 resistance=.45 stored_pressure=.12 - radiation_load name=mouth path=oral_path kind=lip position=1 aperture=.8 reflection=-.82 impedance=.28 - probe_timeline name=flow network=voice blocks=12 block_size=64 - vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth probe=flow - phoneme_gesture name={{SafeDslName(target.Id)}} ipa={{target.Ipa}} descriptor={{descriptor}} start=0 dur={{Format(duration)}} intensity={{Format(intensity)}} - vocal network=voice freq={{Format(frequency)}} gain=.7 sustain={{Format(sustain)}} - """; + return Scaffold( + [GestureLine(target, 0, duration, intensity)], + Math.Clamp(duration + 0.12f, 0.15f, 2f), + DescriptorTokens(target.Descriptor).Contains("vowel") ? 150 : 135); } + /// + /// One word on the same scaffold: the targets run back to back, each at its own intensity (a stress mark is a higher + /// intensity on the stressed vowel). An empty descriptor leaves it to the DSL's own IPA table. Pitch stays flat at ; no contour is authored. + /// + public static string BuildWordScript(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> phones, float frequency = 140, float tailSeconds = 0.12f) + { + if (phones.Count == 0) throw new ArgumentException("a word needs at least one phone", nameof(phones)); + var lines = new List(); + var start = 0f; + foreach (var (target, intensity) in phones) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(target.Ipa); + var duration = Math.Clamp(target.DurationSeconds, 0.03f, 1.5f); + lines.Add(GestureLine(target, start, duration, Math.Clamp(intensity, 0.05f, 2f))); + start += duration; + } + + return Scaffold(lines, Math.Clamp(start + tailSeconds, 0.15f, 4f), frequency); + } + + private static string GestureLine(IpaGestureExperimentTarget target, float start, float duration, float intensity) => + $"phoneme_gesture name={SafeDslName(target.Id)} ipa={target.Ipa} {(target.Descriptor.Length == 0 ? "" : $"descriptor={target.Descriptor.Replace(' ', '_')} ")}start={Format(start)} dur={Format(duration)} intensity={Format(intensity)}"; + + private static string Scaffold(IReadOnlyList gestureLines, float sustain, float frequency) => $$""" + patch gain=.45 + morphology name=oral length_cm=17 diameters=.55,.75,1.05,1.45,1.25,.8 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.2 + morphology name=nasal length_cm=12 diameters=.04,.28,.52,.72 + waveguide_path name=oral_path morphology=oral strategy=thiran order=1 max_delay=4096 loss=.998 + waveguide_path name=nasal_path morphology=nasal strategy=thiran order=1 max_delay=4096 loss=.997 + source_port name=folds path=oral_path kind=glottal position=0 pressure=.66 tension=.54 opening=.42 noise=.04 impedance=.32 flow_scale=.12 + branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening=.01 coupling=1 + constriction_contact name=contact path=oral_path position=.92 opening=.5 resistance=.45 stored_pressure=.12 + radiation_load name=mouth path=oral_path kind=lip position=1 aperture=.8 reflection=-.82 impedance=.28 + probe_timeline name=flow network=voice blocks=12 block_size=64 + vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth probe=flow + {{string.Join("\n", gestureLines)}} + vocal network=voice freq={{Format(frequency)}} gain=.7 sustain={{Format(sustain)}} + """; + private static IReadOnlyList SummarizeMetrics(IReadOnlyList metrics) => metrics .Where(metric => metric.Layer.Equals("gesture", StringComparison.OrdinalIgnoreCase)) diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 91fafa7..d47cbe5 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -1518,6 +1518,7 @@ public static class PatchScript "p" => "voiceless_bilabial_plosive", "b" => "voiced_bilabial_plosive", "m" => "voiced_bilabial_nasal", + "n" => "voiced_alveolar_nasal", "s" => "voiceless_alveolar_fricative", "z" => "voiced_alveolar_fricative", "ʃ" => "voiceless_postalveolar_fricative", diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 1d00e05..9ace5ec 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -613,6 +613,47 @@ public sealed class PatchScriptTests Assert.True(fricativeControls["/vocal/sources/0/noise"] > nasalControls["/vocal/sources/0/noise"]); } + [Fact] + public void BareNIsAnAlveolarNasalNotTheApproximantFallback() + { + var patch = PatchScript.Parse(""" + morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.6,1.3,.9 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.5 + morphology name=nasal length_cm=12 diameters=.05,.35,.6,.8 + waveguide_path name=oral_path morphology=oral loss=.998 + waveguide_path name=nasal_path morphology=nasal loss=.997 + source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=.05 impedance=.3 + branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening=.01 coupling=1 + constriction_contact name=contact path=oral_path position=.92 opening=.5 resistance=.4 stored_pressure=.1 + radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28 + vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth + phoneme_gesture name=en ipa=n start=0 dur=.1 + vocal network=voice freq=140 gain=.2 sustain=.3 + """); + + Assert.Equal("voiced_alveolar_nasal", patch.PhonemeGestures.Single().Descriptor); + Assert.Contains(patch.ControlSplines, spline => spline.Name.StartsWith("en_velum", StringComparison.Ordinal) && spline.SurfacePath == "/vocal/branches/0/opening"); + var catalog = ControlSurfaceCatalog.FromPatch(patch); + Assert.True(catalog.CreateTimeline().ControlValuesAt(.03f)["/vocal/branches/0/opening"] > .5f, "the velum opens for n"); + } + + [Fact] + public void WordScriptRunsGesturesBackToBackAndLeavesDescriptorsToTheDslTable() + { + var script = IpaGestureExperiment.BuildWordScript( + [ + (new IpaGestureExperimentTarget("w_n", "n", "", .1f), 1f), + (new IpaGestureExperimentTarget("w_i", "i", "", .2f), 1.2f), + (new IpaGestureExperimentTarget("w_b", "b", "", .1f), 1f) + ], frequency: 140); + + var patch = PatchScript.Parse(script); + Assert.Equal(["n", "i", "b"], patch.PhonemeGestures.Select(gesture => gesture.Ipa)); + Assert.Equal([0f, .1f, .3f], patch.PhonemeGestures.Select(gesture => gesture.StartSeconds)); + Assert.Equal(["voiced_alveolar_nasal", "voiced_close_front_unrounded_vowel", "voiced_bilabial_plosive"], patch.PhonemeGestures.Select(gesture => gesture.Descriptor)); + Assert.Equal(1.2f, patch.PhonemeGestures[1].Intensity); + Assert.Throws(() => IpaGestureExperiment.BuildWordScript([])); + } + [Fact] public void IpaGestureExperimentWritesFrozenRoundBundle() { diff --git a/tools/WordRender/Program.cs b/tools/WordRender/Program.cs new file mode 100644 index 0000000..807b57a --- /dev/null +++ b/tools/WordRender/Program.cs @@ -0,0 +1,87 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using AquaSynth.Dsl; +using AquaSynth.Faust; + +// Offline probe: one word as a run of IPA gestures on the IpaGestureExperiment scaffold, rendered through the native +// libfaust path to a mono 16-bit WAV plus script.aqua, source.dsp and manifest.json. It measures peak, rms and NaN; +// it cannot say how the word sounds. +// usage: WordRender