speechready 0.0.1 → 0.1.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/README.md +90 -3
- package/dist/changes.d.ts +14 -0
- package/dist/changes.js +10 -0
- package/dist/chunk.d.ts +32 -0
- package/dist/chunk.js +195 -0
- package/dist/clean.d.ts +41 -0
- package/dist/clean.js +323 -0
- package/dist/compile.d.ts +5 -0
- package/dist/compile.js +116 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +15 -0
- package/dist/intents.d.ts +34 -0
- package/dist/intents.js +92 -0
- package/dist/lexicon.d.ts +53 -0
- package/dist/lexicon.js +131 -0
- package/dist/pipeline.d.ts +34 -0
- package/dist/pipeline.js +49 -0
- package/dist/profiles/data.d.ts +1 -0
- package/dist/profiles/data.js +411 -0
- package/dist/profiles/index.d.ts +35 -0
- package/dist/profiles/index.js +72 -0
- package/dist/util.d.ts +53 -0
- package/dist/util.js +146 -0
- package/dist/verbalize/en.d.ts +3 -0
- package/dist/verbalize/en.js +219 -0
- package/dist/verbalize/index.d.ts +22 -0
- package/dist/verbalize/index.js +75 -0
- package/dist/verbalize/numbers_en.d.ts +8 -0
- package/dist/verbalize/numbers_en.js +83 -0
- package/dist/verbalize/numbers_tr.d.ts +24 -0
- package/dist/verbalize/numbers_tr.js +134 -0
- package/dist/verbalize/tr.d.ts +9 -0
- package/dist/verbalize/tr.js +204 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +24 -4
- package/index.js +0 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Provider profiles: data files that say what each TTS model accepts.
|
|
2
|
+
*
|
|
3
|
+
* The YAML files live in the repository's `spec/profiles`. The build step turns
|
|
4
|
+
* them into `data.ts` so the package needs no YAML parser at runtime. Each
|
|
5
|
+
* profile states the intent renderings, the request limit, the escaping rules
|
|
6
|
+
* and whether unknown bracket tags pass through.
|
|
7
|
+
*/
|
|
8
|
+
import { PROFILE_DATA } from "./data.js";
|
|
9
|
+
function fromDict(data) {
|
|
10
|
+
const limit = (data.limit ?? {});
|
|
11
|
+
const lexicon = (data.lexicon ?? {});
|
|
12
|
+
const profile = {
|
|
13
|
+
id: String(data.id),
|
|
14
|
+
provider: String(data.provider),
|
|
15
|
+
models: (data.models ?? []).map(String),
|
|
16
|
+
syntax: data.syntax ?? "plain",
|
|
17
|
+
escape: data.escape ?? "none",
|
|
18
|
+
limitUnit: limit.unit ?? "chars",
|
|
19
|
+
limitMax: limit.max ?? null,
|
|
20
|
+
limitPerModel: { ...(limit.per_model ?? {}) },
|
|
21
|
+
intents: { ...(data.intents ?? {}) },
|
|
22
|
+
audioTags: Boolean(data.audio_tags ?? false),
|
|
23
|
+
lexiconFormat: String(lexicon.format ?? "none"),
|
|
24
|
+
lexiconAlphabet: String(lexicon.alphabet ?? "ipa"),
|
|
25
|
+
default: Boolean(data.default ?? false),
|
|
26
|
+
notes: data.notes ?? "",
|
|
27
|
+
sources: (data.sources ?? []).map(String),
|
|
28
|
+
verified: String(data.verified ?? ""),
|
|
29
|
+
raw: data,
|
|
30
|
+
limitFor(model) {
|
|
31
|
+
if (model && model in this.limitPerModel)
|
|
32
|
+
return this.limitPerModel[model];
|
|
33
|
+
return this.limitMax;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return profile;
|
|
37
|
+
}
|
|
38
|
+
let cache = null;
|
|
39
|
+
function all() {
|
|
40
|
+
if (!cache)
|
|
41
|
+
cache = PROFILE_DATA.map(fromDict);
|
|
42
|
+
return cache;
|
|
43
|
+
}
|
|
44
|
+
export function listProfiles() {
|
|
45
|
+
return [...all()];
|
|
46
|
+
}
|
|
47
|
+
export function loadProfile(profileId) {
|
|
48
|
+
for (const p of all())
|
|
49
|
+
if (p.id === profileId)
|
|
50
|
+
return p;
|
|
51
|
+
throw new Error(`unknown profile ${JSON.stringify(profileId)}; known: ${all().map((p) => p.id).join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
/** Pick the profile for a provider and model. No provider = the `plain` profile. */
|
|
54
|
+
export function resolveProfile(provider, model) {
|
|
55
|
+
if (!provider && !model)
|
|
56
|
+
return loadProfile("plain");
|
|
57
|
+
const matches = all().filter((p) => !provider || p.provider === provider);
|
|
58
|
+
if (model) {
|
|
59
|
+
for (const p of matches)
|
|
60
|
+
if (p.models.includes(model))
|
|
61
|
+
return p;
|
|
62
|
+
if (!provider)
|
|
63
|
+
throw new Error(`no profile lists model ${JSON.stringify(model)}`);
|
|
64
|
+
}
|
|
65
|
+
for (const p of matches)
|
|
66
|
+
if (p.default)
|
|
67
|
+
return p;
|
|
68
|
+
if (matches.length)
|
|
69
|
+
return matches[0];
|
|
70
|
+
const known = [...new Set(all().map((p) => p.provider))].sort();
|
|
71
|
+
throw new Error(`no profile for provider ${JSON.stringify(provider)}; known providers: ${known.join(", ")}`);
|
|
72
|
+
}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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 declare const W = "\\p{L}\\p{N}_";
|
|
10
|
+
/** Python's `\b` under Unicode. */
|
|
11
|
+
export declare const B = "(?:(?<=[\\p{L}\\p{N}_])(?![\\p{L}\\p{N}_])|(?<![\\p{L}\\p{N}_])(?=[\\p{L}\\p{N}_]))";
|
|
12
|
+
/** Compile a pattern with the `u` flag (plus `g`, which every helper here expects). */
|
|
13
|
+
export declare function re(source: string, flags?: string): RegExp;
|
|
14
|
+
/** A match as the Python-style callbacks see it: `m[0]`, `m[1]`..., `m.index`, `m.groups`. */
|
|
15
|
+
export type Match = RegExpExecArray;
|
|
16
|
+
/** `pattern.sub(fn, text)`: every match replaced by `fn(match)`. */
|
|
17
|
+
export declare function sub(pattern: RegExp, text: string, fn: (m: Match) => string): string;
|
|
18
|
+
/** `pattern.sub(repl, text)` with a plain string replacement (no `$` expansion). */
|
|
19
|
+
export declare function subs(pattern: RegExp, text: string, repl: string): string;
|
|
20
|
+
/** First match anywhere, or null. Safe on global patterns. */
|
|
21
|
+
export declare function search(pattern: RegExp, text: string): Match | null;
|
|
22
|
+
/** `pattern.match(text)`: a match anchored at the start, or null. */
|
|
23
|
+
export declare function matchStart(pattern: RegExp, text: string): Match | null;
|
|
24
|
+
/** `re.fullmatch(pattern, text)`. */
|
|
25
|
+
export declare function fullmatch(pattern: RegExp, text: string): Match | null;
|
|
26
|
+
export declare function test(pattern: RegExp, text: string): boolean;
|
|
27
|
+
/** All matches, in order. */
|
|
28
|
+
export declare function finditer(pattern: RegExp, text: string): Match[];
|
|
29
|
+
export declare function findall(pattern: RegExp, text: string): string[];
|
|
30
|
+
/** `re.escape`: escape what is special in a pattern (letters are left alone, which the `u` flag requires). */
|
|
31
|
+
export declare function escapeRe(s: string): string;
|
|
32
|
+
export declare function group(m: Match, i: number): string | undefined;
|
|
33
|
+
export declare function strip(s: string, chars?: string): string;
|
|
34
|
+
export declare function lstrip(s: string, chars?: string): string;
|
|
35
|
+
export declare function rstrip(s: string, chars?: string): string;
|
|
36
|
+
export declare function partition(s: string, sep: string): [string, string, string];
|
|
37
|
+
export declare function rpartition(s: string, sep: string): [string, string, string];
|
|
38
|
+
export declare function isDigit(s: string): boolean;
|
|
39
|
+
export declare function isUpper(ch: string): boolean;
|
|
40
|
+
export declare function isAlpha(s: string): boolean;
|
|
41
|
+
export declare function isSpace(ch: string): boolean;
|
|
42
|
+
export declare function chars(s: string): string[];
|
|
43
|
+
/** Python's `round()`: ties go to the even neighbour. */
|
|
44
|
+
export declare function roundHalfEven(x: number): number;
|
|
45
|
+
/** Python's `divmod` for non-negative integers. */
|
|
46
|
+
export declare function divmod(n: number, d: number): [number, number];
|
|
47
|
+
/** `str.format` for the small templates in provider profiles: `{ms}`, `{s}`, `{text}`, `{ipa}`. */
|
|
48
|
+
export declare function format(template: string, values: Record<string, string | number>): string;
|
|
49
|
+
export declare function escapeXml(text: string): string;
|
|
50
|
+
/** `len(text.encode("utf-8"))`. */
|
|
51
|
+
export declare function utf8Length(text: string): number;
|
|
52
|
+
/** `len(text)` in code points, as Python counts. */
|
|
53
|
+
export declare function codePoints(text: string): number;
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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,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;
|