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.
@@ -0,0 +1,131 @@
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 { SENTINEL_CLOSE, SENTINEL_OPEN, intent } from "./intents.js";
14
+ import { W, escapeRe, escapeXml, re, sub } from "./util.js";
15
+ export function entry(e) {
16
+ return { ipa: "", say: "", spell: false, case_sensitive: false, ...e };
17
+ }
18
+ function entryIntent(e, matched) {
19
+ if (e.spell && !e.ipa && !e.say)
20
+ return intent("spell", { text: matched });
21
+ return intent("phoneme", { text: matched, ipa: e.ipa, say: e.say });
22
+ }
23
+ export class Lexicon {
24
+ entries;
25
+ locale;
26
+ constructor(entries = [], locale = "en-US") {
27
+ this.entries = entries.map(entry);
28
+ this.locale = locale;
29
+ }
30
+ // --- construction ---------------------------------------------------------------
31
+ static fromEntries(entries, locale = "en-US") {
32
+ return new Lexicon(entries, locale);
33
+ }
34
+ /** Build from a parsed lexicon document (`{locale, entries}`), e.g. `JSON.parse` of a lexicon file. */
35
+ static fromObject(data) {
36
+ return new Lexicon(data.entries ?? [], data.locale ?? "en-US");
37
+ }
38
+ /** Read a `.json` lexicon file, or a `.yaml` one when the optional `yaml` package is installed. Node only. */
39
+ static async load(path) {
40
+ const fs = await import("node:fs/promises");
41
+ const text = await fs.readFile(path, "utf-8");
42
+ if (/\.ya?ml$/i.test(path)) {
43
+ let yaml;
44
+ try {
45
+ yaml = (await import("yaml"));
46
+ }
47
+ catch {
48
+ throw new Error("reading a YAML lexicon needs the `yaml` package (npm install yaml); JSON lexicons need nothing");
49
+ }
50
+ return Lexicon.fromObject(yaml.parse(text));
51
+ }
52
+ return Lexicon.fromObject(JSON.parse(text));
53
+ }
54
+ toObject() {
55
+ return {
56
+ locale: this.locale,
57
+ entries: this.entries.map((e) => {
58
+ const out = {};
59
+ for (const [k, v] of Object.entries({ word: e.word, ipa: e.ipa, say: e.say, spell: e.spell, case_sensitive: e.case_sensitive })) {
60
+ if (v)
61
+ out[k] = v;
62
+ }
63
+ return out;
64
+ }),
65
+ };
66
+ }
67
+ // --- applying to text ------------------------------------------------------------
68
+ /** Replace every lexicon word in `text` with an intent sentinel; appends to `intents`. */
69
+ apply(text, intents) {
70
+ if (!this.entries.length)
71
+ return text;
72
+ let work = text;
73
+ const ordered = [...this.entries].sort((a, b) => b.word.length - a.word.length);
74
+ for (const e of ordered) {
75
+ const flags = e.case_sensitive ? "" : "i";
76
+ const pattern = re(`(?<![${W}${SENTINEL_OPEN}${SENTINEL_CLOSE}])${escapeRe(e.word)}(?![${W}])`, flags);
77
+ work = sub(pattern, work, (m) => {
78
+ intents.push(entryIntent(e, m[0]));
79
+ return `${SENTINEL_OPEN}${intents.length - 1}${SENTINEL_CLOSE}`;
80
+ });
81
+ }
82
+ return work;
83
+ }
84
+ // --- export ----------------------------------------------------------------------------
85
+ /** Pronunciation Lexicon Specification XML, accepted by ElevenLabs, Azure and Amazon Polly. */
86
+ toPls(alphabet = "ipa") {
87
+ const lines = [
88
+ '<?xml version="1.0" encoding="UTF-8"?>',
89
+ `<lexicon version="1.0" xmlns="http://www.w3.org/2005/01/pronunciation-lexicon" alphabet="${alphabet}" xml:lang="${this.locale}">`,
90
+ ];
91
+ for (const e of this.entries) {
92
+ lines.push(" <lexeme>");
93
+ lines.push(` <grapheme>${escapeXml(e.word)}</grapheme>`);
94
+ if (e.ipa)
95
+ lines.push(` <phoneme>${escapeXml(e.ipa)}</phoneme>`);
96
+ else if (e.say)
97
+ lines.push(` <alias>${escapeXml(e.say)}</alias>`);
98
+ else if (e.spell)
99
+ lines.push(` <alias>${escapeXml(Array.from(e.word).join(" "))}</alias>`);
100
+ lines.push(" </lexeme>");
101
+ }
102
+ lines.push("</lexicon>");
103
+ return lines.join("\n") + "\n";
104
+ }
105
+ /** Google Cloud TTS custom pronunciations (v1beta1 `customPronunciations`). IPA entries only. */
106
+ toGoogleJson() {
107
+ const items = this.entries
108
+ .filter((e) => e.ipa)
109
+ .map((e) => ({ phrase: e.word, phoneticEncoding: "PHONETIC_ENCODING_IPA", pronunciation: e.ipa }));
110
+ return JSON.stringify({ pronunciations: items }, null, 2) + "\n";
111
+ }
112
+ toJson() {
113
+ return JSON.stringify(this.toObject(), null, 2) + "\n";
114
+ }
115
+ /** The upload file for a provider profile, or null when the provider takes no lexicon. */
116
+ exportFor(profile) {
117
+ if (profile.lexiconFormat === "none")
118
+ return null;
119
+ return this.export(profile.lexiconFormat, profile.lexiconAlphabet);
120
+ }
121
+ /** `pls`, `google`, or `json`. */
122
+ export(fmt, alphabet = "ipa") {
123
+ if (fmt === "pls")
124
+ return this.toPls(alphabet);
125
+ if (fmt === "google")
126
+ return this.toGoogleJson();
127
+ if (fmt === "json")
128
+ return this.toJson();
129
+ throw new Error(`unknown lexicon format ${JSON.stringify(fmt)}; use pls, google or json`);
130
+ }
131
+ }
@@ -0,0 +1,34 @@
1
+ /** The pipeline: clean -> verbalize -> compile -> chunk, with a change log. */
2
+ import { Change } from "./changes.js";
3
+ import { Options } from "./clean.js";
4
+ import { EntryInput, Lexicon } from "./lexicon.js";
5
+ import { Profile } from "./profiles/index.js";
6
+ import "./verbalize/en.js";
7
+ import "./verbalize/tr.js";
8
+ export interface Prepared {
9
+ /** The full compiled text. */
10
+ text: string;
11
+ /** The same text split under the model's request limit. */
12
+ chunks: string[];
13
+ /** Every rewrite: rule, before, after, note. */
14
+ changes: Change[];
15
+ profile: Profile;
16
+ locale: string;
17
+ }
18
+ export interface PrepareInit {
19
+ provider?: string | null;
20
+ model?: string | null;
21
+ /** A profile picked directly; overrides provider and model. */
22
+ profile?: Profile | null;
23
+ locale?: string;
24
+ /** Overrides the profile's request limit (null = no limit, undefined = the profile's). */
25
+ limit?: number | null;
26
+ options?: Partial<Options> | null;
27
+ /** A Lexicon, or its entries; its words are pronounced as it says. */
28
+ lexicon?: Lexicon | EntryInput[] | null;
29
+ }
30
+ /** Prepare `text` for the given provider and model.
31
+ *
32
+ * Returns the full compiled text, the request-sized chunks, and every change made.
33
+ */
34
+ export declare function prepare(text: string, init?: PrepareInit): Prepared;
@@ -0,0 +1,49 @@
1
+ /** The pipeline: clean -> verbalize -> compile -> chunk, with a change log. */
2
+ import { Recorder } from "./changes.js";
3
+ import { pack, splitSentences } from "./chunk.js";
4
+ import { clean } from "./clean.js";
5
+ import { compileText } from "./compile.js";
6
+ import { extract, sentinelPattern } from "./intents.js";
7
+ import { Lexicon } from "./lexicon.js";
8
+ import { resolveProfile } from "./profiles/index.js";
9
+ import { sub } from "./util.js";
10
+ import { verbalize } from "./verbalize/index.js";
11
+ import "./verbalize/en.js";
12
+ import "./verbalize/tr.js";
13
+ /** Prepare `text` for the given provider and model.
14
+ *
15
+ * Returns the full compiled text, the request-sized chunks, and every change made.
16
+ */
17
+ export function prepare(text, init = {}) {
18
+ const locale = init.locale ?? "en-US";
19
+ const lex = Array.isArray(init.lexicon) ? Lexicon.fromEntries(init.lexicon, locale) : init.lexicon ?? null;
20
+ const prof = init.profile ?? resolveProfile(init.provider ?? null, init.model ?? null);
21
+ const rec = new Recorder();
22
+ let [work, intents] = extract(text);
23
+ work = clean(work, init.options, rec, locale);
24
+ [work, intents] = extract(work, intents);
25
+ if (lex) {
26
+ const before = work;
27
+ work = lex.apply(work, intents);
28
+ rec.add("lexicon", "lexicon.apply", before, work, "lexicon words marked for pronunciation");
29
+ }
30
+ work = verbalize(work, locale, rec, init.options);
31
+ work = compileText(work, intents, prof, rec);
32
+ const maxSize = init.limit !== undefined ? init.limit : prof.limitFor(init.model ?? null);
33
+ const sentences = splitSentences(work, locale);
34
+ const chunks = pack(sentences, maxSize, prof.limitUnit, " ");
35
+ return { text: work, chunks, changes: readable(rec.changes, intents), profile: prof, locale };
36
+ }
37
+ function intentTag(intent) {
38
+ if (intent.kind === "pause")
39
+ return `[pause ${intent.ms}ms]`;
40
+ if (intent.kind === "phoneme")
41
+ return `[ipa ${intent.text} ${intent.ipa || intent.say}]`;
42
+ return `[${intent.kind} ${intent.text}]`;
43
+ }
44
+ /** Show intents in the change log as the tags the developer wrote, not as sentinels. */
45
+ function readable(changes, intents) {
46
+ const pattern = sentinelPattern();
47
+ const fix = (text) => sub(pattern, text, (m) => intentTag(intents[parseInt(m[1], 10)]));
48
+ return changes.map((c) => ({ ...c, before: fix(c.before), after: fix(c.after) }));
49
+ }
@@ -0,0 +1 @@
1
+ export declare const PROFILE_DATA: Record<string, unknown>[];
@@ -0,0 +1,411 @@
1
+ // Generated by scripts/build-profiles.mjs from spec/profiles. Do not edit.
2
+ export const PROFILE_DATA = [
3
+ {
4
+ "id": "amazon-polly",
5
+ "provider": "polly",
6
+ "models": [
7
+ "neural",
8
+ "generative",
9
+ "long-form",
10
+ "standard"
11
+ ],
12
+ "default": true,
13
+ "syntax": "ssml",
14
+ "escape": "xml",
15
+ "audio_tags": false,
16
+ "limit": {
17
+ "unit": "chars",
18
+ "max": 3000
19
+ },
20
+ "intents": {
21
+ "pause": {
22
+ "render": "<break time=\"{ms}ms\"/>",
23
+ "min_ms": 0,
24
+ "max_ms": 10000
25
+ },
26
+ "spell": {
27
+ "render": "spaced"
28
+ },
29
+ "emphasis": {
30
+ "render": "{text}"
31
+ },
32
+ "phoneme": {
33
+ "render": "<phoneme alphabet=\"ipa\" ph=\"{ipa}\">{text}</phoneme>"
34
+ }
35
+ },
36
+ "lexicon": {
37
+ "format": "pls",
38
+ "alphabet": "ipa"
39
+ },
40
+ "notes": "Output is the SSML body for `TextType=ssml`; the caller wraps it in <speak>. SynthesizeSpeech takes at most 3,000 billed characters (6,000 including tags); the profile counts everything, so 3,000 is safe. <break> caps at 10 seconds. <emphasis> is unavailable on neural, long-form and generative voices, so emphasis is rendered as plain text. <say-as interpret-as=\"characters\"> silently switches a neural voice to its standard counterpart for that sentence, so spelling is rendered as spaced characters instead. <phoneme> with IPA is supported (partially on generative). Lexicons are uploaded as PLS with PutLexicon.\n",
41
+ "sources": [
42
+ "https://docs.aws.amazon.com/polly/latest/dg/API_SynthesizeSpeech.html",
43
+ "https://docs.aws.amazon.com/polly/latest/dg/supportedtags.html",
44
+ "https://docs.aws.amazon.com/polly/latest/dg/break-tag.html",
45
+ "https://docs.aws.amazon.com/polly/latest/dg/say-as-tag.html"
46
+ ],
47
+ "verified": "2026-10-29"
48
+ },
49
+ {
50
+ "id": "azure-neural",
51
+ "provider": "azure",
52
+ "models": [
53
+ "neural",
54
+ "hd"
55
+ ],
56
+ "default": true,
57
+ "syntax": "ssml",
58
+ "escape": "xml",
59
+ "audio_tags": false,
60
+ "limit": {
61
+ "unit": "chars",
62
+ "max": 8000
63
+ },
64
+ "intents": {
65
+ "pause": {
66
+ "render": "<break time=\"{ms}ms\" />",
67
+ "min_ms": 0,
68
+ "max_ms": 20000
69
+ },
70
+ "spell": {
71
+ "render": "<say-as interpret-as=\"characters\">{text}</say-as>"
72
+ },
73
+ "emphasis": {
74
+ "render": "<emphasis level=\"strong\">{text}</emphasis>"
75
+ },
76
+ "phoneme": {
77
+ "render": "<phoneme alphabet=\"ipa\" ph=\"{ipa}\">{text}</phoneme>"
78
+ }
79
+ },
80
+ "lexicon": {
81
+ "format": "pls",
82
+ "alphabet": "ipa"
83
+ },
84
+ "notes": "Output is the SSML body: text with break, say-as and emphasis elements, with & < > escaped. The caller wraps it in <speak> and <voice>, since the voice name is theirs to choose. Azure caps audio at 10 minutes per request and the WebSocket SSML message at 64 KB; 8,000 characters stays well inside both at normal speaking rates. HD voices support a subset of SSML; check the HD page.\n",
85
+ "sources": [
86
+ "https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-structure",
87
+ "https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-quotas-and-limits"
88
+ ],
89
+ "verified": "2026-10-28"
90
+ },
91
+ {
92
+ "id": "cartesia-sonic",
93
+ "provider": "cartesia",
94
+ "models": [
95
+ "sonic-3.6",
96
+ "sonic-3.5",
97
+ "sonic-3",
98
+ "sonic-latest",
99
+ "sonic-2",
100
+ "sonic-turbo"
101
+ ],
102
+ "default": true,
103
+ "syntax": "plain",
104
+ "escape": "none",
105
+ "audio_tags": false,
106
+ "limit": {
107
+ "unit": "chars",
108
+ "max": null
109
+ },
110
+ "intents": {
111
+ "pause": {
112
+ "render": "<break time=\"{ms}ms\"/>",
113
+ "min_ms": 0
114
+ },
115
+ "spell": {
116
+ "render": "<spell>{text}</spell>"
117
+ },
118
+ "emphasis": {
119
+ "render": "{text}"
120
+ }
121
+ },
122
+ "lexicon": {
123
+ "format": "none"
124
+ },
125
+ "notes": "Sonic takes plain text with a small set of SSML-like tags, not SSML: <break time=\"500ms\"/>, <spell>ABC123</spell>, and speed, volume and emotion tags on sonic-3 and newer. Each tag counts as one character. Cartesia publishes no per-request transcript limit; its own guidance for agents is one sentence per request, which the streaming segmenter gives you. Cartesia runs its own text normalizer (request field `normalization`, default `auto`); its docs recommend pre-normalizing and setting `normalization: off` when you need full control of the spoken form, which is exactly what this library's output is for. Pronunciations are managed server-side through `pronunciation_dict_id`, so there is no lexicon export.\n",
126
+ "sources": [
127
+ "https://docs.cartesia.ai/api-reference/tts/bytes",
128
+ "https://docs.cartesia.ai/build-with-cartesia/capability-guides/ssml-tags",
129
+ "https://docs.cartesia.ai/build-with-cartesia/capability-guides/text-normalization",
130
+ "https://docs.cartesia.ai/build-with-cartesia/tts-models/latest"
131
+ ],
132
+ "verified": "2026-10-29"
133
+ },
134
+ {
135
+ "id": "deepgram-aura",
136
+ "provider": "deepgram",
137
+ "models": [
138
+ "aura-2",
139
+ "aura"
140
+ ],
141
+ "default": true,
142
+ "syntax": "plain",
143
+ "escape": "none",
144
+ "audio_tags": false,
145
+ "limit": {
146
+ "unit": "chars",
147
+ "max": 2000
148
+ },
149
+ "intents": {
150
+ "pause": {
151
+ "render": "punctuation"
152
+ },
153
+ "spell": {
154
+ "render": "spaced"
155
+ },
156
+ "emphasis": {
157
+ "render": "{text}"
158
+ }
159
+ },
160
+ "lexicon": {
161
+ "format": "none"
162
+ },
163
+ "notes": "Plain text only: no SSML, no pause or pronunciation markup, prosody only through the `speed` query parameter. Aura-2 covers English, Spanish, German, French, Dutch, Italian and Japanese; no Turkish. The 2,000 character cap is the figure Deepgram's REST reference carried at the Aura launch; the current pages state no number and their chunking guide works in 100 to 200 character sentence pieces, so treat 2,000 as the ceiling and stream sentences in practice.\n",
164
+ "sources": [
165
+ "https://developers.deepgram.com/reference/text-to-speech/speak-request",
166
+ "https://developers.deepgram.com/docs/tts-models",
167
+ "https://developers.deepgram.com/docs/text-chunking-for-tts-optimization"
168
+ ],
169
+ "verified": "2026-10-29"
170
+ },
171
+ {
172
+ "id": "elevenlabs-v2",
173
+ "provider": "elevenlabs",
174
+ "models": [
175
+ "eleven_multilingual_v2",
176
+ "eleven_flash_v2_5",
177
+ "eleven_flash_v2",
178
+ "eleven_turbo_v2_5",
179
+ "eleven_turbo_v2",
180
+ "eleven_multilingual_v1",
181
+ "eleven_monolingual_v1"
182
+ ],
183
+ "default": true,
184
+ "syntax": "plain",
185
+ "escape": "none",
186
+ "audio_tags": false,
187
+ "limit": {
188
+ "unit": "chars",
189
+ "max": 10000,
190
+ "per_model": {
191
+ "eleven_flash_v2_5": 40000,
192
+ "eleven_flash_v2": 30000
193
+ }
194
+ },
195
+ "intents": {
196
+ "pause": {
197
+ "render": "<break time=\"{s}s\" />",
198
+ "min_ms": 100,
199
+ "max_ms": 3000
200
+ },
201
+ "spell": {
202
+ "render": "spaced"
203
+ },
204
+ "emphasis": {
205
+ "render": "{text}"
206
+ }
207
+ },
208
+ "lexicon": {
209
+ "format": "pls",
210
+ "alphabet": "ipa"
211
+ },
212
+ "notes": "Models before v3 accept the SSML break tag and nothing else from SSML. Pauses are capped at 3 seconds; the docs warn that many breaks in one request can destabilise output, so keep them few. Dashes and ellipses are the documented fallback. Character limits per model come from the models overview page; turbo models list none there, so they inherit the 10,000 default.\n",
213
+ "sources": [
214
+ "https://elevenlabs.io/docs/product/prompting/pauses",
215
+ "https://elevenlabs.io/docs/overview/models"
216
+ ],
217
+ "verified": "2026-10-27"
218
+ },
219
+ {
220
+ "id": "elevenlabs-v3",
221
+ "provider": "elevenlabs",
222
+ "models": [
223
+ "eleven_v3",
224
+ "eleven_v3_conversational",
225
+ "eleven_v4",
226
+ "eleven_v4_turbo"
227
+ ],
228
+ "default": false,
229
+ "syntax": "plain",
230
+ "escape": "none",
231
+ "audio_tags": true,
232
+ "limit": {
233
+ "unit": "chars",
234
+ "max": 5000,
235
+ "per_model": {
236
+ "eleven_v4": 10000
237
+ }
238
+ },
239
+ "intents": {
240
+ "pause": {
241
+ "render": "punctuation"
242
+ },
243
+ "spell": {
244
+ "render": "spaced"
245
+ },
246
+ "emphasis": {
247
+ "render": "{text}"
248
+ }
249
+ },
250
+ "lexicon": {
251
+ "format": "pls",
252
+ "alphabet": "ipa"
253
+ },
254
+ "notes": "v3 and v4 do not accept SSML break tags. Pacing comes from punctuation and audio tags such as [short pause]; unknown bracket tags made of words pass through for these models. Limits: eleven_v3 5,000 characters, eleven_v4 10,000; the conversational and turbo variants list none, so they take the 5,000 default until verified.\n",
255
+ "sources": [
256
+ "https://elevenlabs.io/docs/product/prompting/pauses",
257
+ "https://elevenlabs.io/docs/overview/models"
258
+ ],
259
+ "verified": "2026-10-27"
260
+ },
261
+ {
262
+ "id": "google-chirp3",
263
+ "provider": "google",
264
+ "models": [
265
+ "chirp3-hd",
266
+ "chirp-hd",
267
+ "gemini-tts"
268
+ ],
269
+ "default": true,
270
+ "syntax": "plain",
271
+ "escape": "none",
272
+ "audio_tags": false,
273
+ "limit": {
274
+ "unit": "bytes",
275
+ "max": 5000
276
+ },
277
+ "intents": {
278
+ "pause": {
279
+ "render": "punctuation"
280
+ },
281
+ "spell": {
282
+ "render": "spaced"
283
+ },
284
+ "emphasis": {
285
+ "render": "{text}"
286
+ }
287
+ },
288
+ "lexicon": {
289
+ "format": "google",
290
+ "alphabet": "ipa"
291
+ },
292
+ "notes": "Chirp 3 HD takes plain text in `input.text`; SSML on these voices is a preview that works only for synchronous requests, so this profile stays plain. Pauses can be steered with `[pause]`, `[pause short]` and `[pause long]`, but only in the `input.markup` field, never in `text`, so the profile renders pauses as punctuation and leaves markup to callers who use that field. Pronunciations go in the request's `custom_pronunciations` list (IPA or X-SAMPA), which is what the google lexicon export produces. The request body is capped at 5,000 bytes.\n",
293
+ "sources": [
294
+ "https://docs.cloud.google.com/text-to-speech/docs/chirp3-hd",
295
+ "https://docs.cloud.google.com/text-to-speech/quotas"
296
+ ],
297
+ "verified": "2026-10-29"
298
+ },
299
+ {
300
+ "id": "google-ssml",
301
+ "provider": "google",
302
+ "models": [
303
+ "neural2",
304
+ "wavenet",
305
+ "studio",
306
+ "standard",
307
+ "polyglot"
308
+ ],
309
+ "default": false,
310
+ "syntax": "ssml",
311
+ "escape": "xml",
312
+ "audio_tags": false,
313
+ "limit": {
314
+ "unit": "bytes",
315
+ "max": 5000
316
+ },
317
+ "intents": {
318
+ "pause": {
319
+ "render": "<break time=\"{ms}ms\"/>",
320
+ "min_ms": 0
321
+ },
322
+ "spell": {
323
+ "render": "<say-as interpret-as=\"characters\">{text}</say-as>"
324
+ },
325
+ "emphasis": {
326
+ "render": "<emphasis level=\"moderate\">{text}</emphasis>"
327
+ },
328
+ "phoneme": {
329
+ "render": "<phoneme alphabet=\"ipa\" ph=\"{ipa}\">{text}</phoneme>"
330
+ }
331
+ },
332
+ "lexicon": {
333
+ "format": "none"
334
+ },
335
+ "notes": "The classic voice families take full SSML in `input.ssml`. Output is the body; the caller wraps it in <speak>. Studio voices ignore <emphasis>, <mark>, <prosody pitch> and <lang>. Google documents no upper bound for <break time>. The 5,000-byte cap counts the SSML tags too. There is no lexicon upload for these voices, so lexicon entries are rendered inline as <phoneme> tags.\n",
336
+ "sources": [
337
+ "https://docs.cloud.google.com/text-to-speech/docs/ssml",
338
+ "https://docs.cloud.google.com/text-to-speech/docs/list-voices-and-types",
339
+ "https://docs.cloud.google.com/text-to-speech/quotas"
340
+ ],
341
+ "verified": "2026-10-29"
342
+ },
343
+ {
344
+ "id": "openai-tts",
345
+ "provider": "openai",
346
+ "models": [
347
+ "gpt-4o-mini-tts",
348
+ "gpt-4o-mini-tts-2025-12-15",
349
+ "tts-1",
350
+ "tts-1-hd"
351
+ ],
352
+ "default": true,
353
+ "syntax": "plain",
354
+ "escape": "none",
355
+ "audio_tags": false,
356
+ "limit": {
357
+ "unit": "chars",
358
+ "max": 4096
359
+ },
360
+ "intents": {
361
+ "pause": {
362
+ "render": "punctuation"
363
+ },
364
+ "spell": {
365
+ "render": "spaced"
366
+ },
367
+ "emphasis": {
368
+ "render": "{text}"
369
+ }
370
+ },
371
+ "lexicon": {
372
+ "format": "none"
373
+ },
374
+ "notes": "No SSML. Tone is steered with the separate `instructions` request parameter, which does not work with tts-1 or tts-1-hd. The input is capped at 4,096 characters. OpenAI has announced that these models shut down on 6 January 2027 in favour of the Realtime API, so this profile is maintained until then.\n",
375
+ "sources": [
376
+ "https://developers.openai.com/api/docs/api-reference/audio/createSpeech",
377
+ "https://developers.openai.com/api/docs/guides/text-to-speech"
378
+ ],
379
+ "verified": "2026-10-28"
380
+ },
381
+ {
382
+ "id": "plain",
383
+ "provider": "plain",
384
+ "models": [],
385
+ "default": true,
386
+ "syntax": "plain",
387
+ "escape": "none",
388
+ "audio_tags": false,
389
+ "limit": {
390
+ "unit": "chars",
391
+ "max": null
392
+ },
393
+ "intents": {
394
+ "pause": {
395
+ "render": "punctuation"
396
+ },
397
+ "spell": {
398
+ "render": "spaced"
399
+ },
400
+ "emphasis": {
401
+ "render": "{text}"
402
+ }
403
+ },
404
+ "lexicon": {
405
+ "format": "none"
406
+ },
407
+ "notes": "Provider-neutral output. Pauses become punctuation, spell-outs become spaced characters, emphasis is dropped. No request limit, so one chunk.\n",
408
+ "sources": [],
409
+ "verified": "2026-10-27"
410
+ }
411
+ ];
@@ -0,0 +1,35 @@
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
+ export interface Profile {
9
+ id: string;
10
+ provider: string;
11
+ models: string[];
12
+ /** plain | ssml */
13
+ syntax: string;
14
+ /** none | xml */
15
+ escape: string;
16
+ /** chars | bytes */
17
+ limitUnit: string;
18
+ limitMax: number | null;
19
+ limitPerModel: Record<string, number>;
20
+ intents: Record<string, Record<string, unknown>>;
21
+ audioTags: boolean;
22
+ /** pls | google | none: how a lexicon is uploaded to this provider */
23
+ lexiconFormat: string;
24
+ lexiconAlphabet: string;
25
+ default: boolean;
26
+ notes: string;
27
+ sources: string[];
28
+ verified: string;
29
+ raw: Record<string, unknown>;
30
+ limitFor(model?: string | null): number | null;
31
+ }
32
+ export declare function listProfiles(): Profile[];
33
+ export declare function loadProfile(profileId: string): Profile;
34
+ /** Pick the profile for a provider and model. No provider = the `plain` profile. */
35
+ export declare function resolveProfile(provider?: string | null, model?: string | null): Profile;