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