speechready 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +91 -2
- 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 +29 -4
package/dist/clean.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/** Stage 1: clean. Turn markdown, code, emoji, URLs and invisible Unicode into plain prose.
|
|
2
|
+
*
|
|
3
|
+
* Block structure is preserved as one block per line: headings, paragraphs,
|
|
4
|
+
* list items and table rows each become a line, and a block that ends without
|
|
5
|
+
* terminal punctuation gets a full stop so the model pauses after it. Hard-wrapped
|
|
6
|
+
* lines inside a paragraph are joined with a space.
|
|
7
|
+
*/
|
|
8
|
+
import { Recorder } from "./changes.js";
|
|
9
|
+
import { SENTINEL_CLOSE } from "./intents.js";
|
|
10
|
+
import { W, escapeRe, isDigit, matchStart, re, rstrip, sub, test } from "./util.js";
|
|
11
|
+
const STAGE = "clean";
|
|
12
|
+
export const DEFAULT_OPTIONS = Object.freeze({
|
|
13
|
+
code_blocks: "drop",
|
|
14
|
+
code_block_mention: null,
|
|
15
|
+
tables: "rows",
|
|
16
|
+
urls: "domain",
|
|
17
|
+
emails: "keep",
|
|
18
|
+
emoji: "drop",
|
|
19
|
+
emphasis: "strip",
|
|
20
|
+
list_numbers: "keep",
|
|
21
|
+
strip_tags: ["think", "thinking", "reasoning"],
|
|
22
|
+
terminal_punctuation: ".",
|
|
23
|
+
html: "strip",
|
|
24
|
+
fractions: "spoken",
|
|
25
|
+
foreign_acronyms: true,
|
|
26
|
+
extra: {},
|
|
27
|
+
});
|
|
28
|
+
/** Fill in defaults. Unknown keys throw, so a camelCase typo is not silently ignored. */
|
|
29
|
+
export function options(partial) {
|
|
30
|
+
if (!partial)
|
|
31
|
+
return DEFAULT_OPTIONS;
|
|
32
|
+
for (const key of Object.keys(partial)) {
|
|
33
|
+
if (!(key in DEFAULT_OPTIONS)) {
|
|
34
|
+
throw new TypeError(`unknown option ${JSON.stringify(key)}; options use the same snake_case names as the Python package: ${Object.keys(DEFAULT_OPTIONS).join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...DEFAULT_OPTIONS, ...partial };
|
|
38
|
+
}
|
|
39
|
+
// what `code_blocks: "mention"` says in place of a fenced block, by language
|
|
40
|
+
const MENTION = { en: "See the code block.", tr: "Kod bloğuna bakın." };
|
|
41
|
+
const TERMINAL = ".!?…:;,";
|
|
42
|
+
const CLOSERS = "\"'”’)]»";
|
|
43
|
+
const ZERO_WIDTH = re("[]");
|
|
44
|
+
const SPACES = re("[ ]");
|
|
45
|
+
const CONTROL = re("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]");
|
|
46
|
+
const FENCE = re(String.raw `^\s*(${"```"}|~~~)`);
|
|
47
|
+
const HEADING = re(String.raw `^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$`);
|
|
48
|
+
const SETEXT = re(String.raw `^\s{0,3}(=+|-+)\s*$`);
|
|
49
|
+
const HR = re(String.raw `^\s{0,3}([-*_])(\s*\1){2,}\s*$`);
|
|
50
|
+
const LIST = re(String.raw `^(\s*)([-*+•]|\d{1,3}[.)])\s+(.*)$`);
|
|
51
|
+
const TASK = re(String.raw `^\[( |x|X)\]\s+`);
|
|
52
|
+
const QUOTE = re(String.raw `^\s{0,3}>\s?`);
|
|
53
|
+
const TABLE_SEP = re(String.raw `^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$`);
|
|
54
|
+
const TABLE_ROW = re(String.raw `^\s*\|.*\|\s*$`);
|
|
55
|
+
const IMAGE = re(String.raw `!\[([^\]]*)\]\([^)]*\)`);
|
|
56
|
+
const LINK = re(String.raw `\[([^\]]+)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)`);
|
|
57
|
+
const AUTOLINK = re(String.raw `<((?:https?|mailto):[^>\s]+)>`);
|
|
58
|
+
const REF_LINK = re(String.raw `\[([^\]]+)\]\[[^\]]*\]`);
|
|
59
|
+
const CODE_SPAN = re(String.raw `(${"`"}+)(.+?)\1`);
|
|
60
|
+
const BOLD = re(String.raw `(\*\*|__)(?=\S)(.+?)(?<=\S)\1`);
|
|
61
|
+
const ITALIC = re(String.raw `(?<![${W}*])(\*|_)(?=\S)(.+?)(?<=\S)\1(?![${W}*])`);
|
|
62
|
+
const STRIKE = re(String.raw `~~(?=\S)(.+?)(?<=\S)~~`);
|
|
63
|
+
const ESCAPE = re(String.raw `\\([\\${"`"}*_{}\[\]()#+\-.!>~|])`);
|
|
64
|
+
const HTML_TAG = re(String.raw `</?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?/?>`);
|
|
65
|
+
const URL = re(String.raw `(?<![${W}@/])(?:https?://|www\.)([^\s<>()"']+)`);
|
|
66
|
+
const EMAIL = re(String.raw `[${W}.+-]+@[${W}-]+(?:\.[${W}-]+)+`);
|
|
67
|
+
const FOOTNOTE = re(String.raw `\[\^[^\]]+\]`);
|
|
68
|
+
// a backslash-escaped marker is swapped for a private-use character while the
|
|
69
|
+
// markdown rules run, so it can never be read as markdown, and swapped back after
|
|
70
|
+
const ESCAPABLE = "\\`*_{}[]()#+-.!>~|";
|
|
71
|
+
const ESC_TO_PUA = new Map(Array.from(ESCAPABLE).map((c, i) => [c, String.fromCharCode(0xe100 + i)]));
|
|
72
|
+
const PUA_TO_ESC = new Map(Array.from(ESC_TO_PUA.entries()).map(([k, v]) => [v, k]));
|
|
73
|
+
const PUA_RANGE = re("[-]");
|
|
74
|
+
// Emoji and pictographs: symbols (So), variation selectors, ZWJ, skin tones, keycaps, flags.
|
|
75
|
+
const EMOJI = re("(?:[\\u{1F000}-\\u{1FAFF}\\u2600-\\u27BF\\u2B00-\\u2BFF\\u{1F1E6}-\\u{1F1FF}\\uFE0F\\u200D\\u20E3\\u{1F3FB}-\\u{1F3FF}]|[\\u2190-\\u21FF])+");
|
|
76
|
+
// Other pictographic symbols in the U+2000-U+32FF blocks, except letterlike symbols (U+2100-U+214F).
|
|
77
|
+
const SYMBOLS = re("(?:(?=[\\u2000-\\u20FF\\u2150-\\u32FF])\\p{So})+");
|
|
78
|
+
/** Run the clean stage on `text`; returns the cleaned text and records changes.
|
|
79
|
+
*
|
|
80
|
+
* `locale` only picks the sentence that `{code_blocks: "mention"}` speaks.
|
|
81
|
+
*/
|
|
82
|
+
export function clean(text, opts, recorder, locale = "en-US") {
|
|
83
|
+
const o = options(opts);
|
|
84
|
+
const rec = recorder ?? new Recorder();
|
|
85
|
+
let work = unicode(text, rec);
|
|
86
|
+
work = stripReasoning(work, o, rec);
|
|
87
|
+
const blocks = splitBlocks(work, o, rec, locale);
|
|
88
|
+
let lines = blocks.map((b) => inline(b, o, rec));
|
|
89
|
+
lines = lines.filter((l) => l.trim()).map((l) => terminate(l, o, rec));
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
// --- character level ---------------------------------------------------------
|
|
93
|
+
function unicode(text, rec) {
|
|
94
|
+
let out = text.normalize("NFC");
|
|
95
|
+
out = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
96
|
+
out = sub(ZERO_WIDTH, out, () => "");
|
|
97
|
+
out = sub(SPACES, out, () => " ");
|
|
98
|
+
out = sub(CONTROL, out, () => "");
|
|
99
|
+
if (out !== text)
|
|
100
|
+
rec.add(STAGE, "clean.unicode.invisible", text, out, "removed invisible or non-standard whitespace characters");
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function stripReasoning(text, o, rec) {
|
|
104
|
+
let work = text;
|
|
105
|
+
for (const tag of o.strip_tags) {
|
|
106
|
+
const pattern = re(`<${escapeRe(tag)}${B_WORD}[^>]*>[\\s\\S]*?</${escapeRe(tag)}\\s*>`, "i");
|
|
107
|
+
const next = sub(pattern, work, () => "");
|
|
108
|
+
if (next !== work) {
|
|
109
|
+
rec.add(STAGE, `clean.tags.${tag}`, work, next, `dropped <${tag}> block`);
|
|
110
|
+
work = next;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return work;
|
|
114
|
+
}
|
|
115
|
+
const B_WORD = `(?![${W}])`; // `\b` right after a tag name
|
|
116
|
+
// --- block level ---------------------------------------------------------------
|
|
117
|
+
/** Split markdown into blocks: one string per heading, paragraph, list item, table row. */
|
|
118
|
+
function splitBlocks(text, o, rec, locale) {
|
|
119
|
+
const blocks = [];
|
|
120
|
+
let para = [];
|
|
121
|
+
let inFence = false;
|
|
122
|
+
let lastWasList = false;
|
|
123
|
+
let fenceLines = [];
|
|
124
|
+
const lines = text.split("\n");
|
|
125
|
+
const flush = () => {
|
|
126
|
+
if (para.length) {
|
|
127
|
+
blocks.push(para.map((s) => s.trim()).join(" "));
|
|
128
|
+
para = [];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
for (let i = 0; i < lines.length; i++) {
|
|
132
|
+
const raw = lines[i];
|
|
133
|
+
let line = raw;
|
|
134
|
+
if (matchStart(FENCE, line)) {
|
|
135
|
+
if (inFence) {
|
|
136
|
+
inFence = false;
|
|
137
|
+
const body = fenceLines.join("\n");
|
|
138
|
+
if (o.code_blocks === "keep") {
|
|
139
|
+
blocks.push(...fenceLines.filter((s) => s.trim()));
|
|
140
|
+
rec.add(STAGE, "clean.markdown.code_block", "```\n" + body + "\n```", body, "kept code block as plain lines");
|
|
141
|
+
}
|
|
142
|
+
else if (o.code_blocks === "mention") {
|
|
143
|
+
const said = o.code_block_mention || MENTION[locale.split("-")[0].toLowerCase()] || MENTION.en;
|
|
144
|
+
blocks.push(said);
|
|
145
|
+
rec.add(STAGE, "clean.markdown.code_block", "```\n" + body + "\n```", said, "code block replaced by a mention");
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
rec.add(STAGE, "clean.markdown.code_block", "```\n" + body + "\n```", "", "dropped code block");
|
|
149
|
+
}
|
|
150
|
+
fenceLines = [];
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
flush();
|
|
154
|
+
inFence = true;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (inFence) {
|
|
159
|
+
fenceLines.push(line);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const quoted = matchStart(QUOTE, line);
|
|
163
|
+
if (quoted) {
|
|
164
|
+
line = line.slice(quoted[0].length);
|
|
165
|
+
rec.add(STAGE, "clean.markdown.blockquote", raw, line, "removed blockquote marker");
|
|
166
|
+
}
|
|
167
|
+
if (!line.trim()) {
|
|
168
|
+
flush();
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (matchStart(HR, line)) {
|
|
172
|
+
flush();
|
|
173
|
+
rec.add(STAGE, "clean.markdown.rule", line, "", "dropped horizontal rule");
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (matchStart(SETEXT, line) && para.length) {
|
|
177
|
+
// underline under a paragraph line = setext heading
|
|
178
|
+
const heading = para.map((s) => s.trim()).join(" ");
|
|
179
|
+
para = [];
|
|
180
|
+
blocks.push(heading);
|
|
181
|
+
rec.add(STAGE, "clean.markdown.heading", `${heading}\n${line}`, heading, "setext heading to plain line");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const h = matchStart(HEADING, line);
|
|
185
|
+
if (h) {
|
|
186
|
+
flush();
|
|
187
|
+
blocks.push(h[2]);
|
|
188
|
+
rec.add(STAGE, "clean.markdown.heading", line, h[2], "heading marker removed");
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (matchStart(TABLE_SEP, line)) {
|
|
192
|
+
flush();
|
|
193
|
+
rec.add(STAGE, "clean.markdown.table", line, "", "dropped table separator row");
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (matchStart(TABLE_ROW, line)) {
|
|
197
|
+
flush();
|
|
198
|
+
if (o.tables === "drop") {
|
|
199
|
+
rec.add(STAGE, "clean.markdown.table", line, "", "dropped table row");
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const inner = line.trim().replace(/^\|+/, "").replace(/\|+$/, "");
|
|
203
|
+
const cells = inner.split("|").map((c) => c.trim());
|
|
204
|
+
const row = cells.filter((c) => c).join(", ");
|
|
205
|
+
blocks.push(row);
|
|
206
|
+
rec.add(STAGE, "clean.markdown.table", line, row, "table row read as a comma list");
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let li = matchStart(LIST, line);
|
|
210
|
+
if (li && isDigit(li[2].slice(0, -1)) && li[2].slice(0, -1) !== "1" && !lastWasList) {
|
|
211
|
+
// "3. madde" at the start of a text is an ordinal, not a list that begins at three:
|
|
212
|
+
// a numbered item counts as a list only when it starts at 1, continues a list,
|
|
213
|
+
// or is followed by another numbered item.
|
|
214
|
+
const nxt = lines.slice(i + 1).find((l) => l.trim()) ?? "";
|
|
215
|
+
const m2 = matchStart(LIST, nxt);
|
|
216
|
+
if (!(m2 && isDigit(m2[2].slice(0, -1))))
|
|
217
|
+
li = null;
|
|
218
|
+
}
|
|
219
|
+
if (li) {
|
|
220
|
+
flush();
|
|
221
|
+
lastWasList = true;
|
|
222
|
+
let item = li[3];
|
|
223
|
+
const task = matchStart(TASK, item);
|
|
224
|
+
if (task)
|
|
225
|
+
item = item.slice(task[0].length);
|
|
226
|
+
const marker = li[2];
|
|
227
|
+
if (isDigit(marker.slice(0, -1)) && o.list_numbers === "keep") {
|
|
228
|
+
item = `${marker.slice(0, -1)}. ${item}`;
|
|
229
|
+
rec.add(STAGE, "clean.markdown.list", line, item, "numbered item kept with its number");
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
rec.add(STAGE, "clean.markdown.list", line, item, "list marker removed");
|
|
233
|
+
}
|
|
234
|
+
blocks.push(item);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
para.push(line);
|
|
238
|
+
lastWasList = false;
|
|
239
|
+
}
|
|
240
|
+
flush();
|
|
241
|
+
if (inFence && fenceLines.length) {
|
|
242
|
+
// unterminated fence: treat as dropped code
|
|
243
|
+
rec.add(STAGE, "clean.markdown.code_block", fenceLines.join("\n"), "", "dropped unterminated code block");
|
|
244
|
+
}
|
|
245
|
+
return blocks;
|
|
246
|
+
}
|
|
247
|
+
// --- inline level ---------------------------------------------------------------
|
|
248
|
+
function inline(block, o, rec) {
|
|
249
|
+
let text = block;
|
|
250
|
+
const apply = (pattern, repl, rule, note) => {
|
|
251
|
+
const next = sub(pattern, text, repl);
|
|
252
|
+
if (next !== text) {
|
|
253
|
+
rec.add(STAGE, rule, text, next, note);
|
|
254
|
+
text = next;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
// protect backslash escapes so an escaped marker is never read as markdown
|
|
258
|
+
text = sub(ESCAPE, text, (m) => ESC_TO_PUA.get(m[1]) ?? m[0]);
|
|
259
|
+
apply(IMAGE, (m) => m[1], "clean.markdown.image", "image replaced by its alt text");
|
|
260
|
+
apply(LINK, (m) => m[1], "clean.markdown.link", "link replaced by its text");
|
|
261
|
+
apply(REF_LINK, (m) => m[1], "clean.markdown.link", "reference link replaced by its text");
|
|
262
|
+
apply(AUTOLINK, (m) => m[1], "clean.markdown.link", "autolink brackets removed");
|
|
263
|
+
apply(FOOTNOTE, () => "", "clean.markdown.footnote", "footnote marker removed");
|
|
264
|
+
apply(CODE_SPAN, (m) => m[2], "clean.markdown.code_span", "inline code backticks removed");
|
|
265
|
+
apply(STRIKE, (m) => m[1], "clean.markdown.emphasis", "strikethrough markers removed");
|
|
266
|
+
if (o.emphasis === "intent") {
|
|
267
|
+
apply(BOLD, (m) => `[em ${m[2]}]`, "clean.markdown.emphasis", "bold turned into an emphasis intent");
|
|
268
|
+
apply(ITALIC, (m) => `[em ${m[2]}]`, "clean.markdown.emphasis", "italic turned into an emphasis intent");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
apply(BOLD, (m) => m[2], "clean.markdown.emphasis", "bold markers removed");
|
|
272
|
+
apply(ITALIC, (m) => m[2], "clean.markdown.emphasis", "italic markers removed");
|
|
273
|
+
}
|
|
274
|
+
if (test(PUA_RANGE, text)) {
|
|
275
|
+
const next = sub(PUA_RANGE, text, (m) => PUA_TO_ESC.get(m[0]) ?? m[0]);
|
|
276
|
+
rec.add(STAGE, "clean.markdown.escape", block, next, "backslash escape removed");
|
|
277
|
+
text = next;
|
|
278
|
+
}
|
|
279
|
+
if (o.html === "strip")
|
|
280
|
+
apply(HTML_TAG, () => "", "clean.html.tag", "HTML tag removed");
|
|
281
|
+
if (o.emails === "drop")
|
|
282
|
+
apply(EMAIL, () => "", "clean.email", "email address dropped");
|
|
283
|
+
if (o.urls === "domain")
|
|
284
|
+
apply(URL, domainOf, "clean.url", "URL reduced to its domain");
|
|
285
|
+
else if (o.urls === "drop")
|
|
286
|
+
apply(URL, () => "", "clean.url", "URL dropped");
|
|
287
|
+
if (o.emoji === "drop") {
|
|
288
|
+
apply(EMOJI, () => "", "clean.emoji", "emoji removed");
|
|
289
|
+
apply(SYMBOLS, () => "", "clean.emoji", "pictographic symbol removed");
|
|
290
|
+
}
|
|
291
|
+
text = whitespace(text);
|
|
292
|
+
return text;
|
|
293
|
+
}
|
|
294
|
+
function domainOf(m) {
|
|
295
|
+
const rest = m[1];
|
|
296
|
+
let host = rest.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0];
|
|
297
|
+
const at = host.split("@");
|
|
298
|
+
host = at[at.length - 1].split(":")[0];
|
|
299
|
+
return host;
|
|
300
|
+
}
|
|
301
|
+
function whitespace(text) {
|
|
302
|
+
let t = text.replace(/[ \t]+/g, " ");
|
|
303
|
+
t = t.replace(/ ([,.;:!?])(?!\d)/gu, "$1"); // "word ." -> "word."
|
|
304
|
+
t = t.replace(/\( /g, "(");
|
|
305
|
+
t = t.replace(/ \)/g, ")");
|
|
306
|
+
return t.trim();
|
|
307
|
+
}
|
|
308
|
+
/** A block that ends without punctuation gets a full stop so the model pauses after it. */
|
|
309
|
+
function terminate(line, o, rec) {
|
|
310
|
+
if (!o.terminal_punctuation)
|
|
311
|
+
return line;
|
|
312
|
+
const stripped = rstrip(line, CLOSERS + " ");
|
|
313
|
+
if (!stripped)
|
|
314
|
+
return line;
|
|
315
|
+
const last = stripped[stripped.length - 1];
|
|
316
|
+
if (TERMINAL.includes(last) || last === SENTINEL_CLOSE)
|
|
317
|
+
return line;
|
|
318
|
+
if ("-–—".includes(last))
|
|
319
|
+
return line;
|
|
320
|
+
const next = line + o.terminal_punctuation;
|
|
321
|
+
rec.add(STAGE, "clean.block.terminal", line, next, "added a full stop at the end of a block");
|
|
322
|
+
return next;
|
|
323
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Stage 3: compile. Render pacing intents in the target provider's syntax and escape text. */
|
|
2
|
+
import { Recorder } from "./changes.js";
|
|
3
|
+
import { Intent } from "./intents.js";
|
|
4
|
+
import { Profile } from "./profiles/index.js";
|
|
5
|
+
export declare function compileText(text: string, intents: Intent[], profile: Profile, recorder?: Recorder): string;
|
package/dist/compile.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Stage 3: compile. Render pacing intents in the target provider's syntax and escape text. */
|
|
2
|
+
import { Recorder } from "./changes.js";
|
|
3
|
+
import { splitOnSentinels } from "./intents.js";
|
|
4
|
+
import { escapeXml, format, fullmatch, isSpace, re, rstrip, sub } from "./util.js";
|
|
5
|
+
const STAGE = "compile";
|
|
6
|
+
const UNKNOWN_TAG = re(String.raw `\[([^\[\]\n]{1,60})\]`);
|
|
7
|
+
const WORDY = re("[A-Za-zÀ-ÿĞğİıŞşÇçÖöÜü ,'\\-]+");
|
|
8
|
+
function spec(profile, name) {
|
|
9
|
+
return (profile.intents[name] ?? {});
|
|
10
|
+
}
|
|
11
|
+
/** Render a pause as punctuation. `prev` is the text so far (used to avoid doubling). */
|
|
12
|
+
function punctuationPause(prev, ms) {
|
|
13
|
+
const tail = rstrip(prev);
|
|
14
|
+
if (!tail)
|
|
15
|
+
return ""; // a pause before any text: nothing to pause after
|
|
16
|
+
const last = tail[tail.length - 1];
|
|
17
|
+
if (ms <= 300) {
|
|
18
|
+
if (".!?,;:…".includes(last))
|
|
19
|
+
return " ";
|
|
20
|
+
return ", ";
|
|
21
|
+
}
|
|
22
|
+
if (".!?…".includes(last))
|
|
23
|
+
return ms <= 1000 ? " " : "\n";
|
|
24
|
+
if (ms <= 1000)
|
|
25
|
+
return "... ";
|
|
26
|
+
return "...\n";
|
|
27
|
+
}
|
|
28
|
+
function renderPause(ms, s, prev) {
|
|
29
|
+
const render = s.render ?? "punctuation";
|
|
30
|
+
if (render === "punctuation")
|
|
31
|
+
return punctuationPause(prev, ms);
|
|
32
|
+
const lo = s.min_ms ?? 0;
|
|
33
|
+
const hi = s.max_ms;
|
|
34
|
+
let v = Math.max(ms, lo);
|
|
35
|
+
if (hi !== undefined && hi !== null)
|
|
36
|
+
v = Math.min(v, hi);
|
|
37
|
+
const seconds = String(v / 1000);
|
|
38
|
+
return format(render, { ms: v, s: seconds });
|
|
39
|
+
}
|
|
40
|
+
function renderSpell(text, s) {
|
|
41
|
+
const render = s.render ?? "spaced";
|
|
42
|
+
if (render === "spaced")
|
|
43
|
+
return Array.from(text).filter((ch) => !isSpace(ch)).map((ch) => ch.toUpperCase()).join(" ");
|
|
44
|
+
return format(render, { text });
|
|
45
|
+
}
|
|
46
|
+
function renderEm(text, s) {
|
|
47
|
+
const render = s.render ?? "{text}";
|
|
48
|
+
return format(render, { text });
|
|
49
|
+
}
|
|
50
|
+
/** Phoneme tag where the provider takes one, else the respelling, else the word. */
|
|
51
|
+
function renderPhoneme(intent, profile) {
|
|
52
|
+
const esc = profile.escape === "xml" ? escapeXml : (t) => t;
|
|
53
|
+
const s = spec(profile, "phoneme");
|
|
54
|
+
if (intent.ipa && s.render)
|
|
55
|
+
return format(s.render, { text: esc(intent.text), ipa: esc(intent.ipa) });
|
|
56
|
+
if (intent.say)
|
|
57
|
+
return esc(intent.say);
|
|
58
|
+
return esc(intent.text);
|
|
59
|
+
}
|
|
60
|
+
export function compileText(text, intents, profile, recorder) {
|
|
61
|
+
const rec = recorder ?? new Recorder();
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const [segment, idx] of splitOnSentinels(text)) {
|
|
64
|
+
if (idx === null) {
|
|
65
|
+
let seg = unknownTags(segment, profile, rec);
|
|
66
|
+
if (profile.escape === "xml") {
|
|
67
|
+
const esc = escapeXml(seg);
|
|
68
|
+
rec.add(STAGE, "compile.escape.xml", seg, esc, "escaped & < > for SSML");
|
|
69
|
+
seg = esc;
|
|
70
|
+
}
|
|
71
|
+
out.push(seg);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const intent = intents[idx];
|
|
75
|
+
const prev = out.join("");
|
|
76
|
+
let rendered;
|
|
77
|
+
if (intent.kind === "pause") {
|
|
78
|
+
rendered = renderPause(intent.ms, spec(profile, "pause"), prev);
|
|
79
|
+
if (out.length && (rendered.startsWith(",") || rendered.startsWith(".")) && prev.endsWith(" ")) {
|
|
80
|
+
out[out.length - 1] = rstrip(out[out.length - 1], " ");
|
|
81
|
+
}
|
|
82
|
+
rec.add(STAGE, "compile.pause", `[pause ${intent.ms}ms]`, rendered, `pause rendered for ${profile.id}`);
|
|
83
|
+
}
|
|
84
|
+
else if (intent.kind === "phoneme") {
|
|
85
|
+
rendered = renderPhoneme(intent, profile);
|
|
86
|
+
rec.add(STAGE, "compile.phoneme", intent.text, rendered, `pronunciation rendered for ${profile.id}`);
|
|
87
|
+
}
|
|
88
|
+
else if (intent.kind === "spell") {
|
|
89
|
+
const body = profile.escape === "xml" ? escapeXml(intent.text) : intent.text;
|
|
90
|
+
rendered = renderSpell(body, spec(profile, "spell"));
|
|
91
|
+
rec.add(STAGE, "compile.spell", `[spell ${intent.text}]`, rendered, `spell-out rendered for ${profile.id}`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const body = profile.escape === "xml" ? escapeXml(intent.text) : intent.text;
|
|
95
|
+
rendered = renderEm(body, spec(profile, "emphasis"));
|
|
96
|
+
rec.add(STAGE, "compile.emphasis", `[em ${intent.text}]`, rendered, `emphasis rendered for ${profile.id}`);
|
|
97
|
+
}
|
|
98
|
+
out.push(rendered);
|
|
99
|
+
}
|
|
100
|
+
let result = out.join("");
|
|
101
|
+
result = result.replace(/[ \t]{2,}/g, " ");
|
|
102
|
+
result = result.replace(/ +\n/g, "\n");
|
|
103
|
+
result = result.replace(/\n +/g, "\n");
|
|
104
|
+
return result.trim();
|
|
105
|
+
}
|
|
106
|
+
/** Bracket tags the pipeline does not know: pass through on audio-tag models, drop elsewhere. */
|
|
107
|
+
function unknownTags(segment, profile, rec) {
|
|
108
|
+
return sub(UNKNOWN_TAG, segment, (m) => {
|
|
109
|
+
const body = m[1].trim();
|
|
110
|
+
const wordy = fullmatch(WORDY, body) !== null;
|
|
111
|
+
if (profile.audioTags && wordy)
|
|
112
|
+
return m[0];
|
|
113
|
+
rec.add(STAGE, "compile.unknown_tag", m[0], "", "bracket tag removed; this model does not read audio tags");
|
|
114
|
+
return "";
|
|
115
|
+
});
|
|
116
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** speechready: prepare text for text-to-speech.
|
|
2
|
+
*
|
|
3
|
+
* Clean LLM output, verbalize ambiguous tokens, compile pacing intents to a
|
|
4
|
+
* provider's own syntax, and chunk under the model's request limit. Deterministic,
|
|
5
|
+
* no network calls, every rewrite reported as a Change.
|
|
6
|
+
*/
|
|
7
|
+
export type { Change } from "./changes.js";
|
|
8
|
+
export { Segmenter, pack, splitSentences } from "./chunk.js";
|
|
9
|
+
export { DEFAULT_OPTIONS, clean, options } from "./clean.js";
|
|
10
|
+
export type { Options } from "./clean.js";
|
|
11
|
+
export { Lexicon } from "./lexicon.js";
|
|
12
|
+
export type { Entry, EntryInput } from "./lexicon.js";
|
|
13
|
+
export { prepare } from "./pipeline.js";
|
|
14
|
+
export type { Prepared, PrepareInit } from "./pipeline.js";
|
|
15
|
+
export { listProfiles, loadProfile, resolveProfile } from "./profiles/index.js";
|
|
16
|
+
export type { Profile } from "./profiles/index.js";
|
|
17
|
+
export { verbalize } from "./verbalize/index.js";
|
|
18
|
+
import "./verbalize/en.js";
|
|
19
|
+
import "./verbalize/tr.js";
|
|
20
|
+
export { version } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** speechready: prepare text for text-to-speech.
|
|
2
|
+
*
|
|
3
|
+
* Clean LLM output, verbalize ambiguous tokens, compile pacing intents to a
|
|
4
|
+
* provider's own syntax, and chunk under the model's request limit. Deterministic,
|
|
5
|
+
* no network calls, every rewrite reported as a Change.
|
|
6
|
+
*/
|
|
7
|
+
export { Segmenter, pack, splitSentences } from "./chunk.js";
|
|
8
|
+
export { DEFAULT_OPTIONS, clean, options } from "./clean.js";
|
|
9
|
+
export { Lexicon } from "./lexicon.js";
|
|
10
|
+
export { prepare } from "./pipeline.js";
|
|
11
|
+
export { listProfiles, loadProfile, resolveProfile } from "./profiles/index.js";
|
|
12
|
+
export { verbalize } from "./verbalize/index.js";
|
|
13
|
+
import "./verbalize/en.js";
|
|
14
|
+
import "./verbalize/tr.js";
|
|
15
|
+
export { version } from "./version.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Pacing intents written inline in the input and carried through the pipeline.
|
|
2
|
+
*
|
|
3
|
+
* Developers write `[pause 500ms]`, `[spell AB12]` or `[em really]` in their text,
|
|
4
|
+
* or an SSML `<break time="500ms"/>` they already had. `extract` replaces each one
|
|
5
|
+
* with a private-use sentinel so cleaning and verbalizing leave it alone; the
|
|
6
|
+
* compile stage renders every sentinel in the target provider's own syntax.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SENTINEL_OPEN = "\uE000";
|
|
9
|
+
export declare const SENTINEL_CLOSE = "\uE001";
|
|
10
|
+
export declare const DEFAULT_PAUSE_MS = 500;
|
|
11
|
+
export interface Intent {
|
|
12
|
+
kind: "pause" | "spell" | "em" | "phoneme";
|
|
13
|
+
/** pause only */
|
|
14
|
+
ms: number;
|
|
15
|
+
/** spell, em and phoneme: the words as written */
|
|
16
|
+
text: string;
|
|
17
|
+
/** phoneme: IPA transcription */
|
|
18
|
+
ipa: string;
|
|
19
|
+
/** phoneme: respelling fallback for providers without phoneme tags */
|
|
20
|
+
say: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function intent(kind: Intent["kind"], fields?: Partial<Omit<Intent, "kind">>): Intent;
|
|
23
|
+
/** '1.5s', '500ms', '2' (seconds) -> milliseconds. Unparseable -> the default. */
|
|
24
|
+
export declare function parseDurationMs(value: string | undefined | null): number;
|
|
25
|
+
/** Replace every inline intent with a sentinel; return the text and the intents in order.
|
|
26
|
+
*
|
|
27
|
+
* Pass an existing list to append to it (the pipeline runs a second pass after
|
|
28
|
+
* cleaning, because the cleaner can turn markdown emphasis into `[em ...]`).
|
|
29
|
+
*/
|
|
30
|
+
export declare function extract(text: string, intents?: Intent[]): [string, Intent[]];
|
|
31
|
+
/** Split text into [segment, intentIndex] pairs; plain text has index null. */
|
|
32
|
+
export declare function splitOnSentinels(text: string): [string, number | null][];
|
|
33
|
+
export declare function stripSentinels(text: string): string;
|
|
34
|
+
export declare function sentinelPattern(): RegExp;
|
package/dist/intents.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Pacing intents written inline in the input and carried through the pipeline.
|
|
2
|
+
*
|
|
3
|
+
* Developers write `[pause 500ms]`, `[spell AB12]` or `[em really]` in their text,
|
|
4
|
+
* or an SSML `<break time="500ms"/>` they already had. `extract` replaces each one
|
|
5
|
+
* with a private-use sentinel so cleaning and verbalizing leave it alone; the
|
|
6
|
+
* compile stage renders every sentinel in the target provider's own syntax.
|
|
7
|
+
*/
|
|
8
|
+
import { fullmatch, partition, re, roundHalfEven, sub } from "./util.js";
|
|
9
|
+
export const SENTINEL_OPEN = "";
|
|
10
|
+
export const SENTINEL_CLOSE = "";
|
|
11
|
+
const INTENT_TAG = re(String.raw `\[(?<kind>pause|spell|em|ipa)(?:\s+(?<arg>[^\]]*?))?\s*\](?!\()`, "i");
|
|
12
|
+
const SSML_BREAK = re(String.raw `<break\s+(?:time\s*=\s*["'](?<time>[^"']+)["']|strength\s*=\s*["'](?<strength>[^"']+)["'])[^>]*/?>`, "i");
|
|
13
|
+
const SENTINEL = re(`${SENTINEL_OPEN}(\\d+)${SENTINEL_CLOSE}`);
|
|
14
|
+
const DURATION = re(String.raw `(\d+(?:\.\d+)?)\s*(ms|s)?`);
|
|
15
|
+
const STRENGTH_MS = { "x-weak": 250, weak: 500, medium: 750, strong: 1000, "x-strong": 1250 };
|
|
16
|
+
export const DEFAULT_PAUSE_MS = 500;
|
|
17
|
+
export function intent(kind, fields = {}) {
|
|
18
|
+
return { kind, ms: 0, text: "", ipa: "", say: "", ...fields };
|
|
19
|
+
}
|
|
20
|
+
/** '1.5s', '500ms', '2' (seconds) -> milliseconds. Unparseable -> the default. */
|
|
21
|
+
export function parseDurationMs(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return DEFAULT_PAUSE_MS;
|
|
24
|
+
const v = value.trim().toLowerCase();
|
|
25
|
+
const m = fullmatch(DURATION, v);
|
|
26
|
+
if (!m)
|
|
27
|
+
return DEFAULT_PAUSE_MS;
|
|
28
|
+
const number = parseFloat(m[1]);
|
|
29
|
+
const unit = m[2];
|
|
30
|
+
if (unit === "ms")
|
|
31
|
+
return roundHalfEven(number);
|
|
32
|
+
return roundHalfEven(number * 1000);
|
|
33
|
+
}
|
|
34
|
+
/** Replace every inline intent with a sentinel; return the text and the intents in order.
|
|
35
|
+
*
|
|
36
|
+
* Pass an existing list to append to it (the pipeline runs a second pass after
|
|
37
|
+
* cleaning, because the cleaner can turn markdown emphasis into `[em ...]`).
|
|
38
|
+
*/
|
|
39
|
+
export function extract(text, intents) {
|
|
40
|
+
const list = intents ?? [];
|
|
41
|
+
const add = (it) => {
|
|
42
|
+
list.push(it);
|
|
43
|
+
return `${SENTINEL_OPEN}${list.length - 1}${SENTINEL_CLOSE}`;
|
|
44
|
+
};
|
|
45
|
+
const ssml = (m) => {
|
|
46
|
+
const g = m.groups ?? {};
|
|
47
|
+
if (g.time)
|
|
48
|
+
return add(intent("pause", { ms: parseDurationMs(g.time) }));
|
|
49
|
+
return add(intent("pause", { ms: STRENGTH_MS[(g.strength ?? "").toLowerCase()] ?? DEFAULT_PAUSE_MS }));
|
|
50
|
+
};
|
|
51
|
+
const tag = (m) => {
|
|
52
|
+
const g = m.groups ?? {};
|
|
53
|
+
const kind = (g.kind ?? "").toLowerCase();
|
|
54
|
+
const arg = (g.arg ?? "").trim();
|
|
55
|
+
if (kind === "pause")
|
|
56
|
+
return add(intent("pause", { ms: parseDurationMs(arg) }));
|
|
57
|
+
if (!arg)
|
|
58
|
+
return "";
|
|
59
|
+
if (kind === "ipa") {
|
|
60
|
+
const [word, , transcription] = partition(arg, " ");
|
|
61
|
+
if (!transcription)
|
|
62
|
+
return word;
|
|
63
|
+
return add(intent("phoneme", { text: word, ipa: transcription.trim() }));
|
|
64
|
+
}
|
|
65
|
+
return add(intent(kind, { text: arg }));
|
|
66
|
+
};
|
|
67
|
+
let out = sub(SSML_BREAK, text, ssml);
|
|
68
|
+
out = sub(INTENT_TAG, out, tag);
|
|
69
|
+
return [out, list];
|
|
70
|
+
}
|
|
71
|
+
/** Split text into [segment, intentIndex] pairs; plain text has index null. */
|
|
72
|
+
export function splitOnSentinels(text) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
let pos = 0;
|
|
75
|
+
SENTINEL.lastIndex = 0;
|
|
76
|
+
for (const m of text.matchAll(SENTINEL)) {
|
|
77
|
+
const start = m.index ?? 0;
|
|
78
|
+
if (start > pos)
|
|
79
|
+
parts.push([text.slice(pos, start), null]);
|
|
80
|
+
parts.push(["", parseInt(m[1], 10)]);
|
|
81
|
+
pos = start + m[0].length;
|
|
82
|
+
}
|
|
83
|
+
if (pos < text.length)
|
|
84
|
+
parts.push([text.slice(pos), null]);
|
|
85
|
+
return parts;
|
|
86
|
+
}
|
|
87
|
+
export function stripSentinels(text) {
|
|
88
|
+
return sub(SENTINEL, text, () => "");
|
|
89
|
+
}
|
|
90
|
+
export function sentinelPattern() {
|
|
91
|
+
return re(`${SENTINEL_OPEN}(\\d+)${SENTINEL_CLOSE}`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One pronunciation lexicon, applied in the pipeline and exported to each vendor's format.
|
|
2
|
+
*
|
|
3
|
+
* An entry says how a word is pronounced in one of three ways:
|
|
4
|
+
* - `ipa`: an IPA transcription, rendered as a phoneme tag where the provider
|
|
5
|
+
* accepts one (Azure, Google, Polly) and exported to lexicon files;
|
|
6
|
+
* - `say`: a respelling or alias ("engine X", "sequel") that replaces the word in
|
|
7
|
+
* the text, which works on every provider;
|
|
8
|
+
* - `spell: true`: the word is read letter by letter.
|
|
9
|
+
*
|
|
10
|
+
* When a provider cannot take a phoneme tag, an entry falls back to `say`, then
|
|
11
|
+
* to `spell`, then leaves the word alone.
|
|
12
|
+
*/
|
|
13
|
+
import { Intent } from "./intents.js";
|
|
14
|
+
import { Profile } from "./profiles/index.js";
|
|
15
|
+
export interface Entry {
|
|
16
|
+
word: string;
|
|
17
|
+
ipa: string;
|
|
18
|
+
say: string;
|
|
19
|
+
spell: boolean;
|
|
20
|
+
case_sensitive: boolean;
|
|
21
|
+
}
|
|
22
|
+
export type EntryInput = Partial<Entry> & {
|
|
23
|
+
word: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function entry(e: EntryInput): Entry;
|
|
26
|
+
export declare class Lexicon {
|
|
27
|
+
entries: Entry[];
|
|
28
|
+
locale: string;
|
|
29
|
+
constructor(entries?: EntryInput[], locale?: string);
|
|
30
|
+
static fromEntries(entries: EntryInput[], locale?: string): Lexicon;
|
|
31
|
+
/** Build from a parsed lexicon document (`{locale, entries}`), e.g. `JSON.parse` of a lexicon file. */
|
|
32
|
+
static fromObject(data: {
|
|
33
|
+
locale?: string;
|
|
34
|
+
entries?: EntryInput[];
|
|
35
|
+
}): Lexicon;
|
|
36
|
+
/** Read a `.json` lexicon file, or a `.yaml` one when the optional `yaml` package is installed. Node only. */
|
|
37
|
+
static load(path: string): Promise<Lexicon>;
|
|
38
|
+
toObject(): {
|
|
39
|
+
locale: string;
|
|
40
|
+
entries: Record<string, unknown>[];
|
|
41
|
+
};
|
|
42
|
+
/** Replace every lexicon word in `text` with an intent sentinel; appends to `intents`. */
|
|
43
|
+
apply(text: string, intents: Intent[]): string;
|
|
44
|
+
/** Pronunciation Lexicon Specification XML, accepted by ElevenLabs, Azure and Amazon Polly. */
|
|
45
|
+
toPls(alphabet?: string): string;
|
|
46
|
+
/** Google Cloud TTS custom pronunciations (v1beta1 `customPronunciations`). IPA entries only. */
|
|
47
|
+
toGoogleJson(): string;
|
|
48
|
+
toJson(): string;
|
|
49
|
+
/** The upload file for a provider profile, or null when the provider takes no lexicon. */
|
|
50
|
+
exportFor(profile: Profile): string | null;
|
|
51
|
+
/** `pls`, `google`, or `json`. */
|
|
52
|
+
export(fmt: string, alphabet?: string): string;
|
|
53
|
+
}
|