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