Weksa phones line reader: strict typed parse, unsupported phones reported, stress moves only intensity

The DSL's IPA table becomes PatchScript.TryIpaDescriptor so the reader asks the one table instead of keeping its own.
Fixture is Weksa PR 16 at a7bc2478, byte for byte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-08 04:43:20 +00:00
commit 11bcbd0621
5 changed files with 228 additions and 19 deletions

View file

@ -8,6 +8,34 @@ public sealed class PatchScriptException(int line, string message) : Exception($
}
public static class PatchScript
{
/// <summary>
/// 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.
/// </summary>
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<string> DescriptorTokens(string descriptor) =>

View file

@ -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<WeksaPhone> Phones);
public sealed class WeksaPhonesException(string message) : Exception(message);
/// <summary>
/// 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 <see cref="IpaGestureExperiment"/>.
/// </summary>
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<PhoneDto?>? Phones { get; init; }
}
public static WeksaPhones Parse(string json)
{
LineDto? dto;
try { dto = JsonSerializer.Deserialize<LineDto>(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<WeksaPhone>();
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);
}
}
/// <summary>
/// A Weksa phones line as a word the tract can render. Every phone must be in the DSL's IPA table
/// (<see cref="PatchScript.TryIpaDescriptor"/>); a phone outside it is listed in <see cref="Unsupported"/> 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.
/// </summary>
public sealed record WeksaWordPlan(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> Phones, IReadOnlyList<WeksaPhone> 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, []);
}
}

View file

@ -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.

View file

@ -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"}]}

View file

@ -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<object[]> 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<WeksaPhonesException>(() => 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 _));
}
}