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:
parent
3fe877cbf4
commit
11bcbd0621
5 changed files with 228 additions and 19 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
101
src/AquaSynth.Core/WeksaPhones.cs
Normal file
101
src/AquaSynth.Core/WeksaPhones.cs
Normal 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, []);
|
||||
}
|
||||
}
|
||||
1
tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
Normal file
1
tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
Normal 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.
|
||||
|
|
@ -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"}]}
|
||||
96
tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
Normal file
96
tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue