Code/LetterToSound.cs
#nullable enable
using System;
using System.Collections.Generic;
namespace SharpTalk
{
// English letter-to-sound rules derived from NRL Report 7948
// (Elovitz, Johnson, McHugh, Shore — "Automatic Translation of English Text to Phonetics", 1976)
public static class LetterToSound
{
const byte pIY=0,pIH=1,pEH=2,pAE=3,pAA=4,pAH=5,pAO=6,pUH=7,pAX=8,pER=9;
const byte pEY=10,pAY=11,pOY=12,pAW=13,pOW=14,pUW=15;
const byte pW=28,pY=29,pR=30,pL=31,pHH=32,pM=33,pN=34,pNG=35;
const byte pF=36,pV=37,pTH=38,pDH=39,pS=40,pZ=41,pSH=42,pZH=43;
const byte pP=44,pB=45,pT=46,pD=47,pK=48,pG=49,pCH=50,pJH=51;
const byte CV = 0x01; // vowel: A E I O U Y
const byte CF = 0x02; // front vowel: E I Y
const byte CC = 0x04; // consonant: all non-vowels
const byte CZ = 0x08; // voiced consonant: B D G J L M N R V W Z
const byte CS = 0x10; // sibilant: S C G Z X J (+ CH/SH digraphs)
const byte CU = 0x20; // @-consonant (long-U modifier): T S R D L Z N J
static readonly byte[] CK = new byte[128];
static LetterToSound()
{
foreach (char c in "AEIOUY") CK[c] |= CV;
foreach (char c in "EIY") CK[c] |= CF;
foreach (char c in "BCDFGHJKLMNPQRSTVWXZ") CK[c] |= CC;
foreach (char c in "BDGJLMNRVWZ") CK[c] |= CZ;
foreach (char c in "SCGZXJ") CK[c] |= CS;
foreach (char c in "TSRDLZNJ") CK[c] |= CU;
Rules = Compile(RuleSrc);
}
// Format: "LEFT[MATCH]RIGHT=OUTPUT"
// Special symbols in LEFT / RIGHT (not between brackets):
// # 1+ vowels * 1+ consonants . voiced consonant
// $ 1 consonant + E/I % suffix & sibilant
// @ long-U consonant ^ exactly 1 cons + front vowel (E I Y)
// : 0+ consonants ' ' word boundary
// OUTPUT: space-separated NRL phoneme names, or empty for silence.
static readonly string[][] RuleSrc =
{
new[]{
"[A] =AX",
" [ARE]=AA R",
" [AR]O=AX R",
"#[AR]#=EH R",
" ^[AS]#=EY S",
"[A]WA=AX",
"[AW]=AO",
" :[ANY]=EH N IY",
"[A]^+#=EY",
"#:[ALLY]=AX L IY",
" [AL]#=AX L",
"[AGAIN]=AX G EH N",
"#:[AG]E=IH JH",
"#[A]^+#=AE",
" *[A]^+ =EY",
"[A]^%=EY",
" *[ARR]=AX R",
"[ARR]=AE R",
" *[AR] =AA R",
"[AR] =ER",
"[AR]=AA R",
"[AIR]=EH R",
"[AI]=EY",
"[AY]=EY",
"[AU]=AO",
"#*:[AL] =AX L",
"#*:[ALS] =AX L Z",
"[ALK]=AO K",
"[AL]=AO L",
" *[ABLE]=EY B AX L",
"[ABLE]=AX B AX L",
"[ANG]+=EY N JH",
"[A]=AE",
},
new[]{
" [BE]^#=B IH",
"[BEING]=B IY IH NG",
" [BOTH] =B OW TH",
" [BUS]#=B IH Z",
"[BUIL]=B IH L",
"[B]=B",
},
new[]{
" [CH]=K",
"^^E[CH]=K",
"[CH]=CH",
" S[CI]#=S AY",
"[CI]A=SH",
"[CI]O=SH",
"[CI]EN=SH",
"[C]+=S",
"[CK]=K",
".[COM]%=K AH M",
"[C]=K",
},
new[]{
"#*:[DED] =D IH D",
".E[D]=D",
"#*^E[D]=T",
" [DE]^#=D IH",
" [DO] =D UW",
" [DOES]=D AH Z",
" [DOING]=D UW IH NG",
" [DOW]=D AW",
"[DU]A=JH UW",
"[D]=D",
},
new[]{
"#*[E] =",
"#*^[E] =",
" :[E] =IY",
"#[ED] =D",
"#*[E]D =",
"[EV]ER=EH V",
"[E]^%=IY",
"[ERI]#=IY R IY",
"[ERI]=EH R IH",
"#:[ER]#=ER",
"[ER]#=EH R",
"[ER]=ER",
" [EVEN]=IY V EH N",
"#:[EW]=",
"@[EW]=UW",
"[EW]=Y UW",
"[EO]=IY",
"#*&[ES] =IH Z",
"#*[ES] =",
"#*[ELY] =L IY",
"#*[EMENT] =M EH N T",
"[EFUL]=F UH L",
"[EE]=IY",
"[EARN]=ER N",
" [EAR]^=ER",
"[EAD]=EH D",
"#*[EA] =IY AX",
"[EA]SU=EH",
"[EA]=IY",
"[EIGH]=EY",
"[EI]=IY",
" [EYE]=AY",
"[EY]=IY",
"[EU]=Y UW",
"[E]=EH",
},
new[]{
"[FUL]=F UH L",
"[F]=F",
},
new[]{
" [GN]=N",
"[GIV]=G IH V",
" [G]I=G",
"[GE]T=G EH",
"SU[GGES]=G JH EH S",
"[GG]=G",
" B#[G]=G",
"[G]+=JH",
"[GREAT]=G R EY T",
"#[GH]=",
"[G]=G",
},
new[]{
" [HAV]=HH AE V",
" [HERE]=HH IY R",
" [HOUR]=AW ER",
"[HOW]=HH AW",
"[H]#=HH",
"[H]=",
},
new[]{
" [IN]=IH N",
" [I] =AY",
"[IN]D=AY N",
"[IER]=IY ER",
"#*R[IED] =IY D",
"[IED] =AY D",
"[IEN]=IY EH N",
"[IE]T=AY EH",
" :[I]%=AY",
"[I]%=IY",
"[I]E=IY",
"[I]^+#=IH",
"[I]#=AY R",
"[IZ]%=AY Z",
"[IS]%=AY Z",
"[ID]%=AY D",
"+^[I]+=IH",
"[I]T%=AY",
"#*:[I]^+=IH",
"[I]^+=AY",
"[IR]=ER",
"[IGH]=AY",
"[ILD]=AY L D",
"[IGN] =AY N",
"[IGN]^=AY N",
"[IGN]%=AY N",
"[IQUE]=IY K",
"[I]=IH",
},
new[]{
"[J]=JH",
},
new[]{
" [K]N=",
"[K]=K",
},
new[]{
"[LO]C#=L OW",
"[L]L=",
"#^:[L]%=AX L",
"[LEAD]=L IY D",
"[L]=L",
},
new[]{
"[MOV]=M UW V",
"[M]=M",
},
new[]{
"E[NG]+=N JH",
"[NG]R=NG G",
"[NG]#=NG G",
"[NGL]%=NG G AX L",
"[NG]=NG",
"[NK]=NG K",
" [NOW] =N AW",
"[N]=N",
},
new[]{
"[OF] =AX V",
"[OROUGH]=ER OW",
"#:[OR] =ER",
"#:[ORS] =ER Z",
"[OR]=AO R",
" [ONE]=W AH N",
"[OW]=OW",
" [OVER]=OW V ER",
"[OV]=AH V",
"[O]^%=OW",
"[O]^EN=OW",
"[O]^I#=OW",
"[OLD]=OW L D",
"[OUGHT]=AO T",
"[OUGH]=AH F",
" [OU]=AW",
"H[OU]S#=AW",
"[OUS]=AX S",
"[OUR]=AO R",
"[OULD]=UH D",
"^^[OU]L=AH",
"[OUP]=UW P",
"[OU]=AW",
"[OY]=OY",
"[OING]=OW IH NG",
"[OI]=OY",
"[OOR]=AO R",
"[OOK]=UH K",
"[OOD]=UH D",
"[OO]=UW",
"[O]E=OW",
"[O] =OW",
"[OA]=OW",
" [ONLY]=OW N L IY",
" [ONCE]=W AH N S",
"*[ON] T=OW N",
"C[ION]=AX N",
"[O]NG=AO",
" ^:[ON]=AH N",
"#:[ON]=AX N",
"#*[ON] =AX N",
"#^[ON]=AX N",
"[O]ST =OW",
"[OF]^=AO F",
"[OTHER]=AH DH ER",
"[OSS] =AO S",
"#*:[OM]=AH M",
"[O]=AA",
},
new[]{
"[PH]=F",
"[PEOP]=P IY P",
"[POW]=P AW",
"[PUT] =P UH T",
"[P]=P",
},
new[]{
"[QUAR]=K W AO R",
"[QU]=K W",
"[Q]=K",
},
new[]{
" [RE]^#=R IY",
"[R]=R",
},
new[]{
"[SH]=SH",
"#[SION]=ZH AX N",
"[SOME]=S AH M",
"#[SUR]#=ZH ER",
"[SUR]#=SH ER",
"#[SU]#=ZH UW",
"#[SSU]#=SH UW",
"#[SED] =Z D",
"#[S]#=Z",
"[SAID]=S EH D",
"^^[SION]=SH AX N",
"[S]S=",
".[S] =Z",
"#*.E[S] =Z",
"#*^##[S] =Z",
"#*^#[S] =S",
"U[S] =S",
" :#[S] =Z",
" [SCH]=S K",
"[S]C+=",
"#[SM]=Z M",
"#[SN] =Z AX N",
"[S]=S",
},
new[]{
" [THE] =DH AX",
"[TO] =T UW",
"[THAT] =DH AE T",
" [THIS] =DH IH S",
" [THEY]=DH EY",
" [THERE]=DH EH R",
"[THER]=DH ER",
"[THEIR]=DH EH R",
" [THAN] =DH AE N",
" [THEM] =DH EH M",
"[THESE] =DH IY Z",
" [THEN]=DH EH N",
"[THROUGH]=TH R UW",
"[THOSE]=DH OW Z",
"[THOUGH] =DH OW",
" [THUS]=DH AH S",
"[TH]=TH",
"#:[TED] =T IH D",
"S[TI]#N=CH",
"[TION]=SH AX N",
"[TIO]=SH",
"[TIA]=SH",
"[TIEN]=SH AX N",
"[TUR]#=CH ER",
"[TU]A=CH UW",
" [TWO]=T UW",
"[T]=T",
},
new[]{
" [UN]I=Y UW N",
" [UN]=AH N",
" [UPON]=AX P AO N",
"@[UR]#=UH R",
"[UR]#=Y UH R",
"[UR]=ER",
"[U]^ =AH",
"[U]^^=AH",
"[UY]=AY",
" G[U]#=",
"G[U]%=",
"G[U]#=W",
"#N[U]=Y UW",
"@[U]=UW",
"[U]=Y UW",
},
new[]{
"[VIEW]=V Y UW",
"[V]=V",
},
new[]{
" [WERE]=W ER",
"[WA]S=W AA",
"[WA]T=W AA",
"[WHERE]=WH EH R",
"[WHOL]=HH OW L",
"[WHO]=HH UW",
"[WH]=WH",
"[WAR]=W AO R",
"[WOR]^=W ER",
"[WR]=R",
"[W]=W",
},
new[]{
"[X]=K S",
},
new[]{
"[YOUNG]=Y AH NG",
" [YOU]=Y UW",
" [YES]=Y EH S",
" [Y] =AY",
"#^:[Y] =IY",
"#^:[Y]I=IY",
" :[Y] =AY",
" :[Y]#=Y", // initial Y before vowel = glide (year, yellow, yet)
" :[Y]^+#=IH",
" :[Y]^#=AY",
"[Y]=IH",
},
new[]{
"[Z]=Z",
},
};
readonly struct CompiledRule
{
public readonly string Left;
public readonly string Match;
public readonly string Right;
public readonly byte[] Out;
public CompiledRule(string l, string m, string r, byte[] o)
{ Left = l; Match = m; Right = r; Out = o; }
}
static readonly CompiledRule[][] Rules;
static CompiledRule[][] Compile(string[][] src)
{
var result = new CompiledRule[26][];
for (int li = 0; li < 26; li++)
{
var group = src[li];
var compiled = new CompiledRule[group.Length];
for (int ri = 0; ri < group.Length; ri++)
compiled[ri] = ParseRule(group[ri]);
result[li] = compiled;
}
return result;
}
static CompiledRule ParseRule(string s)
{
int lbr = s.IndexOf('[');
int rbr = s.IndexOf(']');
int eq = s.IndexOf('=', rbr + 1);
return new CompiledRule(
lbr > 0 ? s[..lbr] : "",
s[(lbr+1)..rbr],
s[(rbr+1)..eq],
ParseOutput(s[(eq+1)..])
);
}
static readonly Dictionary<string, byte> PhonMap = new()
{
{"IY",pIY},{"IH",pIH},{"EH",pEH},{"AE",pAE},{"AA",pAA},{"AH",pAH},
{"AO",pAO},{"UH",pUH},{"AX",pAX},{"ER",pER},{"EY",pEY},{"AY",pAY},
{"OY",pOY},{"AW",pAW},{"OW",pOW},{"UW",pUW},
{"W",pW},{"Y",pY},{"R",pR},{"L",pL},{"HH",pHH},{"M",pM},{"N",pN},
{"NG",pNG},{"F",pF},{"V",pV},{"TH",pTH},{"DH",pDH},{"S",pS},{"Z",pZ},
{"SH",pSH},{"ZH",pZH},{"P",pP},{"B",pB},{"T",pT},{"D",pD},
{"K",pK},{"G",pG},{"CH",pCH},{"JH",pJH},{"WH",pW},
};
static byte[] ParseOutput(string s)
{
if (string.IsNullOrWhiteSpace(s)) return Array.Empty<byte>();
var parts = s.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
var buf = new byte[parts.Length];
for (int i = 0; i < parts.Length; i++)
buf[i] = PhonMap.TryGetValue(parts[i], out byte p) ? p : pAX;
return buf;
}
public static byte[] Convert(string word)
{
if (string.IsNullOrEmpty(word)) return Array.Empty<byte>();
// Pad: leading space (word boundary) + word uppercase + two trailing spaces
var inp = new char[word.Length + 3];
inp[0] = ' ';
for (int i = 0; i < word.Length; i++)
inp[i + 1] = char.ToUpperInvariant(word[i]);
inp[word.Length + 1] = ' ';
inp[word.Length + 2] = ' ';
var phons = new List<byte>(word.Length * 2);
int pos = 1;
while (inp[pos] != ' ')
{
char c = inp[pos];
if (c == '\'' || c == '.') { pos++; continue; }
int li = c - 'A';
if (li < 0 || li >= 26) { pos++; continue; }
bool matched = false;
foreach (var rule in Rules[li])
{
if (!MatchMid(inp, pos, rule.Match, out int endPos)) continue;
if (!MatchCtx(inp, pos - 1, rule.Left, rule.Left.Length - 1, -1)) continue;
if (!MatchCtx(inp, endPos, rule.Right, 0, +1)) continue;
foreach (byte ph in rule.Out) phons.Add(ph);
pos = endPos;
matched = true;
break;
}
if (!matched) pos++;
}
return phons.ToArray();
}
static bool MatchMid(char[] inp, int pos, string match, out int end)
{
end = pos;
foreach (char m in match)
{
if (end >= inp.Length || inp[end] != m) return false;
end++;
}
return true;
}
// Recursive context matcher with backtracking for #, *, :
// dir=+1: left-to-right (right context), ci advances forward
// dir=-1: right-to-left (left context), ci retreats toward -1
static bool MatchCtx(char[] inp, int pos, string ctx, int ci, int dir)
{
int cEnd = dir == 1 ? ctx.Length : -1;
if (ci == cEnd) return true;
char sym = ctx[ci];
int nci = ci + dir;
switch (sym)
{
case '#': // one or more vowels
if (!IsVowel(inp, pos)) return false;
pos += dir;
while (true)
{
if (MatchCtx(inp, pos, ctx, nci, dir)) return true;
if (!IsVowel(inp, pos)) return false;
pos += dir;
}
case '*': // one or more consonants
if (!IsConsonant(inp, pos)) return false;
pos += dir;
while (true)
{
if (MatchCtx(inp, pos, ctx, nci, dir)) return true;
if (!IsConsonant(inp, pos)) return false;
pos += dir;
}
case ':': // zero or more consonants
if (MatchCtx(inp, pos, ctx, nci, dir)) return true;
while (IsConsonant(inp, pos))
{
pos += dir;
if (MatchCtx(inp, pos, ctx, nci, dir)) return true;
}
return false;
case '^': // exactly one consonant
if (!IsConsonant(inp, pos)) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
case '+': // one front vowel (E I Y)
if (pos < 0 || pos >= inp.Length || (CK[inp[pos]] & CF) == 0) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
case '.': // one voiced consonant
if (!IsVoiced(inp, pos)) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
case '&': // one sibilant
{
int p2 = pos;
if (!MatchSibilant(inp, ref p2, dir)) return false;
return MatchCtx(inp, p2, ctx, nci, dir);
}
case '@': // one long-U consonant
if (!IsUMod(inp, pos)) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
case '%': // suffix (right context only): ER, E, ES, ED, ING, ELY
if (!MatchSuffix(inp, pos, out int sfxEnd)) return false;
return MatchCtx(inp, sfxEnd, ctx, nci, dir);
case '$': // one consonant followed by E or I
if (!IsConsonant(inp, pos)) return false;
pos += dir;
if (pos < 0 || pos >= inp.Length || (CK[inp[pos]] & CF) == 0) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
case ' ': // word boundary
if (pos < 0 || pos >= inp.Length || inp[pos] != ' ') return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
default: // literal character
if (pos < 0 || pos >= inp.Length || inp[pos] != sym) return false;
return MatchCtx(inp, pos + dir, ctx, nci, dir);
}
}
static bool IsVowel(char[] inp, int p)
=> p >= 0 && p < inp.Length && (CK[inp[p]] & CV) != 0;
static bool IsConsonant(char[] inp, int p)
{
if (p < 0 || p >= inp.Length) return false;
char c = inp[p];
if ((CK[c] & CC) != 0) return true;
if ((c == 'Q' || c == 'G') && p + 1 < inp.Length && inp[p + 1] == 'U') return true;
return false;
}
static bool IsVoiced(char[] inp, int p)
=> p >= 0 && p < inp.Length && (CK[inp[p]] & CZ) != 0;
static bool IsUMod(char[] inp, int p)
=> p >= 0 && p < inp.Length && (CK[inp[p]] & CU) != 0;
static bool MatchSibilant(char[] inp, ref int pos, int dir)
{
if (pos < 0 || pos >= inp.Length) return false;
char c = inp[pos];
if ((CK[c] & CS) != 0) { pos += dir; return true; }
if (dir == 1 && (c == 'C' || c == 'S') && pos + 1 < inp.Length && inp[pos + 1] == 'H')
{ pos += 2; return true; }
if (dir == -1 && c == 'H' && pos > 0 && (inp[pos - 1] == 'C' || inp[pos - 1] == 'S'))
{ pos -= 2; return true; }
return false;
}
static bool MatchSuffix(char[] inp, int pos, out int end)
{
end = pos;
if (pos < 0 || pos >= inp.Length) return false;
char c = inp[pos];
if (c == 'E')
{
if (pos + 2 < inp.Length && inp[pos+1] == 'R') { end = pos + 2; return true; }
if (pos + 2 < inp.Length && inp[pos+1] == 'D') { end = pos + 2; return true; }
if (pos + 2 < inp.Length && inp[pos+1] == 'S') { end = pos + 2; return true; }
if (pos + 3 < inp.Length && inp[pos+1]=='L' && inp[pos+2]=='Y') { end = pos + 3; return true; }
if (pos + 1 < inp.Length && inp[pos + 1] == ' ') { end = pos + 1; return true; }
return false;
}
if (c == 'I' && pos + 3 < inp.Length && inp[pos+1]=='N' && inp[pos+2]=='G')
{ end = pos + 3; return true; }
return false;
}
}
}