1import type { SpeechCustomWords } from "@t3tools/contracts";
2
3/** Fuzzy-matches preferred terms and rewrites aliases to their preferred spelling. */
4export function makeSpeechTextCorrector(
5 words: readonly string[],
6 dictionary: SpeechCustomWords = [],
7) {
8 const MATCH_THRESHOLD = 0.18;
9
10 const matchKey = (value: string) =>
11 [...value]
12 .filter((character) => /[\p{L}\p{N}]/u.test(character))
13 .join("")
14 .toLocaleLowerCase();
15
16 const isAsciiKey = (value: string) => /^[a-z0-9]+$/i.test(value);
17
18 const soundex = (value: string) => {
19 const upper = value.toUpperCase();
20 const first = upper[0] ?? "";
21 const code = (character: string) => {
22 if ("BFPV".includes(character)) return "1";
23 if ("CGJKQSXZ".includes(character)) return "2";
24 if ("DT".includes(character)) return "3";
25 if (character === "L") return "4";
26 if ("MN".includes(character)) return "5";
27 if (character === "R") return "6";
28 return "0";
29 };
30 let previous = code(first);
31 let result = first;
32 for (const character of upper.slice(1)) {
33 const next = code(character);
34 if (next !== "0" && next !== previous) result += next;
35 previous = next;
36 }
37 return `${result}000`.slice(0, 4);
38 };
39
40 const editDistance = (left: string, right: string) => {
41 let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
42 for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
43 const current = [leftIndex + 1];
44 for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
45 current.push(
46 Math.min(
47 current[rightIndex]! + 1,
48 previous[rightIndex + 1]! + 1,
49 previous[rightIndex]! + (left[leftIndex] === right[rightIndex] ? 0 : 1),
50 ),
51 );
52 }
53 previous = current;
54 }
55 return previous[right.length]!;
56 };
57
58 const preserveCase = (original: string, replacement: string) => {
59 const letters = [...original].filter((character) => /\p{L}/u.test(character));
60 if (letters.length > 0 && letters.every((character) => character === character.toUpperCase()))
61 return replacement.toUpperCase();
62 const firstLetter = [...original].find((character) => /\p{L}/u.test(character));
63 if (firstLetter && firstLetter === firstLetter.toUpperCase()) {
64 const [first = "", ...rest] = [...replacement];
65 return first.toUpperCase() + rest.join("");
66 }
67 return replacement;
68 };
69
70 type CustomWordKey = { readonly word: string; readonly key: string };
71
72 const customWordKeys = (words: readonly string[]): CustomWordKey[] =>
73 words.flatMap((word) => {
74 const sources = [word];
75 if (word.includes("&")) sources.push(word.replaceAll("&", " and "));
76 const keys = sources.flatMap((source) => {
77 const key = matchKey(source);
78 // A term that loses its symbols to one letter (C# -> c) would claim every standalone letter.
79 const lostSymbols = key !== source.replace(/\s+/gu, "").toLocaleLowerCase();
80 return key.length < 2 && lostSymbols ? [] : [key];
81 });
82 return [...new Set(keys)].filter(isAsciiKey).map((key) => ({ word, key }));
83 });
84
85 const possessive = /['’]s$/iu;
86 const innerPossessives = (value: string) => value.match(/['’]s(?=\s)/giu)?.length ?? 0;
87 type Match = { readonly word: string; readonly score: number; readonly suffix: string };
88 const bestMatch = (phrase: string, keys: readonly CustomWordKey[]) => {
89 const key = matchKey(phrase);
90 const suffix = phrase.match(possessive)?.[0] ?? "";
91 const stem = suffix ? key.slice(0, -1) : key;
92 if (!isAsciiKey(stem) || stem.length > 50) return undefined;
93 const inner = innerPossessives(phrase);
94 let best: Match | undefined;
95 for (const custom of keys) {
96 if (inner > 0 && inner > innerPossessives(custom.word)) continue;
97 const keepSuffix = suffix !== "" && !possessive.test(custom.word);
98 const candidate = keepSuffix ? stem : key;
99 if (Math.min(candidate.length, custom.key.length) < 4 && candidate !== custom.key) continue;
100 const longest = Math.max(candidate.length, custom.key.length);
101 if (Math.abs(candidate.length - custom.key.length) > Math.max(longest * 0.25, 2)) continue;
102 const distance = editDistance(candidate, custom.key) / longest;
103 const phonetic = /^[a-z]+$/i.test(candidate) && /^[a-z]+$/i.test(custom.key);
104 const score =
105 phonetic && soundex(candidate) === soundex(custom.key) ? distance * 0.3 : distance;
106 if (score < MATCH_THRESHOLD && (!best || score < best.score))
107 best = { word: custom.word, score, suffix: keepSuffix ? suffix : "" };
108 }
109 return best;
110 };
111
112 const apply = (text: string): string => {
113 if (keys.length === 0) return text;
114 // Leave surrounding punctuation in the source; only horizontal whitespace can join words.
115 const tokens = [...text.matchAll(/[\p{L}\p{M}\p{N}](?:\S*[\p{L}\p{M}\p{N}])?/gu)]
116 .filter(([word]) => /^[\p{L}\p{M}\p{N}'’-]+$/u.test(word))
117 .map((match) => ({
118 value: match[0],
119 start: match.index,
120 end: match.index + match[0].length,
121 }));
122 const output: string[] = [];
123 let cursor = 0;
124 for (let index = 0; index < tokens.length;) {
125 let best: (Match & { readonly count: number }) | undefined;
126 for (let count = Math.min(4, tokens.length - index); count >= 1; count -= 1) {
127 const slice = tokens.slice(index, index + count);
128 if (
129 slice
130 .slice(0, -1)
131 .some(
132 (token, offset) =>
133 !/^[\t\p{Zs}]+$/u.test(text.slice(token.end, slice[offset + 1]!.start)),
134 )
135 )
136 continue;
137 const match = bestMatch(text.slice(slice[0]!.start, slice[count - 1]!.end), keys);
138 if (
139 match &&
140 (!best || match.score < best.score || (match.score === best.score && count < best.count))
141 )
142 best = { count, ...match };
143 }
144 if (!best) {
145 index += 1;
146 continue;
147 }
148 const first = tokens[index]!;
149 output.push(
150 text.slice(cursor, first.start),
151 preserveCase(first.value, best.word),
152 best.suffix,
153 );
154 cursor = tokens[index + best.count - 1]!.end;
155 index += best.count;
156 }
157 output.push(text.slice(cursor));
158 return output.join("");
159 };
160
161 const keys = customWordKeys(words);
162 const escapePattern = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
164 const spellings = dictionary
165 .flatMap(({ term, aliases }) => [term, ...aliases].map((spelling) => ({ spelling, term })))
166 .sort((left, right) => right.spelling.length - left.spelling.length);
167 if (spellings.length === 0) return apply;
168 const lookup = new Map(
169 spellings.map(({ spelling, term }) => [spelling.toLocaleLowerCase(), term]),
170 );
171 const word = "[\\p{L}\\p{M}\\p{N}\\p{Pc}]";
172 const pattern = new RegExp(
173 `(?<!${word})(?:${spellings.map(({ spelling }) => escapePattern(spelling)).join("|")})(?!${word})`,
174 "giu",
175 );
176 return (text: string) => {
177 let output = "";
178 let cursor = 0;
179 for (const match of text.matchAll(pattern)) {
180 output += apply(text.slice(cursor, match.index));
181 output += lookup.get(match[0].toLocaleLowerCase()) ?? match[0];
182 cursor = match.index + match[0].length;
183 }
184 return output + apply(text.slice(cursor));
185 };
186}
187
188export function applySpeechAliases(text: string, words: SpeechCustomWords): string {
189 return makeSpeechTextCorrector([], words)(text);
190}
191
192const normalizeWord = (word: string) => word.replace(/\s+/g, " ").trim();
193
194export function normalizeSpeechCustomWords(words: SpeechCustomWords): SpeechCustomWords {
195 const seen = new Set<string>();
196 return words
197 .flatMap(({ term, aliases }) => {
198 const preferred = normalizeWord(term);
199 const key = preferred.toLocaleLowerCase();
200 if (!preferred || preferred.length > 50 || seen.has(key)) return [];
201 seen.add(key);
202 const normalizedAliases = aliases.flatMap((alias) => {
203 const value = normalizeWord(alias);
204 const aliasKey = value.toLocaleLowerCase();
205 if (!value || value.length > 50 || seen.has(aliasKey)) return [];
206 seen.add(aliasKey);
207 return [value];
208 });
209 return [{ term: preferred, aliases: normalizedAliases.slice(0, 8) }];
210 })
211 .slice(0, 100);
212}