speechready 0.2.0 → 0.3.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 CHANGED
@@ -57,7 +57,7 @@ for (const token of llmStream) {
57
57
  for (const sentence of seg.flush()) speak(sentence);
58
58
  ```
59
59
 
60
- For LiveKit Agents (Node), the same thing as a `ttsTextTransforms` entry; sentences are released as they are confirmed, and a fenced code block is handled as one piece:
60
+ For LiveKit Agents (Node), the same thing as a `ttsTextTransforms` entry. It returns the `(ReadableStream<string>) => ReadableStream<string>` the SDK chains; sentences are released as they are confirmed, and a fenced code block is handled as one piece. A complete agent is in [`examples/livekit_agent.mjs`](../examples/livekit_agent.mjs).
61
61
 
62
62
  ```js
63
63
  import { textTransform } from "speechready/livekit";
@@ -88,6 +88,15 @@ lex.toGoogleJson(); // Google Cloud TTS custom pronunciations
88
88
  await Lexicon.load("lexicon.json"); // Node only; .yaml files need the optional `yaml` package
89
89
  ```
90
90
 
91
+ One lexicon is bundled: `tr-tech`, foreign tech acronyms and names as Turkish developers say them (`API` → ey pi ay, `JSON` → ceyson). In Turkish an apostrophe suffix is refitted to the spoken form, so `API'yi` becomes `ey pi ay'ı`.
92
+
93
+ ```js
94
+ import { loadLexicon, prepare } from "speechready";
95
+
96
+ prepare("API'yi JSON ile çağır.", { locale: "tr-TR", lexicon: "tr-tech" }).text; // "ey pi ay'ı ceyson ile çağır."
97
+ const lex = loadLexicon("tr-tech"); // a Lexicon you can extend before passing it
98
+ ```
99
+
91
100
  ## Differences from the Python package
92
101
 
93
102
  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.
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { DEFAULT_OPTIONS, clean, options } from "./clean.js";
10
10
  export type { Options } from "./clean.js";
11
11
  export { Lexicon } from "./lexicon.js";
12
12
  export type { Entry, EntryInput } from "./lexicon.js";
13
+ export { listLexicons, loadLexicon } from "./lexicons/index.js";
13
14
  export { prepare } from "./pipeline.js";
14
15
  export type { Prepared, PrepareInit } from "./pipeline.js";
15
16
  export { listProfiles, loadProfile, resolveProfile } from "./profiles/index.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  export { Segmenter, pack, splitSentences } from "./chunk.js";
8
8
  export { DEFAULT_OPTIONS, clean, options } from "./clean.js";
9
9
  export { Lexicon } from "./lexicon.js";
10
+ export { listLexicons, loadLexicon } from "./lexicons/index.js";
10
11
  export { prepare } from "./pipeline.js";
11
12
  export { listProfiles, loadProfile, resolveProfile } from "./profiles/index.js";
12
13
  export { verbalize } from "./verbalize/index.js";
@@ -1,9 +1,11 @@
1
1
  /** speechready as a LiveKit Agents text transform.
2
2
  *
3
3
  * LiveKit Agents for Node passes the LLM's text stream through `ttsTextTransforms`
4
- * before synthesis. Each transform is `(AsyncIterable<string>) => AsyncIterable<string>`.
5
- * This one segments the stream into sentences as they are confirmed, runs each
6
- * through `prepare`, and yields the result.
4
+ * before synthesis. Each transform is `(text: ReadableStream<string>) => ReadableStream<string>`
5
+ * (`TextTransform` in `@livekit/agents`), and the transforms are chained, so the
6
+ * return value has to be a real `ReadableStream`. This one segments the stream into
7
+ * sentences as they are confirmed, runs each through `prepare`, and streams the result.
8
+ * It also accepts any async iterable of strings as input.
7
9
  *
8
10
  * import { textTransform } from "speechready/livekit";
9
11
  * const session = new voice.AgentSession({
@@ -16,4 +18,5 @@ export interface TextTransformInit extends Omit<PrepareInit, "limit"> {
16
18
  /** Release a long sentence at a clause boundary once the buffer holds this many characters. */
17
19
  maxBuffer?: number | null;
18
20
  }
19
- export declare function textTransform(init?: TextTransformInit): (text: AsyncIterable<string>) => AsyncIterable<string>;
21
+ export type TextInput = ReadableStream<string> | AsyncIterable<string>;
22
+ export declare function textTransform(init?: TextTransformInit): (text: TextInput) => ReadableStream<string>;
@@ -1,9 +1,11 @@
1
1
  /** speechready as a LiveKit Agents text transform.
2
2
  *
3
3
  * LiveKit Agents for Node passes the LLM's text stream through `ttsTextTransforms`
4
- * before synthesis. Each transform is `(AsyncIterable<string>) => AsyncIterable<string>`.
5
- * This one segments the stream into sentences as they are confirmed, runs each
6
- * through `prepare`, and yields the result.
4
+ * before synthesis. Each transform is `(text: ReadableStream<string>) => ReadableStream<string>`
5
+ * (`TextTransform` in `@livekit/agents`), and the transforms are chained, so the
6
+ * return value has to be a real `ReadableStream`. This one segments the stream into
7
+ * sentences as they are confirmed, runs each through `prepare`, and streams the result.
8
+ * It also accepts any async iterable of strings as input.
7
9
  *
8
10
  * import { textTransform } from "speechready/livekit";
9
11
  * const session = new voice.AgentSession({
@@ -17,9 +19,9 @@ export function textTransform(init = {}) {
17
19
  const { maxBuffer = null, ...prepareInit } = init;
18
20
  const locale = prepareInit.locale ?? "en-US";
19
21
  const spoken = (piece) => prepare(piece, prepareInit).text;
20
- return async function* transform(text) {
22
+ async function* run(text) {
21
23
  const seg = new Segmenter({ locale, maxBuffer });
22
- for await (const chunk of text) {
24
+ for await (const chunk of iterate(text)) {
23
25
  for (const piece of seg.push(chunk)) {
24
26
  const out = spoken(piece);
25
27
  if (out)
@@ -31,5 +33,39 @@ export function textTransform(init = {}) {
31
33
  if (out)
32
34
  yield out + " ";
33
35
  }
36
+ }
37
+ return function transform(text) {
38
+ const gen = run(text);
39
+ return new ReadableStream({
40
+ async pull(controller) {
41
+ const { value, done } = await gen.next();
42
+ if (done)
43
+ controller.close();
44
+ else
45
+ controller.enqueue(value);
46
+ },
47
+ async cancel() {
48
+ await gen.return(undefined);
49
+ },
50
+ });
34
51
  };
35
52
  }
53
+ /** Read a ReadableStream (which browsers do not make async-iterable) or any async iterable. */
54
+ async function* iterate(text) {
55
+ if (typeof text[Symbol.asyncIterator] === "function") {
56
+ yield* text;
57
+ return;
58
+ }
59
+ const reader = text.getReader();
60
+ try {
61
+ for (;;) {
62
+ const { value, done } = await reader.read();
63
+ if (done)
64
+ return;
65
+ yield value;
66
+ }
67
+ }
68
+ finally {
69
+ reader.releaseLock();
70
+ }
71
+ }
package/dist/lexicon.js CHANGED
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { SENTINEL_CLOSE, SENTINEL_OPEN, intent } from "./intents.js";
14
14
  import { W, escapeRe, escapeXml, re, sub } from "./util.js";
15
+ import { respellSuffix } from "./verbalize/numbers_tr.js";
16
+ const TR_SUFFIX = "(?:(['’])([a-zçğıöşü]+))?";
15
17
  export function entry(e) {
16
18
  return { ipa: "", say: "", spell: false, case_sensitive: false, ...e };
17
19
  }
@@ -71,12 +73,22 @@ export class Lexicon {
71
73
  return text;
72
74
  let work = text;
73
75
  const ordered = [...this.entries].sort((a, b) => b.word.length - a.word.length);
76
+ const turkish = this.locale.split("-")[0].toLowerCase() === "tr";
74
77
  for (const e of ordered) {
75
78
  const flags = e.case_sensitive ? "" : "i";
76
- const pattern = re(`(?<![${W}${SENTINEL_OPEN}${SENTINEL_CLOSE}])${escapeRe(e.word)}(?![${W}])`, flags);
79
+ // In Turkish a respelled word's apostrophe suffix (API'yi) is refitted to the spoken form (ey pi ay'ı).
80
+ const refit = turkish && !!e.say && !e.ipa;
81
+ const suffix = refit ? TR_SUFFIX : "";
82
+ const pattern = re(`(?<![${W}${SENTINEL_OPEN}${SENTINEL_CLOSE}])${escapeRe(e.word)}${suffix}(?![${W}])`, flags);
77
83
  work = sub(pattern, work, (m) => {
78
- intents.push(entryIntent(e, m[0]));
79
- return `${SENTINEL_OPEN}${intents.length - 1}${SENTINEL_CLOSE}`;
84
+ let word = m[0];
85
+ let tail = "";
86
+ if (refit && m[2]) {
87
+ word = word.slice(0, word.length - m[1].length - m[2].length);
88
+ tail = m[1] + respellSuffix(e.say, m[2]);
89
+ }
90
+ intents.push(entryIntent(e, word));
91
+ return `${SENTINEL_OPEN}${intents.length - 1}${SENTINEL_CLOSE}${tail}`;
80
92
  });
81
93
  }
82
94
  return work;
@@ -0,0 +1,4 @@
1
+ export declare const LEXICON_DATA: Record<string, {
2
+ locale?: string;
3
+ entries?: Record<string, unknown>[];
4
+ }>;
@@ -0,0 +1,293 @@
1
+ // Generated by scripts/build-profiles.mjs from spec/lexicons. Do not edit.
2
+ export const LEXICON_DATA = {
3
+ "tr-tech": {
4
+ "locale": "tr-TR",
5
+ "entries": [
6
+ {
7
+ "word": "API",
8
+ "say": "ey pi ay"
9
+ },
10
+ {
11
+ "word": "URL",
12
+ "say": "yu ar el"
13
+ },
14
+ {
15
+ "word": "URI",
16
+ "say": "yu ar ay"
17
+ },
18
+ {
19
+ "word": "AWS",
20
+ "say": "ey dabılyu es"
21
+ },
22
+ {
23
+ "word": "GCP",
24
+ "say": "ci si pi"
25
+ },
26
+ {
27
+ "word": "CPU",
28
+ "say": "si pi yu"
29
+ },
30
+ {
31
+ "word": "GPU",
32
+ "say": "ci pi yu"
33
+ },
34
+ {
35
+ "word": "SSD",
36
+ "say": "es es di"
37
+ },
38
+ {
39
+ "word": "HTML",
40
+ "say": "eyç ti em el"
41
+ },
42
+ {
43
+ "word": "CSS",
44
+ "say": "si es es"
45
+ },
46
+ {
47
+ "word": "SQL",
48
+ "say": "es kyu el"
49
+ },
50
+ {
51
+ "word": "PHP",
52
+ "say": "pi eyç pi"
53
+ },
54
+ {
55
+ "word": "HTTP",
56
+ "say": "eyç ti ti pi"
57
+ },
58
+ {
59
+ "word": "HTTPS",
60
+ "say": "eyç ti ti pi es"
61
+ },
62
+ {
63
+ "word": "JS",
64
+ "say": "cey es"
65
+ },
66
+ {
67
+ "word": "TS",
68
+ "say": "ti es"
69
+ },
70
+ {
71
+ "word": "UI",
72
+ "say": "yu ay"
73
+ },
74
+ {
75
+ "word": "UX",
76
+ "say": "yu eks"
77
+ },
78
+ {
79
+ "word": "AI",
80
+ "say": "ey ay"
81
+ },
82
+ {
83
+ "word": "ML",
84
+ "say": "em el"
85
+ },
86
+ {
87
+ "word": "LLM",
88
+ "say": "el el em"
89
+ },
90
+ {
91
+ "word": "GPT",
92
+ "say": "ci pi ti"
93
+ },
94
+ {
95
+ "word": "OCR",
96
+ "say": "o si ar"
97
+ },
98
+ {
99
+ "word": "PDF",
100
+ "say": "pi di ef"
101
+ },
102
+ {
103
+ "word": "CSV",
104
+ "say": "si es vi"
105
+ },
106
+ {
107
+ "word": "XML",
108
+ "say": "eks em el"
109
+ },
110
+ {
111
+ "word": "SDK",
112
+ "say": "es di key"
113
+ },
114
+ {
115
+ "word": "CLI",
116
+ "say": "si el ay"
117
+ },
118
+ {
119
+ "word": "IDE",
120
+ "say": "ay di i"
121
+ },
122
+ {
123
+ "word": "DNS",
124
+ "say": "di en es"
125
+ },
126
+ {
127
+ "word": "IP",
128
+ "say": "ay pi"
129
+ },
130
+ {
131
+ "word": "TCP",
132
+ "say": "ti si pi"
133
+ },
134
+ {
135
+ "word": "UDP",
136
+ "say": "yu di pi"
137
+ },
138
+ {
139
+ "word": "SSH",
140
+ "say": "es es eyç"
141
+ },
142
+ {
143
+ "word": "SSL",
144
+ "say": "es es el"
145
+ },
146
+ {
147
+ "word": "TLS",
148
+ "say": "ti el es"
149
+ },
150
+ {
151
+ "word": "VPN",
152
+ "say": "vi pi en"
153
+ },
154
+ {
155
+ "word": "VM",
156
+ "say": "vi em"
157
+ },
158
+ {
159
+ "word": "DB",
160
+ "say": "di bi"
161
+ },
162
+ {
163
+ "word": "ORM",
164
+ "say": "o ar em"
165
+ },
166
+ {
167
+ "word": "JWT",
168
+ "say": "cey dabılyu ti"
169
+ },
170
+ {
171
+ "word": "NPM",
172
+ "say": "en pi em"
173
+ },
174
+ {
175
+ "word": "UUID",
176
+ "say": "yu yu ay di"
177
+ },
178
+ {
179
+ "word": "CDN",
180
+ "say": "si di en"
181
+ },
182
+ {
183
+ "word": "TTS",
184
+ "say": "ti ti es"
185
+ },
186
+ {
187
+ "word": "STT",
188
+ "say": "es ti ti"
189
+ },
190
+ {
191
+ "word": "ASR",
192
+ "say": "ey es ar"
193
+ },
194
+ {
195
+ "word": "NLP",
196
+ "say": "en el pi"
197
+ },
198
+ {
199
+ "word": "MCP",
200
+ "say": "em si pi"
201
+ },
202
+ {
203
+ "word": "QA",
204
+ "say": "kyu ey"
205
+ },
206
+ {
207
+ "word": "PR",
208
+ "say": "pi ar"
209
+ },
210
+ {
211
+ "word": "CI",
212
+ "say": "si ay"
213
+ },
214
+ {
215
+ "word": "CD",
216
+ "say": "si di"
217
+ },
218
+ {
219
+ "word": "CRM",
220
+ "say": "si ar em"
221
+ },
222
+ {
223
+ "word": "ERP",
224
+ "say": "i ar pi"
225
+ },
226
+ {
227
+ "word": "SEO",
228
+ "say": "es i o"
229
+ },
230
+ {
231
+ "word": "KPI",
232
+ "say": "key pi ay"
233
+ },
234
+ {
235
+ "word": "CEO",
236
+ "say": "si i o"
237
+ },
238
+ {
239
+ "word": "CTO",
240
+ "say": "si ti o"
241
+ },
242
+ {
243
+ "word": "OS",
244
+ "say": "o es"
245
+ },
246
+ {
247
+ "word": "PC",
248
+ "say": "pi si"
249
+ },
250
+ {
251
+ "word": "iOS",
252
+ "say": "ay o es"
253
+ },
254
+ {
255
+ "word": "IT",
256
+ "say": "ay ti",
257
+ "case_sensitive": true
258
+ },
259
+ {
260
+ "word": "JSON",
261
+ "say": "ceyson"
262
+ },
263
+ {
264
+ "word": "GitHub",
265
+ "say": "githab"
266
+ },
267
+ {
268
+ "word": "Python",
269
+ "say": "paytın"
270
+ },
271
+ {
272
+ "word": "WiFi",
273
+ "say": "vayfay"
274
+ },
275
+ {
276
+ "word": "Wi-Fi",
277
+ "say": "vayfay"
278
+ },
279
+ {
280
+ "word": "Java",
281
+ "say": "cava"
282
+ },
283
+ {
284
+ "word": "Excel",
285
+ "say": "eksel"
286
+ },
287
+ {
288
+ "word": "Chrome",
289
+ "say": "krom"
290
+ }
291
+ ]
292
+ }
293
+ };
@@ -0,0 +1,10 @@
1
+ /** Lexicons bundled with the package, from the repository's `spec/lexicons`.
2
+ *
3
+ * prepare("API'yi çağır.", { locale: "tr-TR", lexicon: loadLexicon("tr-tech") }).text
4
+ * prepare("API'yi çağır.", { locale: "tr-TR", lexicon: "tr-tech" }).text // the same, by name
5
+ */
6
+ import { Lexicon } from "../lexicon.js";
7
+ /** Names of the bundled lexicons, e.g. ["tr-tech"]. */
8
+ export declare function listLexicons(): string[];
9
+ /** A bundled lexicon by name; a new object each call, so it can be extended. */
10
+ export declare function loadLexicon(name: string): Lexicon;
@@ -0,0 +1,18 @@
1
+ /** Lexicons bundled with the package, from the repository's `spec/lexicons`.
2
+ *
3
+ * prepare("API'yi çağır.", { locale: "tr-TR", lexicon: loadLexicon("tr-tech") }).text
4
+ * prepare("API'yi çağır.", { locale: "tr-TR", lexicon: "tr-tech" }).text // the same, by name
5
+ */
6
+ import { Lexicon } from "../lexicon.js";
7
+ import { LEXICON_DATA } from "./data.js";
8
+ /** Names of the bundled lexicons, e.g. ["tr-tech"]. */
9
+ export function listLexicons() {
10
+ return Object.keys(LEXICON_DATA).sort();
11
+ }
12
+ /** A bundled lexicon by name; a new object each call, so it can be extended. */
13
+ export function loadLexicon(name) {
14
+ const data = LEXICON_DATA[name];
15
+ if (!data)
16
+ throw new Error(`unknown bundled lexicon ${JSON.stringify(name)}; known: ${listLexicons().join(", ")}`);
17
+ return Lexicon.fromObject({ locale: data.locale, entries: (data.entries ?? []) });
18
+ }
@@ -24,8 +24,8 @@ export interface PrepareInit {
24
24
  /** Overrides the profile's request limit (null = no limit, undefined = the profile's). */
25
25
  limit?: number | null;
26
26
  options?: Partial<Options> | null;
27
- /** A Lexicon, or its entries; its words are pronounced as it says. */
28
- lexicon?: Lexicon | EntryInput[] | null;
27
+ /** A Lexicon, its entries, or the name of a bundled lexicon ("tr-tech"); its words are pronounced as it says. */
28
+ lexicon?: Lexicon | EntryInput[] | string | null;
29
29
  }
30
30
  /** Prepare `text` for the given provider and model.
31
31
  *
package/dist/pipeline.js CHANGED
@@ -5,6 +5,7 @@ import { clean } from "./clean.js";
5
5
  import { compileText } from "./compile.js";
6
6
  import { extract, sentinelPattern } from "./intents.js";
7
7
  import { Lexicon } from "./lexicon.js";
8
+ import { loadLexicon } from "./lexicons/index.js";
8
9
  import { resolveProfile } from "./profiles/index.js";
9
10
  import { sub } from "./util.js";
10
11
  import { verbalize } from "./verbalize/index.js";
@@ -16,7 +17,9 @@ import "./verbalize/tr.js";
16
17
  */
17
18
  export function prepare(text, init = {}) {
18
19
  const locale = init.locale ?? "en-US";
19
- const lex = Array.isArray(init.lexicon) ? Lexicon.fromEntries(init.lexicon, locale) : init.lexicon ?? null;
20
+ const lex = typeof init.lexicon === "string" ? loadLexicon(init.lexicon)
21
+ : Array.isArray(init.lexicon) ? Lexicon.fromEntries(init.lexicon, locale)
22
+ : init.lexicon ?? null;
20
23
  const prof = init.profile ?? resolveProfile(init.provider ?? null, init.model ?? null);
21
24
  const rec = new Recorder();
22
25
  let [work, intents] = extract(text);
@@ -16,6 +16,12 @@ export declare function soften(word: string): string;
16
16
  export declare function attach(word: string, suffix: string): string;
17
17
  /** Re-harmonise a suffix written against an abbreviation (TL'ye, 1/4'ü) for the spoken word (liraya, çeyreği). */
18
18
  export declare function reharmonize(word: string, suffix: string): string;
19
+ /** The suffix a writer attached to an abbreviation (API'yi), refitted to how the word is said (ey pi ay -> 'ı).
20
+ *
21
+ * Harmony follows the last spoken word, and the buffer consonant follows whether it
22
+ * ends in a vowel: GPU'yu -> ci pi yu'yu, API'si -> ey pi ay'ı.
23
+ */
24
+ export declare function respellSuffix(word: string, suffix: string): string;
19
25
  /** üçüncü, dördüncü, yirmi birinci. */
20
26
  export declare function ordinal(n: number): string;
21
27
  /** üç -> üçte, dört -> dörtte, on -> onda, beş -> beşte. */
@@ -99,6 +99,25 @@ export function reharmonize(word, suffix) {
99
99
  const four = harmonyI(word);
100
100
  return Array.from(suffix).map((ch) => ("ae".includes(ch) ? two : "ıiuü".includes(ch) ? four : ch)).join("");
101
101
  }
102
+ /** The suffix a writer attached to an abbreviation (API'yi), refitted to how the word is said (ey pi ay -> 'ı).
103
+ *
104
+ * Harmony follows the last spoken word, and the buffer consonant follows whether it
105
+ * ends in a vowel: GPU'yu -> ci pi yu'yu, API'si -> ey pi ay'ı.
106
+ */
107
+ export function respellSuffix(word, suffix) {
108
+ if (!suffix)
109
+ return suffix;
110
+ const last = word.slice(word.lastIndexOf(" ") + 1);
111
+ let s = reharmonize(last, suffix);
112
+ const endsInVowel = VOWELS.includes(last[last.length - 1]);
113
+ if ("sy".includes(s[0]) && s.length > 1 && VOWELS.includes(s[1]) && !endsInVowel) {
114
+ s = s.slice(1);
115
+ }
116
+ else if (VOWELS.includes(s[0]) && endsInVowel) {
117
+ s = "y" + s;
118
+ }
119
+ return s;
120
+ }
102
121
  /** üçüncü, dördüncü, yirmi birinci. */
103
122
  export function ordinal(n) {
104
123
  const words = cardinal(n);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.2.0";
1
+ export declare const version = "0.3.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-profiles.mjs from package.json. Do not edit.
2
- export const version = "0.2.0";
2
+ export const version = "0.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speechready",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Prepare text for text-to-speech: clean LLM output, verbalize ambiguous tokens, compile pauses to each provider's syntax, chunk under model limits.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",