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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berker Batur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # speechready (JavaScript / TypeScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Prepare text for text-to-speech. See the [repository README](https://github.com/berkerbatur/speechready) for the project overview and the evaluation results.
4
+
5
+ ```
6
+ npm install speechready
7
+ ```
8
+
9
+ ESM only, Node 20 or newer, no runtime dependencies. Type declarations are included. The provider profiles from `spec/profiles` are compiled into the package, so it also runs in a browser bundle.
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ import { prepare } from "speechready";
15
+
16
+ const result = prepare(
17
+ "**Step 1:** run `npm install`. [pause 1s] Then read https://example.com/docs.",
18
+ { provider: "elevenlabs", model: "eleven_multilingual_v2", locale: "en-US" },
19
+ );
20
+
21
+ result.text // 'Step 1: run npm install. <break time="1s" /> Then read example.com.'
22
+ result.chunks // the same text split under the model's request limit
23
+ for (const change of result.changes) {
24
+ console.log(change.rule, JSON.stringify(change.before), "->", JSON.stringify(change.after));
25
+ }
26
+ ```
27
+
28
+ `provider` and `model` pick a profile; with neither, the `plain` profile renders pauses as punctuation and returns one chunk. Pass `profile: loadProfile("azure-neural")` to pick one directly and `limit` to override the request limit. `locale` is `en-US` by default; `tr-TR` turns on the Turkish rules.
29
+
30
+ Everything a caller may want to decide differently is an option. The names are the same as in the Python package and in the shared fixtures, so they are snake_case; a name the package does not know throws.
31
+
32
+ ```js
33
+ prepare(text, { provider: "openai", options: {
34
+ code_blocks: "mention", // drop (default) | keep | mention: one sentence stands in for a fenced block
35
+ code_block_mention: null, // what `mention` says; null = "See the code block." / "Kod bloğuna bakın."
36
+ tables: "rows", // rows (default, each row a comma list) | drop
37
+ urls: "domain", // domain (default) | drop | keep
38
+ emails: "keep", // keep (default) | drop
39
+ emoji: "drop", // drop (default) | keep
40
+ emphasis: "strip", // strip (default) | intent: **bold** becomes an emphasis intent
41
+ list_numbers: "keep", // keep (default, "1. Open the app") | drop
42
+ fractions: "spoken", // spoken (default: "one half", "yarım") | division ("one over two", "bir bölü iki")
43
+ foreign_acronyms: true, // Turkish: npm, css, sql read with English letter names (en pi em)
44
+ terminal_punctuation: ".", // added to a block that ends without one
45
+ }});
46
+ ```
47
+
48
+ Stages are also exported on their own: `clean(text, options, recorder, locale)`, `verbalize(text, locale, recorder, options)`, `splitSentences(text, locale)`, `pack(sentences, limit, unit)`, and `Segmenter` for streaming token feeds:
49
+
50
+ ```js
51
+ import { Segmenter } from "speechready";
52
+
53
+ const seg = new Segmenter({ locale: "en-US" });
54
+ for (const token of llmStream) {
55
+ for (const sentence of seg.push(token)) speak(sentence); // released once the boundary is confirmed
56
+ }
57
+ for (const sentence of seg.flush()) speak(sentence);
58
+ ```
59
+
60
+ Pronunciation lexicon, one file for every vendor:
61
+
62
+ ```js
63
+ import { Lexicon, prepare } from "speechready";
64
+
65
+ const lex = Lexicon.fromEntries([
66
+ { word: "Nginx", ipa: "ˈɛndʒɪnˈɛks", say: "engine X" },
67
+ { word: "SQL", say: "sequel" },
68
+ { word: "CLI", spell: true },
69
+ ]);
70
+ prepare("Restart Nginx.", { provider: "azure", lexicon: lex }).text;
71
+ // 'Restart <phoneme alphabet="ipa" ph="ˈɛndʒɪnˈɛks">Nginx</phoneme>.'
72
+ prepare("Restart Nginx.", { provider: "openai", lexicon: lex }).text;
73
+ // 'Restart engine X.' (no phoneme tags on OpenAI, so the respelling is used)
74
+
75
+ lex.toPls(); // PLS XML for ElevenLabs, Azure and Polly uploads
76
+ lex.toGoogleJson(); // Google Cloud TTS custom pronunciations
77
+ await Lexicon.load("lexicon.json"); // Node only; .yaml files need the optional `yaml` package
78
+ ```
79
+
80
+ ## Differences from the Python package
81
+
82
+ The two packages produce the same text for every fixture in `spec/fixtures`. The API differs only where the languages do: keyword arguments are one options object, methods are camelCase (`limitFor`, `toPls`, `splitSentences`), `Lexicon.load` is async, and `prepare` takes a `Lexicon` or an entries array rather than a file path.
83
+
84
+ ## Development
85
+
86
+ ```
87
+ cd js
88
+ npm ci
89
+ npm test # builds, then runs every fixture in ../spec/fixtures plus the package tests
90
+ ```
91
+
92
+ `npm run build` regenerates `src/profiles/data.ts` from `../spec/profiles` and `src/version.ts` from `package.json`, then compiles to `dist/`. All three are build outputs and are not committed.
@@ -0,0 +1,14 @@
1
+ /** The audit trail: one Change per rewrite the pipeline made. */
2
+ export interface Change {
3
+ stage: string;
4
+ /** Dotted and stable, e.g. `clean.markdown.heading`. */
5
+ rule: string;
6
+ before: string;
7
+ after: string;
8
+ note: string;
9
+ }
10
+ /** Collects Changes; stages call `add` whenever they rewrite something. */
11
+ export declare class Recorder {
12
+ changes: Change[];
13
+ add(stage: string, rule: string, before: string, after: string, note?: string): void;
14
+ }
@@ -0,0 +1,10 @@
1
+ /** The audit trail: one Change per rewrite the pipeline made. */
2
+ /** Collects Changes; stages call `add` whenever they rewrite something. */
3
+ export class Recorder {
4
+ changes = [];
5
+ add(stage, rule, before, after, note = "") {
6
+ if (before === after)
7
+ return;
8
+ this.changes.push({ stage, rule, before, after, note });
9
+ }
10
+ }
@@ -0,0 +1,32 @@
1
+ /** Stage 4: chunk. Sentence segmentation, streaming segmentation, and packing under a limit.
2
+ *
3
+ * Rules that keep prosody intact:
4
+ * - a newline is a hard boundary (blocks from the clean stage never merge);
5
+ * - a sentence ends at . ! ? … followed by whitespace and then an uppercase
6
+ * letter, a digit, an opening quote or bracket, or a tag;
7
+ * - a known abbreviation, a single-letter initial, or a number before the full
8
+ * stop never ends a sentence;
9
+ * - a sentence that is longer than the limit is split at clause punctuation,
10
+ * then at spaces, and only as a last resort mid-word.
11
+ */
12
+ /** Split text into sentences. Newlines are hard boundaries. Empty sentences are dropped. */
13
+ export declare function splitSentences(text: string, locale?: string): string[];
14
+ /** Streaming sentence segmenter for LLM token feeds.
15
+ *
16
+ * `push(text)` returns the sentences that are now complete; `flush()` returns
17
+ * whatever is left. A boundary is only confirmed once text after it arrives,
18
+ * so a sentence is never cut at "Dr." or "3.14" because the stream paused there.
19
+ */
20
+ export declare class Segmenter {
21
+ locale: string;
22
+ maxBuffer: number | null;
23
+ private buffer;
24
+ constructor(init?: {
25
+ locale?: string;
26
+ maxBuffer?: number | null;
27
+ });
28
+ push(text: string): string[];
29
+ flush(): string[];
30
+ }
31
+ /** Greedily join sentences into chunks no larger than `limit`. null = one chunk. */
32
+ export declare function pack(sentences: string[], limit: number | null | undefined, unit?: string, joiner?: string): string[];
package/dist/chunk.js ADDED
@@ -0,0 +1,195 @@
1
+ /** Stage 4: chunk. Sentence segmentation, streaming segmentation, and packing under a limit.
2
+ *
3
+ * Rules that keep prosody intact:
4
+ * - a newline is a hard boundary (blocks from the clean stage never merge);
5
+ * - a sentence ends at . ! ? … followed by whitespace and then an uppercase
6
+ * letter, a digit, an opening quote or bracket, or a tag;
7
+ * - a known abbreviation, a single-letter initial, or a number before the full
8
+ * stop never ends a sentence;
9
+ * - a sentence that is longer than the limit is split at clause punctuation,
10
+ * then at spaces, and only as a last resort mid-word.
11
+ */
12
+ import { SENTINEL_OPEN } from "./intents.js";
13
+ import { codePoints, finditer, fullmatch, isAlpha, isDigit, isUpper, re, rstrip, strip, utf8Length } from "./util.js";
14
+ const ABBREVIATIONS = {
15
+ en: new Set(`mr mrs ms dr prof sr jr st mt ft vs etc e.g i.e cf no nos inc ltd co corp llc dept est approx
16
+ jan feb mar apr jun jul aug sep sept oct nov dec mon tue wed thu fri sat sun a.m p.m
17
+ u.s u.k e.u ph.d m.d b.a m.a b.sc m.sc fig vol pp ed eds rev gen col lt sgt capt cmdr hon
18
+ ave blvd rd hwy tel ext dept univ assn bros`.split(/\s+/)),
19
+ tr: new Set(`dr prof doç yrd av sn vb vs vd örn bkz yy t.c no sok cad apt mah alb yzb bşk müh
20
+ oca şub mar nis haz tem ağu eyl eki kas ara ltd şti a.ş san tic dk sa sn bk ör
21
+ hz sf s c çev der haz üniv fak böl hst bul uzm öğr yrd tel`.split(/\s+/)),
22
+ };
23
+ const END = re(String.raw `(?<end>[.!?…]+)(?<close>["'”’)\]»]*)(?<space>\s+)(?=(?<next>\S))`);
24
+ const WORD_BEFORE = re(String.raw `(\S+)$`);
25
+ const CLAUSE = re(String.raw `[,;:—–]\s+|\s+[-]\s+`);
26
+ const DOTTED = re("[a-z](\\.[a-z])+");
27
+ const LIST_MARKER = re(String.raw `\d{1,3}\.`);
28
+ function lang(locale) {
29
+ return (locale || "en").split("-")[0].split("_")[0].toLowerCase();
30
+ }
31
+ function abbreviations(locale) {
32
+ return ABBREVIATIONS[lang(locale)] ?? new Set();
33
+ }
34
+ function isBoundary(text, m, abbrs) {
35
+ const g = m.groups ?? {};
36
+ const end = g.end ?? "";
37
+ const nxt = g.next ?? "";
38
+ const before = text.slice(0, m.index);
39
+ const wordM = WORD_BEFORE.exec(before);
40
+ WORD_BEFORE.lastIndex = 0;
41
+ const word = wordM ? wordM[1] : "";
42
+ const core = strip(word, "(\"'“”‘’[").toLowerCase();
43
+ if ((g.space ?? "").includes("\n"))
44
+ return true;
45
+ if (end.startsWith(".") && !end.includes("…") && !end.includes("!") && !end.includes("?")) {
46
+ if (abbrs.has(core) || abbrs.has(rstrip(core, ".")))
47
+ return false;
48
+ if (core.length === 1 && isAlpha(core))
49
+ return false; // initial: "J. R. R. Tolkien"
50
+ if (isDigit(core) && !isUpper(nxt[0]))
51
+ return false; // ordinal or list number: "3. madde"
52
+ if (fullmatch(DOTTED, core))
53
+ return false; // dotted abbreviation not in the list, e.g. "p.s"
54
+ }
55
+ const first = nxt[0];
56
+ if ("\"'“‘([«<".includes(first) || first === SENTINEL_OPEN)
57
+ return true;
58
+ if (isUpper(first) || isDigit(first))
59
+ return true;
60
+ if ("¿¡".includes(first))
61
+ return true;
62
+ return false;
63
+ }
64
+ /** Split text into sentences. Newlines are hard boundaries. Empty sentences are dropped. */
65
+ export function splitSentences(text, locale = "en-US") {
66
+ const abbrs = abbreviations(locale);
67
+ const out = [];
68
+ for (const rawLine of text.split("\n")) {
69
+ const line = rawLine.trim();
70
+ if (!line)
71
+ continue;
72
+ let start = 0;
73
+ for (const m of finditer(END, line)) {
74
+ const g = m.groups ?? {};
75
+ const endAll = m.index + m[0].length;
76
+ const endClose = m.index + (g.end ?? "").length + (g.close ?? "").length;
77
+ if (fullmatch(LIST_MARKER, line.slice(start, endAll).trim()))
78
+ continue; // "1. Open the app": the number is the item's marker, not a sentence
79
+ if (isBoundary(line, m, abbrs)) {
80
+ out.push(line.slice(start, endClose).trim());
81
+ start = endAll;
82
+ }
83
+ }
84
+ const tail = line.slice(start).trim();
85
+ if (tail)
86
+ out.push(tail);
87
+ }
88
+ return out;
89
+ }
90
+ /** Streaming sentence segmenter for LLM token feeds.
91
+ *
92
+ * `push(text)` returns the sentences that are now complete; `flush()` returns
93
+ * whatever is left. A boundary is only confirmed once text after it arrives,
94
+ * so a sentence is never cut at "Dr." or "3.14" because the stream paused there.
95
+ */
96
+ export class Segmenter {
97
+ locale;
98
+ maxBuffer;
99
+ buffer = "";
100
+ constructor(init = {}) {
101
+ this.locale = init.locale ?? "en-US";
102
+ this.maxBuffer = init.maxBuffer ?? null;
103
+ }
104
+ push(text) {
105
+ this.buffer += text;
106
+ const done = [];
107
+ for (;;) {
108
+ const nl = this.buffer.indexOf("\n");
109
+ if (nl < 0)
110
+ break;
111
+ const head = this.buffer.slice(0, nl);
112
+ this.buffer = this.buffer.slice(nl + 1);
113
+ done.push(...splitSentences(head, this.locale));
114
+ }
115
+ const sentences = splitSentences(this.buffer, this.locale);
116
+ if (sentences.length > 1) {
117
+ done.push(...sentences.slice(0, -1));
118
+ // keep the unconfirmed tail exactly as it stands (leading spaces dropped)
119
+ const idx = this.buffer.lastIndexOf(sentences[sentences.length - 1]);
120
+ this.buffer = idx >= 0 ? this.buffer.slice(idx) : this.buffer;
121
+ }
122
+ if (this.maxBuffer && this.buffer.length > this.maxBuffer) {
123
+ const cut = lastClauseCut(this.buffer, this.maxBuffer);
124
+ if (cut > 0) {
125
+ done.push(this.buffer.slice(0, cut).trim());
126
+ this.buffer = this.buffer.slice(cut).replace(/^\s+/, "");
127
+ }
128
+ }
129
+ return done.filter((s) => s);
130
+ }
131
+ flush() {
132
+ const rest = this.buffer;
133
+ this.buffer = "";
134
+ return splitSentences(rest, this.locale);
135
+ }
136
+ }
137
+ function lastClauseCut(text, limit) {
138
+ const window = text.slice(0, limit);
139
+ let best = -1;
140
+ for (const m of finditer(CLAUSE, window))
141
+ best = m.index + m[0].length;
142
+ if (best > 0)
143
+ return best;
144
+ const sp = window.lastIndexOf(" ");
145
+ return sp > 0 ? sp + 1 : limit;
146
+ }
147
+ function size(text, unit) {
148
+ return unit === "bytes" ? utf8Length(text) : codePoints(text);
149
+ }
150
+ /** Split one over-long sentence at clause marks, then spaces, then hard. */
151
+ function splitLong(sentence, limit, unit) {
152
+ if (size(sentence, unit) <= limit)
153
+ return [sentence];
154
+ const pieces = [];
155
+ let rest = sentence;
156
+ while (size(rest, unit) > limit) {
157
+ let window = rest;
158
+ while (size(window, unit) > limit)
159
+ window = window.slice(0, Math.max(1, Math.floor(window.length * 0.9)));
160
+ let cut = -1;
161
+ for (const m of finditer(CLAUSE, window))
162
+ cut = m.index + m[0].length;
163
+ if (cut <= 0)
164
+ cut = window.lastIndexOf(" ") + 1;
165
+ if (cut <= 0)
166
+ cut = window.length;
167
+ pieces.push(rest.slice(0, cut).trim());
168
+ rest = rest.slice(cut).trim();
169
+ }
170
+ if (rest)
171
+ pieces.push(rest);
172
+ return pieces;
173
+ }
174
+ /** Greedily join sentences into chunks no larger than `limit`. null = one chunk. */
175
+ export function pack(sentences, limit, unit = "chars", joiner = " ") {
176
+ if (limit === null || limit === undefined)
177
+ return sentences.length ? [sentences.join(joiner)] : [];
178
+ const chunks = [];
179
+ let current = "";
180
+ for (const s of sentences) {
181
+ for (const piece of splitLong(s, limit, unit)) {
182
+ const candidate = current ? current + joiner + piece : piece;
183
+ if (current && size(candidate, unit) > limit) {
184
+ chunks.push(current);
185
+ current = piece;
186
+ }
187
+ else {
188
+ current = candidate;
189
+ }
190
+ }
191
+ }
192
+ if (current)
193
+ chunks.push(current);
194
+ return chunks;
195
+ }
@@ -0,0 +1,41 @@
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
+ /** Everything a caller can change about how text is prepared. Defaults are what the evaluation ran with.
10
+ *
11
+ * Field names are the same as in the Python package and the shared fixtures (snake_case).
12
+ */
13
+ export interface Options {
14
+ /** drop | keep | mention (one spoken sentence stands in for the block) */
15
+ code_blocks: "drop" | "keep" | "mention";
16
+ /** the sentence `mention` speaks; null = the locale's default */
17
+ code_block_mention: string | null;
18
+ tables: "rows" | "drop";
19
+ urls: "domain" | "drop" | "keep";
20
+ emails: "keep" | "drop";
21
+ emoji: "drop" | "keep";
22
+ emphasis: "strip" | "intent";
23
+ /** keep ("1. Open the app") | drop ("Open the app") */
24
+ list_numbers: "keep" | "drop";
25
+ strip_tags: string[];
26
+ terminal_punctuation: string;
27
+ html: "strip" | "keep";
28
+ /** spoken ("one half", "yarım") | division ("one over two", "bir bölü iki") */
29
+ fractions: "spoken" | "division";
30
+ /** Turkish: lowercase vowel-less tokens like npm, css read with English letter names */
31
+ foreign_acronyms: boolean;
32
+ extra: Record<string, unknown>;
33
+ }
34
+ export declare const DEFAULT_OPTIONS: Options;
35
+ /** Fill in defaults. Unknown keys throw, so a camelCase typo is not silently ignored. */
36
+ export declare function options(partial?: Partial<Options> | null): Options;
37
+ /** Run the clean stage on `text`; returns the cleaned text and records changes.
38
+ *
39
+ * `locale` only picks the sentence that `{code_blocks: "mention"}` speaks.
40
+ */
41
+ export declare function clean(text: string, opts?: Partial<Options> | null, recorder?: Recorder, locale?: string): string;