diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 53f56bc..622bd7c 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -8,6 +8,34 @@ public sealed class PatchScriptException(int line, string message) : Exception($ } public static class PatchScript { + /// + /// The DSL's own IPA table: the phones a phoneme_gesture knows without an explicit descriptor. A hand-written script + /// that names another phone falls back to an alveolar approximant; a caller that must not be approximated asks here first. + /// + public static bool TryIpaDescriptor(string ipa, out string descriptor) + { + var known = ipa switch + { + "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", + "ʒ" => "voiced_postalveolar_fricative", + "ʍ" => "voiceless_labial_velar_fricative", + "w" => "voiced_labial_velar_approximant", + "i" => "voiced_close_front_unrounded_vowel", + "u" => "voiced_close_back_rounded_vowel", + "a" or "ɑ" => "voiced_open_back_unrounded_vowel", + "æ" => "voiced_open_front_unrounded_vowel", + "ə" => "voiced_mid_central_unrounded_vowel", + _ => null + }; + descriptor = known ?? ""; + return known is not null; + } public static SynthPatch Parse(string script) { @@ -1551,25 +1579,7 @@ public static class PatchScript return $"{phonation}_{place}_{manner}"; } - return ipa switch - { - "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", - "ʒ" => "voiced_postalveolar_fricative", - "ʍ" => "voiceless_labial_velar_fricative", - "w" => "voiced_labial_velar_approximant", - "i" => "voiced_close_front_unrounded_vowel", - "u" => "voiced_close_back_rounded_vowel", - "a" or "ɑ" => "voiced_open_back_unrounded_vowel", - "æ" => "voiced_open_front_unrounded_vowel", - "ə" => "voiced_mid_central_unrounded_vowel", - _ => "voiced_alveolar_approximant" - }; + return TryIpaDescriptor(ipa, out var known) ? known : "voiced_alveolar_approximant"; } private static HashSet DescriptorTokens(string descriptor) => diff --git a/src/AquaSynth.Core/WeksaPhones.cs b/src/AquaSynth.Core/WeksaPhones.cs new file mode 100644 index 0000000..9939c79 --- /dev/null +++ b/src/AquaSynth.Core/WeksaPhones.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AquaSynth.Dsl; + +public enum WeksaStress { None, Primary, Secondary } + +public sealed record WeksaPhone(string Ipa, WeksaStress Stress); + +public sealed record WeksaPhones(string Language, string Source, IReadOnlyList Phones); + +public sealed class WeksaPhonesException(string message) : Exception(message); + +/// +/// Reader for the Weksa phones line (`weksa lower --phones`): {"language","source","phones":[{"ipa","stress"}]}. It is +/// strict: a missing, extra, null or mistyped key, an unknown stress value, an empty phone list or malformed JSON is an +/// error, never a guess. Weksa owns the segmentation; durations, pitch and gesture stay with . +/// +public static class WeksaPhonesReader +{ + private static readonly JsonSerializerOptions Strict = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + AllowTrailingCommas = false, + ReadCommentHandling = JsonCommentHandling.Disallow, + PropertyNameCaseInsensitive = false + }; + + private sealed class PhoneDto + { + [JsonPropertyName("ipa")] public required string? Ipa { get; init; } + [JsonPropertyName("stress")] public required string? Stress { get; init; } + } + + private sealed class LineDto + { + [JsonPropertyName("language")] public required string? Language { get; init; } + [JsonPropertyName("source")] public required string? Source { get; init; } + [JsonPropertyName("phones")] public required List? Phones { get; init; } + } + + public static WeksaPhones Parse(string json) + { + LineDto? dto; + try { dto = JsonSerializer.Deserialize(json, Strict); } + catch (JsonException ex) { throw new WeksaPhonesException($"phones line is not the expected JSON: {ex.Message}"); } + if (dto is null) throw new WeksaPhonesException("phones line is null"); + if (string.IsNullOrWhiteSpace(dto.Language)) throw new WeksaPhonesException("`language` must be a non-empty string"); + if (string.IsNullOrWhiteSpace(dto.Source)) throw new WeksaPhonesException("`source` must be a non-empty string"); + if (dto.Phones is null || dto.Phones.Count == 0) throw new WeksaPhonesException("`phones` must be a non-empty list"); + var phones = new List(); + for (var i = 0; i < dto.Phones.Count; i++) + { + var p = dto.Phones[i] ?? throw new WeksaPhonesException($"phones[{i}] is null"); + if (string.IsNullOrWhiteSpace(p.Ipa)) throw new WeksaPhonesException($"phones[{i}].ipa must be a non-empty string"); + var stress = p.Stress switch + { + "none" => WeksaStress.None, + "primary" => WeksaStress.Primary, + "secondary" => WeksaStress.Secondary, + null => throw new WeksaPhonesException($"phones[{i}].stress must be a string"), + var other => throw new WeksaPhonesException($"phones[{i}].stress `{other}` is not none, primary or secondary") + }; + phones.Add(new WeksaPhone(p.Ipa, stress)); + } + + return new WeksaPhones(dto.Language, dto.Source, phones); + } +} + +/// +/// A Weksa phones line as a word the tract can render. Every phone must be in the DSL's IPA table +/// (); a phone outside it is listed in and the plan +/// carries no targets, so nothing is approximated. Duration is the vowel/consonant length AquaSynth owns; stress moves +/// only the gesture intensity, the one thing the word script uses stress for. +/// +public sealed record WeksaWordPlan(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> Phones, IReadOnlyList Unsupported) +{ + public const float VowelSeconds = 0.18f, ConsonantSeconds = 0.095f; + public bool Renderable => Unsupported.Count == 0; + + public static float StressIntensity(WeksaStress stress) => stress switch + { + WeksaStress.Primary => 1.2f, + WeksaStress.Secondary => 1.1f, + _ => 1f + }; + + public static WeksaWordPlan From(WeksaPhones word, string label) + { + var unsupported = word.Phones.Where(p => !PatchScript.TryIpaDescriptor(p.Ipa, out _)).ToArray(); + if (unsupported.Length > 0) return new WeksaWordPlan([], unsupported); + var targets = word.Phones.Select((p, i) => + { + PatchScript.TryIpaDescriptor(p.Ipa, out var descriptor); + var seconds = descriptor.EndsWith("_vowel", StringComparison.Ordinal) ? VowelSeconds : ConsonantSeconds; + return (new IpaGestureExperimentTarget($"{label}_{i}_{p.Ipa}", p.Ipa, "", seconds), StressIntensity(p.Stress)); + }).ToArray(); + return new WeksaWordPlan(targets, []); + } +} diff --git a/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt new file mode 100644 index 0000000..4683ab0 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt @@ -0,0 +1 @@ +nibu-en-US.phones.json is data/phonology/fixtures/nibu-en-US.phones.json from GameCult/Weksa PR 16 (branch weksa-lowering/phones-line), commit a7bc2478e53623ce572ac6d997ba058aa7707e44, byte for byte. diff --git a/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json new file mode 100644 index 0000000..45e2e11 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json @@ -0,0 +1 @@ +{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]} diff --git a/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs b/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs new file mode 100644 index 0000000..27ef6d6 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs @@ -0,0 +1,96 @@ +using System.Text.RegularExpressions; +using AquaSynth.Dsl; + +namespace AquaSynth.Dsl.Tests; + +// Fixtures/Weksa/nibu-en-US.phones.json is Weksa PR 16's fixture (see PROVENANCE.txt); the malformed cases below are +// edits of it, not invented phones. +public sealed class WeksaPhonesTests +{ + private static string Fixture() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Weksa", "nibu-en-US.phones.json")); + + private static string Line(string phones, string extra = "") => + $$"""{"language":"en-US","source":"ˈnibu",{{extra}}"phones":{{phones}}}"""; + + private static string Ph(string ipa, string stress) => $$"""{"ipa":"{{ipa}}","stress":"{{stress}}"}"""; + + [Fact] + public void AcceptsNibuFixtureAsNIBUWithPrimaryStressOnI() + { + var word = WeksaPhonesReader.Parse(Fixture()); + Assert.Equal("en-US", word.Language); + Assert.Equal("ˈnibu", word.Source); + Assert.Equal(["n", "i", "b", "u"], word.Phones.Select(p => p.Ipa)); + Assert.Equal([WeksaStress.None, WeksaStress.Primary, WeksaStress.None, WeksaStress.None], word.Phones.Select(p => p.Stress)); + var plan = WeksaWordPlan.From(word, "nibu"); + Assert.True(plan.Renderable); + Assert.Equal(["n", "i", "b", "u"], plan.Phones.Select(p => p.Target.Ipa)); + } + + public static IEnumerable Refused() + { + yield return ["not json at all"]; + yield return [Fixture0().Replace("}]}", "}]")]; // truncated + yield return [Fixture0() + " trailing"]; // trailing garbage + yield return ["null"]; + yield return [Line($"[{Ph("n", "tertiary")}]")]; // unknown stress value + yield return [Line($"[{Ph("n", "Primary")}]")]; // stress is exact, not case-folded + yield return [Line("""[{"ipa":"n"}]""")]; // missing stress + yield return [Line("""[{"stress":"none"}]""")]; // missing ipa + yield return ["""{"language":"en-US","phones":[{"ipa":"n","stress":"none"}]}"""]; // missing source + yield return ["""{"source":"n","phones":[{"ipa":"n","stress":"none"}]}"""]; // missing language + yield return ["""{"language":"en-US","source":"n"}"""]; // missing phones + yield return [Line($"[{Ph("n", "none")}]", "\"extra\":1,")]; // extra top-level key + yield return [Line("""[{"ipa":"n","stress":"none","dur":90}]""")]; // extra phone key + yield return [Line("""[{"ipa":5,"stress":"none"}]""")]; // wrong type + yield return [Line("""[{"ipa":"n","stress":1}]""")]; // stress as number + yield return [Line("""[{"ipa":null,"stress":"none"}]""")]; + yield return [Line("""[{"ipa":"n","stress":null}]""")]; + yield return [Line("""[null]""")]; + yield return [Line("""[{"ipa":"","stress":"none"}]""")]; + yield return [Line("{}")]; // phones not a list + yield return [Line("[]")]; // empty phones + } + + private static string Fixture0() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Weksa", "nibu-en-US.phones.json")).Trim(); + + [Theory] + [MemberData(nameof(Refused))] + public void RefusesMalformedInput(string json) => Assert.Throws(() => WeksaPhonesReader.Parse(json)); + + [Fact] + public void ReportsPhonesTheTractCannotMakeAndRendersNothing() + { + var word = WeksaPhonesReader.Parse(Line($"[{Ph("n", "none")},{Ph("ɪ", "primary")},{Ph("b", "none")},{Ph("ʊ", "none")}]")); + var plan = WeksaWordPlan.From(word, "x"); + Assert.False(plan.Renderable); + Assert.Equal(["ɪ", "ʊ"], plan.Unsupported.Select(p => p.Ipa)); + Assert.Empty(plan.Phones); + } + + [Fact] + public void StressMovesOnlyTheIntensityOfTheMarkedPhone() + { + string Script(WeksaStress onI) => IpaGestureExperiment.BuildWordScript( + WeksaWordPlan.From(new WeksaPhones("en-US", "ˈnibu", + [new("n", WeksaStress.None), new("i", onI), new("b", WeksaStress.None), new("u", WeksaStress.None)]), "w").Phones.ToArray()); + + var none = Script(WeksaStress.None).Split('\n'); + var primary = Script(WeksaStress.Primary).Split('\n'); + Assert.Equal(none.Length, primary.Length); + var differing = Enumerable.Range(0, none.Length).Where(i => none[i] != primary[i]).ToArray(); + Assert.Single(differing); + Assert.Contains("ipa=i ", primary[differing[0]]); + var strip = (string l) => Regex.Replace(l, @"intensity=\S+", ""); + Assert.Equal(strip(none[differing[0]]), strip(primary[differing[0]])); + Assert.Contains("intensity=1.2", primary[differing[0]]); + } + + [Fact] + public void PhonesTheWordPlanAcceptsAreExactlyTheDslTable() + { + foreach (var ipa in new[] { "n", "i", "b", "u", "a", "ə" }) + Assert.True(PatchScript.TryIpaDescriptor(ipa, out _)); + Assert.False(PatchScript.TryIpaDescriptor("ɪ", out _)); + } +}