speechready 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/util.js ADDED
@@ -0,0 +1,146 @@
1
+ /** Small helpers that make the TypeScript port line up with the Python reference.
2
+ *
3
+ * Python's `re` is Unicode-aware by default: `\w` matches letters in any script
4
+ * and `\b` is a boundary next to them. JavaScript's `\w` and `\b` are ASCII only,
5
+ * so every pattern in this package is written with `W` and `B` instead and
6
+ * compiled with the `u` flag.
7
+ */
8
+ /** Python's `\w` under Unicode: letters, digits, underscore. Use inside a character class. */
9
+ export const W = "\\p{L}\\p{N}_";
10
+ /** Python's `\b` under Unicode. */
11
+ export const B = `(?:(?<=[${W}])(?![${W}])|(?<![${W}])(?=[${W}]))`;
12
+ /** Compile a pattern with the `u` flag (plus `g`, which every helper here expects). */
13
+ export function re(source, flags = "") {
14
+ let f = flags;
15
+ if (!f.includes("u"))
16
+ f += "u";
17
+ if (!f.includes("g"))
18
+ f += "g";
19
+ return new RegExp(source, f);
20
+ }
21
+ /** `pattern.sub(fn, text)`: every match replaced by `fn(match)`. */
22
+ export function sub(pattern, text, fn) {
23
+ pattern.lastIndex = 0;
24
+ return text.replace(pattern, (...args) => {
25
+ const groups = typeof args[args.length - 1] === "object" ? args.pop() : undefined;
26
+ args.pop(); // the whole string
27
+ const index = args.pop();
28
+ const m = args;
29
+ m.index = index;
30
+ m.groups = groups;
31
+ return fn(m);
32
+ });
33
+ }
34
+ /** `pattern.sub(repl, text)` with a plain string replacement (no `$` expansion). */
35
+ export function subs(pattern, text, repl) {
36
+ return sub(pattern, text, () => repl);
37
+ }
38
+ /** First match anywhere, or null. Safe on global patterns. */
39
+ export function search(pattern, text) {
40
+ pattern.lastIndex = 0;
41
+ const m = pattern.exec(text);
42
+ pattern.lastIndex = 0;
43
+ return m;
44
+ }
45
+ /** `pattern.match(text)`: a match anchored at the start, or null. */
46
+ export function matchStart(pattern, text) {
47
+ const m = search(pattern, text);
48
+ return m && m.index === 0 ? m : null;
49
+ }
50
+ /** `re.fullmatch(pattern, text)`. */
51
+ export function fullmatch(pattern, text) {
52
+ const anchored = new RegExp(`^(?:${pattern.source})$`, pattern.flags.replace("g", ""));
53
+ return anchored.exec(text);
54
+ }
55
+ export function test(pattern, text) {
56
+ return search(pattern, text) !== null;
57
+ }
58
+ /** All matches, in order. */
59
+ export function finditer(pattern, text) {
60
+ pattern.lastIndex = 0;
61
+ const out = [];
62
+ for (const m of text.matchAll(pattern))
63
+ out.push(m);
64
+ return out;
65
+ }
66
+ export function findall(pattern, text) {
67
+ return finditer(pattern, text).map((m) => m[0]);
68
+ }
69
+ /** `re.escape`: escape what is special in a pattern (letters are left alone, which the `u` flag requires). */
70
+ export function escapeRe(s) {
71
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
72
+ }
73
+ export function group(m, i) {
74
+ return m[i] === undefined ? undefined : m[i];
75
+ }
76
+ // --- strings -----------------------------------------------------------------
77
+ export function strip(s, chars) {
78
+ return rstrip(lstrip(s, chars), chars);
79
+ }
80
+ export function lstrip(s, chars) {
81
+ let i = 0;
82
+ while (i < s.length && (chars === undefined ? /\s/u.test(s[i]) : chars.includes(s[i])))
83
+ i++;
84
+ return s.slice(i);
85
+ }
86
+ export function rstrip(s, chars) {
87
+ let j = s.length;
88
+ while (j > 0 && (chars === undefined ? /\s/u.test(s[j - 1]) : chars.includes(s[j - 1])))
89
+ j--;
90
+ return s.slice(0, j);
91
+ }
92
+ export function partition(s, sep) {
93
+ const i = s.indexOf(sep);
94
+ return i < 0 ? [s, "", ""] : [s.slice(0, i), sep, s.slice(i + sep.length)];
95
+ }
96
+ export function rpartition(s, sep) {
97
+ const i = s.lastIndexOf(sep);
98
+ return i < 0 ? ["", "", s] : [s.slice(0, i), sep, s.slice(i + sep.length)];
99
+ }
100
+ export function isDigit(s) {
101
+ return s.length > 0 && /^\p{Nd}+$/u.test(s);
102
+ }
103
+ export function isUpper(ch) {
104
+ return /\p{Lu}/u.test(ch);
105
+ }
106
+ export function isAlpha(s) {
107
+ return s.length > 0 && /^\p{L}+$/u.test(s);
108
+ }
109
+ export function isSpace(ch) {
110
+ return /\s/u.test(ch);
111
+ }
112
+ export function chars(s) {
113
+ return Array.from(s);
114
+ }
115
+ /** Python's `round()`: ties go to the even neighbour. */
116
+ export function roundHalfEven(x) {
117
+ const f = Math.floor(x);
118
+ const diff = x - f;
119
+ if (diff > 0.5)
120
+ return f + 1;
121
+ if (diff < 0.5)
122
+ return f;
123
+ return f % 2 === 0 ? f : f + 1;
124
+ }
125
+ /** Python's `divmod` for non-negative integers. */
126
+ export function divmod(n, d) {
127
+ return [Math.floor(n / d), n % d];
128
+ }
129
+ /** `str.format` for the small templates in provider profiles: `{ms}`, `{s}`, `{text}`, `{ipa}`. */
130
+ export function format(template, values) {
131
+ return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
132
+ }
133
+ export function escapeXml(text) {
134
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
135
+ }
136
+ /** `len(text.encode("utf-8"))`. */
137
+ export function utf8Length(text) {
138
+ return new TextEncoder().encode(text).length;
139
+ }
140
+ /** `len(text)` in code points, as Python counts. */
141
+ export function codePoints(text) {
142
+ let n = 0;
143
+ for (const _ of text)
144
+ n++;
145
+ return n;
146
+ }
@@ -0,0 +1,3 @@
1
+ /** English verbalization rules, in the order they run. */
2
+ import { Rule } from "./index.js";
3
+ export declare const RULES: Rule[];
@@ -0,0 +1,219 @@
1
+ /** English verbalization rules, in the order they run. */
2
+ import { B, W, escapeRe, findall, fullmatch, group, isDigit, re, rpartition } from "../util.js";
3
+ import { option, registerRules, rule } from "./index.js";
4
+ import { cardinal, decimal, digits, ordinal, year } from "./numbers_en.js";
5
+ const MONTHS = [
6
+ "January", "February", "March", "April", "May", "June", "July", "August",
7
+ "September", "October", "November", "December",
8
+ ];
9
+ const DAY_FIRST_LOCALES = new Set(["en-GB", "en-AU", "en-NZ", "en-IE", "en-IN", "en-ZA"]);
10
+ const dayFirst = (locale) => DAY_FIRST_LOCALES.has(locale);
11
+ function dateWords(y, m, d) {
12
+ if (!(m >= 1 && m <= 12 && d >= 1 && d <= 31))
13
+ return null;
14
+ let yy = y;
15
+ if (yy < 100)
16
+ yy += yy < 70 ? 2000 : 1900;
17
+ return `${MONTHS[m - 1]} ${ordinal(d)}, ${year(yy)}`;
18
+ }
19
+ const int = (s) => parseInt((s ?? "").replace(/,/g, ""), 10);
20
+ // --- dates -----------------------------------------------------------------------
21
+ const dateIso = (m) => dateWords(int(m[1]), int(m[2]), int(m[3]));
22
+ function dateSlash(m, locale) {
23
+ const a = int(m[1]);
24
+ const b = int(m[2]);
25
+ const y = int(m[3]);
26
+ if (dayFirst(locale))
27
+ return dateWords(y, b, a);
28
+ return dateWords(y, a, b);
29
+ }
30
+ const dateDot = (m) => dateWords(int(m[3]), int(m[2]), int(m[1]));
31
+ // --- digit strings ---------------------------------------------------------------
32
+ function digitGroups(m) {
33
+ const raw = m[0];
34
+ const count = (raw.match(/\d/g) ?? []).length;
35
+ if (count < 7 || count > 16)
36
+ return null;
37
+ const groups = raw.split(/[\s.\-()]+/u).filter((g) => g);
38
+ return groups.map(digits).join(", ");
39
+ }
40
+ const longDigits = (m) => digits(m[0]);
41
+ // --- time ----------------------------------------------------------------------------
42
+ function time(m) {
43
+ const hour = m[1];
44
+ const minute = m[2];
45
+ const suffix = group(m, 3);
46
+ if (suffix)
47
+ return null; // "2:30 pm" is read correctly as it stands
48
+ const h = parseInt(hour, 10);
49
+ if (h >= 13)
50
+ return `${h - 12}:${minute} PM`;
51
+ if (h === 0)
52
+ return `12:${minute} AM`;
53
+ if (hour.startsWith("0"))
54
+ return `${h}:${minute} AM`;
55
+ return null;
56
+ }
57
+ // --- currency --------------------------------------------------------------------------
58
+ const CURRENCY = {
59
+ $: ["dollar", "dollars", "cent", "cents"],
60
+ "€": ["euro", "euros", "cent", "cents"],
61
+ "£": ["pound", "pounds", "penny", "pence"],
62
+ "¥": ["yen", "yen", null, null],
63
+ "₺": ["lira", "lira", "kuruş", "kuruş"],
64
+ USD: ["dollar", "dollars", "cent", "cents"],
65
+ EUR: ["euro", "euros", "cent", "cents"],
66
+ GBP: ["pound", "pounds", "penny", "pence"],
67
+ TRY: ["lira", "lira", "kuruş", "kuruş"],
68
+ TL: ["lira", "lira", "kuruş", "kuruş"],
69
+ };
70
+ const MAGNITUDE = {
71
+ k: "thousand", m: "million", bn: "billion", b: "billion", thousand: "thousand", million: "million", billion: "billion",
72
+ };
73
+ function moneyWords(symbol, whole, frac, magnitude) {
74
+ const [one, many, centOne, centMany] = CURRENCY[symbol];
75
+ const amount = int(whole);
76
+ if (magnitude)
77
+ return `${decimal(whole, frac)} ${MAGNITUDE[magnitude.toLowerCase()]} ${many}`;
78
+ if (frac !== undefined && centOne) {
79
+ const cents = parseInt(frac.padEnd(2, "0").slice(0, 2), 10);
80
+ const main = amount === 0 ? "" : `${cardinal(amount)} ${amount === 1 ? one : many}`;
81
+ if (cents === 0)
82
+ return main || `zero ${many}`;
83
+ const tail = `${cardinal(cents)} ${cents === 1 ? centOne : centMany}`;
84
+ return main ? `${main} and ${tail}` : tail;
85
+ }
86
+ if (frac !== undefined)
87
+ return `${decimal(whole, frac)} ${many}`;
88
+ return `${cardinal(amount)} ${amount === 1 ? one : many}`;
89
+ }
90
+ function currencyRange(m) {
91
+ const [, many] = CURRENCY[m[1]];
92
+ return `${cardinal(int(m[2]))} to ${cardinal(int(m[3]))} ${many}`;
93
+ }
94
+ const currencyPrefix = (m) => moneyWords(m[1], m[2], group(m, 3), group(m, 4));
95
+ const currencyCode = (m) => moneyWords(m[3].toUpperCase(), m[1], group(m, 2), undefined);
96
+ // --- percent, units, fractions, ranges ----------------------------------------------------
97
+ const UNITS = {
98
+ "km/h": ["kilometer per hour", "kilometers per hour"],
99
+ km: ["kilometer", "kilometers"],
100
+ cm: ["centimeter", "centimeters"],
101
+ mm: ["millimeter", "millimeters"],
102
+ kg: ["kilogram", "kilograms"],
103
+ mg: ["milligram", "milligrams"],
104
+ lbs: ["pound", "pounds"],
105
+ lb: ["pound", "pounds"],
106
+ oz: ["ounce", "ounces"],
107
+ mph: ["mile per hour", "miles per hour"],
108
+ mi: ["mile", "miles"],
109
+ ft: ["foot", "feet"],
110
+ GB: ["gigabyte", "gigabytes"],
111
+ MB: ["megabyte", "megabytes"],
112
+ KB: ["kilobyte", "kilobytes"],
113
+ TB: ["terabyte", "terabytes"],
114
+ ms: ["millisecond", "milliseconds"],
115
+ min: ["minute", "minutes"],
116
+ hrs: ["hour", "hours"],
117
+ hr: ["hour", "hours"],
118
+ kWh: ["kilowatt hour", "kilowatt hours"],
119
+ kHz: ["kilohertz", "kilohertz"],
120
+ MHz: ["megahertz", "megahertz"],
121
+ GHz: ["gigahertz", "gigahertz"],
122
+ Hz: ["hertz", "hertz"],
123
+ fps: ["frame per second", "frames per second"],
124
+ "°C": ["degree Celsius", "degrees Celsius"],
125
+ "°F": ["degree Fahrenheit", "degrees Fahrenheit"],
126
+ "°": ["degree", "degrees"],
127
+ };
128
+ const UNIT_ALTERNATION = Object.keys(UNITS).sort((a, b) => b.length - a.length).map(escapeRe).join("|");
129
+ function unit(m) {
130
+ const number = m[1];
131
+ const [one, many] = UNITS[m[2]];
132
+ const isOne = number === "1" || number === "1.0";
133
+ return `${number} ${isOne ? one : many}`;
134
+ }
135
+ const FRACTIONS = {
136
+ 2: ["half", "halves"], 3: ["third", "thirds"], 4: ["quarter", "quarters"],
137
+ 5: ["fifth", "fifths"], 8: ["eighth", "eighths"], 10: ["tenth", "tenths"],
138
+ };
139
+ function fraction(m) {
140
+ const num = parseInt(m[1], 10);
141
+ const den = parseInt(m[2], 10);
142
+ if (num >= den)
143
+ return null;
144
+ if (option("fractions", "spoken") === "division")
145
+ return `${cardinal(num)} over ${cardinal(den)}`;
146
+ if (!(den in FRACTIONS))
147
+ return null;
148
+ const [one, many] = FRACTIONS[den];
149
+ return `${cardinal(num)} ${num === 1 ? one : many}`;
150
+ }
151
+ const YEAR_LIKE = re(String.raw `(19|20)\d\d`);
152
+ function range(m) {
153
+ const a = m[1];
154
+ const b = m[2];
155
+ const short = a.replace(/\./g, "").length <= 3 && b.replace(/\./g, "").length <= 3;
156
+ const years = fullmatch(YEAR_LIKE, a) && fullmatch(YEAR_LIKE, b);
157
+ if (!(short || years))
158
+ return null;
159
+ return `${a} to ${b}`;
160
+ }
161
+ // --- years and ordinals -----------------------------------------------------------------------
162
+ const yearInContext = (m) => `${m[1]} ${year(parseInt(m[2], 10))}`;
163
+ function decade(m) {
164
+ const words = year(parseInt(m[1], 10));
165
+ const [head, , last] = rpartition(words, " ");
166
+ const plural = last.endsWith("y") ? last.slice(0, -1) + "ies" : last + "s";
167
+ return (head ? head + " " : "") + plural;
168
+ }
169
+ const ordinalRule = (m) => ordinal(parseInt(m[1], 10));
170
+ // --- alphanumeric identifiers ------------------------------------------------------------------
171
+ const VOWELS = new Set("AEIOUY");
172
+ const RUNS = re("[A-Z]+|\\d+");
173
+ function alnum(m) {
174
+ const token = m[0];
175
+ if (token.replace(/-/g, "").length < 4)
176
+ return null;
177
+ const out = [];
178
+ const runs = findall(RUNS, token);
179
+ for (const run of runs) {
180
+ if (isDigit(run))
181
+ out.push(runs.length === 2 && run.length <= 2 ? cardinal(parseInt(run, 10)) : digits(run));
182
+ else if (run.length >= 3 && Array.from(run).some((c) => VOWELS.has(c)))
183
+ out.push(run);
184
+ else
185
+ out.push(Array.from(run).join(" "));
186
+ }
187
+ return out.join(" ");
188
+ }
189
+ // a separator inside a phone number: space, dash, or a dot that is not followed by whitespace
190
+ const SEP = String.raw `(?:[ \-]|\.(?!\s))`;
191
+ const timeRange = (m) => `${m[1]} to ${m[2]}`;
192
+ const PER = {
193
+ mo: "month", month: "month", yr: "year", year: "year", hr: "hour", hour: "hour",
194
+ min: "minute", minute: "minute", day: "day", week: "week", wk: "week",
195
+ };
196
+ export const RULES = [
197
+ rule("verbalize.date.iso", re(String.raw `${B}(\d{4})-(\d{2})-(\d{2})${B}`), dateIso, "ISO date read as month, day, year"),
198
+ rule("verbalize.date.slash", re(String.raw `${B}(\d{1,2})/(\d{1,2})/(\d{2}|\d{4})${B}`), dateSlash, "numeric date read in the locale's order"),
199
+ rule("verbalize.date.dot", re(String.raw `${B}(\d{1,2})\.(\d{1,2})\.(\d{4})${B}`), dateDot, "dotted date read as day, month, year"),
200
+ rule("verbalize.phone", re(String.raw `(?<![${W}.])(?:\+?\d{1,3}${SEP})?(?:\(\d{2,4}\)[ \-]?|\d{2,4}${SEP})(?:\d{2,4}${SEP}){1,4}\d{2,4}(?![${W}\-]|[.,]\d)`), digitGroups, "digit groups read digit by digit with a pause between groups"),
201
+ rule("verbalize.number_sign", re(String.raw `#(?=\d)`), () => "number ", "number sign read as a word"),
202
+ rule("verbalize.digits.long", re(String.raw `(?<![${W}.,])\d{7,}(?![${W}]|[.,]\d)`), longDigits, "long digit string read digit by digit"),
203
+ rule("verbalize.time.range", re(String.raw `${B}(\d{1,2}:\d{2})\s?[-–]\s?(\d{1,2}:\d{2})${B}`), timeRange, "time range read with 'to'"),
204
+ rule("verbalize.time", re(String.raw `${B}([01]?\d|2[0-3]):([0-5]\d)${B}(?!:)(?:\s?(am|pm|AM|PM|a\.m\.|p\.m\.))?`), time, "24-hour time read as 12-hour"),
205
+ rule("verbalize.currency.range", re(String.raw `([$€£¥₺])\s?(\d[\d,]*)\s?[-–]\s?[$€£¥₺]?\s?(\d[\d,]*)(?![\d]|[.,]\d)`), currencyRange, "currency range read as words"),
206
+ rule("verbalize.currency", re(String.raw `([$€£¥₺])\s?(\d[\d,]*)(?:\.(\d{1,2}))?\s?(k|K|m|M|bn|B|thousand|million|billion)?(?![${W}]|[.,]\d)`), currencyPrefix, "currency amount read as words"),
207
+ rule("verbalize.currency.code", re(String.raw `(?<![${W}.])(\d[\d,]*)(?:\.(\d{1,2}))?\s?(USD|EUR|GBP|TRY|TL)${B}`), currencyCode, "currency code read as words"),
208
+ rule("verbalize.per", re(String.raw `${B}(dollars?|cents?|euros?|pounds?|\d)/(mo|month|yr|year|hr|hour|min|minute|day|week|wk)${B}`), (m) => `${m[1]} per ${PER[m[2]]}`, "slash before a period read as 'per'"),
209
+ rule("verbalize.percent", re(String.raw `(?<![${W}.])(\d+(?:\.\d+)?)\s?%|%\s?(\d+(?:\.\d+)?)`), (m) => `${group(m, 1) ?? group(m, 2)} percent`, "percent sign read as a word"),
210
+ rule("verbalize.unit", re(String.raw `(?<![${W}.])(\d+(?:\.\d+)?)\s?(${UNIT_ALTERNATION})(?![${W}/])`), unit, "unit abbreviation read as a word"),
211
+ rule("verbalize.fraction", re(String.raw `(?<![\d/])([1-9])/([2-9]|10)(?![\d/])`), fraction, "fraction read as words"),
212
+ rule("verbalize.range", re(String.raw `(?<![${W}.,\-/:])(\d+(?:\.\d+)?)\s?[-–]\s?(\d+(?:\.\d+)?)(?![${W}\-/:]|[.,]\d)`), range, "number range read with 'to'"),
213
+ rule("verbalize.year", re(String.raw `${B}(in|In|since|Since|by|from|until|till|before|after|during|circa|year|est\.|©)\s+(1[1-9]\d\d|20\d\d)${B}(?![${W}.,]?\d)`), yearInContext, "year read as a year"),
214
+ rule("verbalize.decade", re(String.raw `${B}(1[1-9]\d0|20\d0)s${B}`), decade, "decade read as a year"),
215
+ rule("verbalize.ordinal", re(String.raw `${B}(\d{1,4})(?:st|nd|rd|th)${B}`), ordinalRule, "ordinal read as words"),
216
+ rule("verbalize.minus", re(String.raw `(?<![${W}.)\-–])[-–](?=\d)`), () => "minus ", "leading minus read as a word"),
217
+ rule("verbalize.alnum", re(String.raw `${B}(?=[A-Z0-9-]*\d)(?=[A-Z0-9-]*[A-Z])[A-Z0-9]+(?:-[A-Z0-9]+)*${B}`), alnum, "identifier spelled out"),
218
+ ];
219
+ registerRules("en", RULES);
@@ -0,0 +1,22 @@
1
+ /** Stage 2: verbalize. Rewrite only the tokens a TTS model is likely to misread.
2
+ *
3
+ * Each language has an ordered list of rules. A rule is a regex and a function
4
+ * that returns the spoken form; it fires only on the pattern, so ordinary prose
5
+ * and ordinary numbers are left for the model, which reads them well.
6
+ * Sentinels from the intent stage are never touched.
7
+ */
8
+ import { Recorder } from "../changes.js";
9
+ import { Options } from "../clean.js";
10
+ import { Match } from "../util.js";
11
+ export interface Rule {
12
+ id: string;
13
+ pattern: RegExp;
14
+ /** (match, locale) -> replacement, or null to leave the match alone */
15
+ render: (m: Match, locale: string) => string | null;
16
+ note: string;
17
+ }
18
+ export declare function rule(id: string, pattern: RegExp, render: Rule["render"], note?: string): Rule;
19
+ /** Read a field of the Options the current verbalize() call runs with. */
20
+ export declare function option<K extends keyof Options>(name: K, fallback: Options[K]): Options[K];
21
+ export declare function registerRules(lang: string, rules: Rule[]): void;
22
+ export declare function verbalize(text: string, locale?: string, recorder?: Recorder, opts?: Partial<Options> | null): string;
@@ -0,0 +1,75 @@
1
+ /** Stage 2: verbalize. Rewrite only the tokens a TTS model is likely to misread.
2
+ *
3
+ * Each language has an ordered list of rules. A rule is a regex and a function
4
+ * that returns the spoken form; it fires only on the pattern, so ordinary prose
5
+ * and ordinary numbers are left for the model, which reads them well.
6
+ * Sentinels from the intent stage are never touched.
7
+ */
8
+ import { Recorder } from "../changes.js";
9
+ import { SENTINEL_CLOSE, SENTINEL_OPEN } from "../intents.js";
10
+ import { finditer, re, sub } from "../util.js";
11
+ const STAGE = "verbalize";
12
+ export function rule(id, pattern, render, note = "") {
13
+ return { id, pattern, render, note };
14
+ }
15
+ const SENTINEL = re(`${SENTINEL_OPEN}\\d+${SENTINEL_CLOSE}`);
16
+ let currentOptions = null;
17
+ /** Read a field of the Options the current verbalize() call runs with. */
18
+ export function option(name, fallback) {
19
+ const o = currentOptions;
20
+ return o && o[name] !== undefined ? o[name] : fallback;
21
+ }
22
+ let rulesEn = null;
23
+ let rulesTr = null;
24
+ export function registerRules(lang, rules) {
25
+ if (lang === "en")
26
+ rulesEn = rules;
27
+ else if (lang === "tr")
28
+ rulesTr = rules;
29
+ }
30
+ function rulesFor(locale) {
31
+ const lang = (locale || "en").split("-")[0].split("_")[0].toLowerCase();
32
+ if (lang === "en")
33
+ return rulesEn ?? [];
34
+ if (lang === "tr")
35
+ return rulesTr ?? [];
36
+ return [];
37
+ }
38
+ export function verbalize(text, locale = "en-US", recorder, opts) {
39
+ const rec = recorder ?? new Recorder();
40
+ const rules = rulesFor(locale);
41
+ if (!rules.length)
42
+ return text;
43
+ const previous = currentOptions;
44
+ currentOptions = opts;
45
+ try {
46
+ return run(text, rules, locale, rec);
47
+ }
48
+ finally {
49
+ currentOptions = previous;
50
+ }
51
+ }
52
+ function run(text, rules, locale, rec) {
53
+ const sentinels = finditer(SENTINEL, text).map((m) => m[0]);
54
+ const parts = text.split(SENTINEL);
55
+ const out = [];
56
+ parts.forEach((segment, i) => {
57
+ out.push(apply(segment, rules, locale, rec));
58
+ if (i < sentinels.length)
59
+ out.push(sentinels[i]);
60
+ });
61
+ return out.join("");
62
+ }
63
+ function apply(segment, rules, locale, rec) {
64
+ let work = segment;
65
+ for (const r of rules) {
66
+ work = sub(r.pattern, work, (m) => {
67
+ const next = r.render(m, locale);
68
+ if (next === null || next === undefined || next === m[0])
69
+ return m[0];
70
+ rec.add(STAGE, r.id, m[0], next, r.note);
71
+ return next;
72
+ });
73
+ }
74
+ return work;
75
+ }
@@ -0,0 +1,8 @@
1
+ /** English number words: cardinals, ordinals, years, digit strings. */
2
+ export declare function cardinal(n: number): string;
3
+ export declare function ordinal(n: number): string;
4
+ /** Spoken year: 1999 -> nineteen ninety-nine, 2005 -> two thousand five, 2026 -> twenty twenty-six. */
5
+ export declare function year(n: number): string;
6
+ /** Each digit as a word; non-digits pass through as their spoken form where known. */
7
+ export declare function digits(s: string): string;
8
+ export declare function decimal(whole: string, frac: string | null | undefined): string;
@@ -0,0 +1,83 @@
1
+ /** English number words: cardinals, ordinals, years, digit strings. */
2
+ import { divmod, rpartition } from "../util.js";
3
+ const ONES = [
4
+ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
5
+ "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen",
6
+ ];
7
+ const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
8
+ const SCALES = [[1e9, "billion"], [1e6, "million"], [1e3, "thousand"], [100, "hundred"]];
9
+ const ORDINAL_IRREGULAR = {
10
+ one: "first", two: "second", three: "third", five: "fifth", eight: "eighth", nine: "ninth", twelve: "twelfth",
11
+ };
12
+ export function cardinal(n) {
13
+ if (n < 0)
14
+ return "minus " + cardinal(-n);
15
+ if (n < 20)
16
+ return ONES[n];
17
+ if (n < 100) {
18
+ const [tens, ones] = divmod(n, 10);
19
+ return TENS[tens] + (ones ? "-" + ONES[ones] : "");
20
+ }
21
+ for (const [value, name] of SCALES) {
22
+ if (n >= value) {
23
+ const [head, rest] = divmod(n, value);
24
+ let words = cardinal(head) + " " + name;
25
+ if (rest)
26
+ words += " " + cardinal(rest);
27
+ return words;
28
+ }
29
+ }
30
+ return String(n);
31
+ }
32
+ export function ordinal(n) {
33
+ const words = cardinal(n);
34
+ let [head, sep, last] = words.includes(" ") ? rpartition(words, " ") : ["", "", words];
35
+ if (last.includes("-")) {
36
+ const [tens, , ones] = rpartition(last, "-");
37
+ last = tens + "-" + ordinalWord(ones);
38
+ }
39
+ else {
40
+ last = ordinalWord(last);
41
+ }
42
+ return head + sep + last;
43
+ }
44
+ function ordinalWord(word) {
45
+ if (word in ORDINAL_IRREGULAR)
46
+ return ORDINAL_IRREGULAR[word];
47
+ if (word.endsWith("y"))
48
+ return word.slice(0, -1) + "ieth";
49
+ return word + "th";
50
+ }
51
+ /** Spoken year: 1999 -> nineteen ninety-nine, 2005 -> two thousand five, 2026 -> twenty twenty-six. */
52
+ export function year(n) {
53
+ if (n < 1000 || n > 9999)
54
+ return cardinal(n);
55
+ const [high, low] = divmod(n, 100);
56
+ if (low === 0) {
57
+ if (high % 10 === 0)
58
+ return cardinal(n); // 2000, 3000
59
+ return cardinal(high) + " hundred"; // 1900, 2100
60
+ }
61
+ if (n >= 2000 && n <= 2009)
62
+ return "two thousand " + cardinal(low);
63
+ if (low < 10)
64
+ return cardinal(high) + " oh " + cardinal(low); // 1905 -> nineteen oh five
65
+ return cardinal(high) + " " + cardinal(low);
66
+ }
67
+ /** Each digit as a word; non-digits pass through as their spoken form where known. */
68
+ export function digits(s) {
69
+ const out = [];
70
+ for (const ch of s) {
71
+ if (/\d/.test(ch))
72
+ out.push(ONES[parseInt(ch, 10)]);
73
+ else if (ch === "+")
74
+ out.push("plus");
75
+ }
76
+ return out.join(" ");
77
+ }
78
+ export function decimal(whole, frac) {
79
+ let text = cardinal(parseInt(whole.replace(/,/g, ""), 10));
80
+ if (frac)
81
+ text += " point " + Array.from(frac).map((c) => ONES[parseInt(c, 10)]).join(" ");
82
+ return text;
83
+ }
@@ -0,0 +1,24 @@
1
+ /** Turkish number words and suffix helpers (vowel harmony, consonant softening). */
2
+ export declare function cardinal(n: number): string;
3
+ export declare function lastVowel(word: string): string;
4
+ /** Four-way harmony vowel (ı i u ü) for the last vowel of `word`. */
5
+ export declare function harmonyI(word: string): string;
6
+ /** Two-way harmony vowel (a e). */
7
+ export declare function harmonyA(word: string): string;
8
+ /** Final consonant softening before a vowel-initial suffix: dört -> dörd, çeyrek -> çeyreğ. */
9
+ export declare function soften(word: string): string;
10
+ /** Join a spoken word and the suffix the writer attached to the numeral or abbreviation.
11
+ *
12
+ * The writer harmonised the suffix against what they wrote (3'te -> üçte works
13
+ * as is), but a buffer consonant may be wrong for the spoken word: yarım + sı
14
+ * -> yarımı, kilometre + i -> kilometreyi.
15
+ */
16
+ export declare function attach(word: string, suffix: string): string;
17
+ /** Re-harmonise a suffix written against an abbreviation (TL'ye, 1/4'ü) for the spoken word (liraya, çeyreği). */
18
+ export declare function reharmonize(word: string, suffix: string): string;
19
+ /** üçüncü, dördüncü, yirmi birinci. */
20
+ export declare function ordinal(n: number): string;
21
+ /** üç -> üçte, dört -> dörtte, on -> onda, beş -> beşte. */
22
+ export declare function locative(word: string): string;
23
+ export declare function digits(s: string): string;
24
+ export declare function decimal(whole: string, frac: string | null | undefined): string;