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