@pie-players/tts-client-server 0.3.73 → 0.3.75

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
@@ -30,36 +30,33 @@ npm install @pie-players/tts-client-server
30
30
  ### Basic Setup
31
31
 
32
32
  ```typescript
33
- import { ServerTTSProvider } from '@pie-players/tts-client-server';
33
+ import {
34
+ ServerTTSProvider,
35
+ type ServerTTSProviderConfig,
36
+ } from '@pie-players/tts-client-server';
34
37
  import { TTSService } from '@pie-players/pie-assessment-toolkit';
35
38
 
36
39
  const provider = new ServerTTSProvider();
37
-
38
- const ttsService = new TTSService();
39
- await ttsService.initialize(provider, {
40
+ const config: ServerTTSProviderConfig = {
40
41
  apiEndpoint: '/api/tts', // Your SvelteKit API route
41
42
  provider: 'polly', // Server-side provider to use
42
- voiceId: 'Joanna',
43
+ voice: 'Joanna',
43
44
  language: 'en-US',
44
- });
45
+ };
45
46
 
46
- // Note: `apiEndpoint`, `provider`, and `voiceId` are server-provider-specific
47
- // options. When using TTSConfig, pass these inside `providerOptions` rather
48
- // than as top-level fields:
49
- //
50
- // const config: TTSConfig = {
51
- // providerOptions: {
52
- // apiEndpoint: '/api/tts',
53
- // provider: 'polly',
54
- // voiceId: 'Joanna',
55
- // },
56
- // language: 'en-US',
57
- // };
47
+ const ttsService = new TTSService();
48
+ await ttsService.initialize(provider, config);
58
49
  ```
59
50
 
60
- `apiEndpoint` is required when using `ServerTTSProvider` directly. Toolkit-level
61
- `tools.providers.textToSpeech` integration may provide a default endpoint for
62
- server-backed backends, but direct client usage should pass this explicitly.
51
+ `apiEndpoint`, `provider`, `voice` and `language` are top-level fields; the
52
+ provider reads none of them from `providerOptions`. `providerOptions` carries
53
+ request extras: the PIE transport forwards its `engine` (when no top-level
54
+ `engine` is set), `sampleRate`, `format` and `speechMarkTypes`, and the custom
55
+ transport reads its `speedRate`.
56
+
57
+ `apiEndpoint` is required when using `ServerTTSProvider` directly, and
58
+ `initialize` throws without it. Toolkit-level `tools.providers.textToSpeech`
59
+ integration defaults it to `/api/tts` for server-backed backends.
63
60
 
64
61
  ### With Authentication
65
62
 
@@ -67,8 +64,7 @@ server-backed backends, but direct client usage should pass this explicitly.
67
64
  await ttsService.initialize(provider, {
68
65
  apiEndpoint: '/api/tts',
69
66
  provider: 'polly',
70
- authToken: 'your-jwt-token',
71
- organizationId: 'org-123',
67
+ authToken: 'your-jwt-token', // sent as `Authorization: Bearer <token>`
72
68
  });
73
69
  ```
74
70
 
@@ -76,9 +72,7 @@ await ttsService.initialize(provider, {
76
72
 
77
73
  ```typescript
78
74
  // The provider automatically coordinates word highlighting
79
- await ttsService.speak('Hello world, this is a test.', {
80
- contentElement: document.getElementById('content'),
81
- });
75
+ await ttsService.speak(document.getElementById('content'));
82
76
  ```
83
77
 
84
78
  ## Transport Modes
@@ -140,10 +134,17 @@ await ttsService.speak('Hello world, this is a test.', {
140
134
  "text": "Hello world",
141
135
  "speedRate": "medium",
142
136
  "lang_id": "en-US",
137
+ "langId": "en-US",
143
138
  "cache": true
144
139
  }
145
140
  ```
146
141
 
142
+ The language travels under both `lang_id` and `langId`, so a server that binds
143
+ JSON in camelCase reads it without a mapping of its own. It is the host's
144
+ `providerOptions.lang_id`, else the language a speak names, else `language`,
145
+ else `en-US`. In PIE mode, `language` is the language a speak names, else the
146
+ configured one.
147
+
147
148
  ### Custom mode response
148
149
 
149
150
  ```json
@@ -157,7 +158,7 @@ Speech marks are fetched from the `word` URL and parsed as JSONL.
157
158
 
158
159
  ### Optional voices endpoint
159
160
 
160
- `GET ${apiEndpoint}/voices` remains optional and is only used when endpoint validation is configured with `endpointValidationMode: "voices"`.
161
+ `GET ${apiEndpoint}/voices` remains optional and is only used when endpoint validation is configured with `endpointValidationMode: "voices"`. With `provider: "polly"` or `"google"` the probe tries `${apiEndpoint}/<provider>/voices` first and falls back to `${apiEndpoint}/voices` when that route returns 404. A failed probe makes the toolkit switch to browser TTS.
161
162
 
162
163
  ## SvelteKit Implementation Example
163
164
 
@@ -181,15 +182,18 @@ interface ServerTTSProviderConfig {
181
182
  transportMode?: 'pie' | 'custom';
182
183
  endpointMode?: 'synthesizePath' | 'rootPost';
183
184
  endpointValidationMode?: 'voices' | 'endpoint' | 'none';
185
+ validateEndpoint?: boolean; // Probe the endpoint during initialize()
184
186
  authToken?: string; // JWT or API key
185
187
  includeAuthOnAssetFetch?: boolean;
186
188
  assetOrigins?: string[]; // Trusted origins for Authorization header
187
- organizationId?: string; // For multi-tenant setups
189
+ credentials?: 'omit' | 'same-origin' | 'include'; // Fetch credentials mode
188
190
  headers?: Record<string, string>; // Custom headers
189
- voiceId?: string; // Voice ID
191
+ voice?: string; // Voice ID, e.g. 'Joanna'
192
+ engine?: 'standard' | 'neural'; // Polly engine
190
193
  language?: string; // Language code
191
194
  rate?: number; // Speech rate (0.25-4.0)
192
195
  volume?: number; // Volume (0-1)
196
+ providerOptions?: Record<string, unknown>; // Request extras (see Basic Setup)
193
197
  }
194
198
  ```
195
199
 
@@ -218,6 +222,13 @@ server that fall outside a trusted origin set.
218
222
  origins in `assetOrigins`; off-allow-list origins are fetched without
219
223
  auth. Leave at the default unless your CDN / storage backend actually
220
224
  requires the bearer token to read assets.
225
+ - **`credentials`** — the fetch `credentials` mode, unset by default, which
226
+ leaves the browser default (`"same-origin"`). Set `"include"` when the TTS
227
+ server sits on another origin and authenticates by cookie; the server must
228
+ answer CORS with `Access-Control-Allow-Credentials: true` and an explicit
229
+ origin. Speech-mark and audio fetches to an origin outside `assetOrigins`
230
+ keep the browser default, so cookies follow the same allow-list as the
231
+ bearer token.
221
232
 
222
233
  See
223
234
  [`docs/tools-and-accomodations/tool_host_contract.md#backend-endpoints-for-tool-providers`](../../docs/tools-and-accomodations/tool_host_contract.md#backend-endpoints-for-tool-providers)
@@ -225,7 +236,7 @@ for the host-wide contract this provider fits into.
225
236
 
226
237
  ## How It Works
227
238
 
228
- 1. **Client calls** `speak(text)`
239
+ 1. **Client calls** `speak(element)`
229
240
  2. **Adapter builds** backend-specific request payload
230
241
  3. **Provider POSTs** to resolved synthesis endpoint (`/synthesize` or root POST)
231
242
  4. **Adapter normalizes** response into audio + speech marks
@@ -266,7 +277,7 @@ The provider automatically manages Blob URLs:
266
277
 
267
278
  ```typescript
268
279
  try {
269
- await ttsService.speak('Hello world');
280
+ await ttsService.speak(document.getElementById('content'));
270
281
  } catch (error) {
271
282
  console.error('TTS failed:', error.message);
272
283
  // Fallback to browser TTS or show error
@@ -5,11 +5,36 @@
5
5
  * providers like AWS Polly, Google Cloud TTS, etc.
6
6
  * Returns audio with precise word-level timing (speech marks).
7
7
  */
8
- import type { ITTSProvider, ITTSProviderImplementation, TTSConfig, TTSFeature, TTSProviderCapabilities } from "@pie-players/pie-tts";
8
+ import type { ITTSProvider, ITTSProviderImplementation, TTSConfig, TTSProviderCapabilities, TTSProviderOptions } from "@pie-players/pie-tts";
9
+ /**
10
+ * The provider options the server provider reads. `contentLanguage`, set per
11
+ * speak, names the language synthesized: it wins over `language`, and a
12
+ * `lang_id` the host sets wins over it.
13
+ */
14
+ export interface ServerTTSProviderOptions extends TTSProviderOptions {
15
+ /** Polly engine, on the pie transport, when `engine` is not set. */
16
+ engine?: "standard" | "neural";
17
+ /** Output sample rate, on the pie transport. */
18
+ sampleRate?: number;
19
+ /** Audio format, on the pie transport. */
20
+ format?: "mp3" | "ogg" | "pcm";
21
+ /** Speech mark types requested, on the pie transport. */
22
+ speechMarkTypes?: Array<"word" | "sentence" | "ssml">;
23
+ /** Speed bucket, on the custom transport; derived from `rate` when unset. */
24
+ speedRate?: string;
25
+ /**
26
+ * Language id, on the custom transport. Without it, the language a speak
27
+ * names, then `language`, then en-US.
28
+ */
29
+ lang_id?: string;
30
+ /** Server-side caching, on the custom transport. Defaults to true. */
31
+ cache?: boolean;
32
+ }
9
33
  /**
10
34
  * Configuration for ServerTTSProvider
11
35
  */
12
36
  export interface ServerTTSProviderConfig extends TTSConfig {
37
+ providerOptions?: ServerTTSProviderOptions;
13
38
  /** API endpoint base URL (e.g., '/api/tts' or 'https://api.example.com/tts') */
14
39
  apiEndpoint: string;
15
40
  /** Provider to use on server ('polly', 'google', 'elevenlabs', etc.) */
@@ -44,8 +69,9 @@ export interface ServerTTSProviderConfig extends TTSConfig {
44
69
  */
45
70
  endpointValidationMode?: "voices" | "endpoint" | "none";
46
71
  /**
47
- * Include auth headers when fetching custom-transport speech marks and audio URLs.
48
- * Defaults to false for compatibility.
72
+ * Send the synthesis request's `Authorization` header, whether it comes from
73
+ * `headers` or `authToken`, with the custom-transport speech-mark and audio
74
+ * fetches. Defaults to false for compatibility.
49
75
  */
50
76
  includeAuthOnAssetFetch?: boolean;
51
77
  /**
@@ -59,6 +85,14 @@ export interface ServerTTSProviderConfig extends TTSConfig {
59
85
  * responses keep working without explicit configuration.
60
86
  */
61
87
  assetOrigins?: string[];
88
+ /**
89
+ * Fetch `credentials` mode for requests to the TTS server, so a
90
+ * cookie-authenticated endpoint on another origin receives its cookies.
91
+ * Speech-mark and audio fetches use it only for an origin trusted with auth
92
+ * (see `assetOrigins`); others keep the browser default. Unset leaves every
93
+ * fetch on the browser default, `"same-origin"`.
94
+ */
95
+ credentials?: "omit" | "same-origin" | "include";
62
96
  /**
63
97
  * Validate API endpoint availability during initialization (slower but safer)
64
98
  *
@@ -97,7 +131,6 @@ export declare class ServerTTSProvider implements ITTSProvider {
97
131
  * @performance 100-500ms depending on network
98
132
  */
99
133
  private testAPIAvailability;
100
- supportsFeature(feature: TTSFeature): boolean;
101
134
  getCapabilities(): TTSProviderCapabilities;
102
135
  destroy(): void;
103
136
  }
@@ -5,9 +5,12 @@
5
5
  * providers like AWS Polly, Google Cloud TTS, etc.
6
6
  * Returns audio with precise word-level timing (speech marks).
7
7
  */
8
- import { createPieLogger, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared/pie";
8
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
9
9
  import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
10
- const logger = createPieLogger("server-tts-provider", () => isGlobalDebugEnabled());
10
+ // Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
11
+ const logger = createPieLogger("server-tts-provider", isTtsDebugEnabled);
12
+ // Telemetry names the tool by its canonical id, as the toolkit does.
13
+ const TOOL_ID = "textToSpeech";
11
14
  /**
12
15
  * Parse a TTS asset URL (speech marks or audio). Rejects anything that is
13
16
  * not an absolute http(s) URL; relative URLs are resolved against
@@ -85,14 +88,36 @@ function scrubAuthHeaders(headers) {
85
88
  }
86
89
  return cleaned;
87
90
  }
91
+ /**
92
+ * The `credentials` init for a fetch, spread into it. A cookie leaves the page
93
+ * for an untrusted asset origin only as the browser default would send it.
94
+ */
95
+ function fetchCredentials(config, trustedForAuth = true) {
96
+ if (!config.credentials)
97
+ return {};
98
+ if (!trustedForAuth && config.credentials === "include")
99
+ return {};
100
+ return { credentials: config.credentials };
101
+ }
102
+ /**
103
+ * The headers a custom-transport speech-mark or audio fetch starts from: the
104
+ * synthesis request's `Authorization` header under `includeAuthOnAssetFetch`,
105
+ * nothing otherwise.
106
+ */
107
+ function assetFetchHeaders(requestHeaders, config) {
108
+ const assetHeaders = {};
109
+ if (!config.includeAuthOnAssetFetch)
110
+ return assetHeaders;
111
+ for (const [key, value] of Object.entries(requestHeaders)) {
112
+ if (key.toLowerCase() === "authorization") {
113
+ assetHeaders[key] = value;
114
+ }
115
+ }
116
+ return assetHeaders;
117
+ }
88
118
  const getTelemetryReporter = (config) => {
89
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
90
- ? config.providerOptions
91
- : {};
92
- const reporter = providerOptions.__pieTelemetry;
93
- return typeof reporter === "function"
94
- ? reporter
95
- : undefined;
119
+ const reporter = config.providerOptions?.__pieTelemetry;
120
+ return typeof reporter === "function" ? reporter : undefined;
96
121
  };
97
122
  const MAX_TEXT_LENGTH_BY_MODE = {
98
123
  pie: 3000,
@@ -117,13 +142,15 @@ const resolveSupportsSSML = (config) => {
117
142
  return SSML_CAPABLE_PIE_PROVIDERS.has(provider);
118
143
  };
119
144
  const trimTrailingSlash = (value) => value.replace(/\/+$/, "");
120
- const resolveVoicesValidationUrl = (config) => {
145
+ // Polly and Google probe their provider route first, then the generic
146
+ // `${apiEndpoint}/voices` the integration guides have hosts create.
147
+ const resolveVoicesValidationUrls = (config) => {
121
148
  const base = trimTrailingSlash(config.apiEndpoint);
122
149
  const provider = (config.provider || "").toLowerCase();
123
150
  if (provider === "polly" || provider === "google") {
124
- return `${base}/${provider}/voices`;
151
+ return [`${base}/${provider}/voices`, `${base}/voices`];
125
152
  }
126
- return `${base}/voices`;
153
+ return [`${base}/voices`];
127
154
  };
128
155
  const resolveTransportMode = (config) => {
129
156
  if (config.transportMode === "custom")
@@ -143,12 +170,18 @@ const resolveValidationMode = (config, mode) => {
143
170
  return mode === "custom" ? "none" : "voices";
144
171
  };
145
172
  const resolveSpeedRate = (config) => {
146
- const providerOptions = (config.providerOptions || {});
147
- if (typeof providerOptions.speedRate === "string") {
148
- return providerOptions.speedRate;
149
- }
173
+ const speedRate = config.providerOptions?.speedRate;
174
+ if (typeof speedRate === "string")
175
+ return speedRate;
150
176
  return resolveSpeedRateBucket(config.rate);
151
177
  };
178
+ /** The language a speak named for its content, if any. */
179
+ const contentLanguageOf = (config) => {
180
+ const language = config.providerOptions?.contentLanguage;
181
+ return typeof language === "string" && language.trim()
182
+ ? language.trim()
183
+ : undefined;
184
+ };
152
185
  const parseInlineSpeechMarks = (input) => {
153
186
  if (!Array.isArray(input))
154
187
  return [];
@@ -189,7 +222,7 @@ const pieAdapter = {
189
222
  return endpointMode === "rootPost" ? base : `${base}/synthesize`;
190
223
  },
191
224
  buildRequestBody: (text, config) => {
192
- const providerOptions = (config.providerOptions || {});
225
+ const providerOptions = config.providerOptions || {};
193
226
  const engine = typeof config.engine === "string"
194
227
  ? config.engine
195
228
  : typeof providerOptions.engine === "string"
@@ -211,7 +244,7 @@ const pieAdapter = {
211
244
  text,
212
245
  provider: config.provider || "polly",
213
246
  voice: config.voice,
214
- language: config.language,
247
+ language: contentLanguageOf(config) ?? config.language,
215
248
  rate: config.rate,
216
249
  engine,
217
250
  sampleRate,
@@ -240,28 +273,27 @@ const customAdapter = {
240
273
  return endpointMode === "synthesizePath" ? `${base}/synthesize` : base;
241
274
  },
242
275
  buildRequestBody: (text, config) => {
243
- const providerOptions = (config.providerOptions || {});
244
- const langId = typeof providerOptions.lang_id === "string"
276
+ const providerOptions = config.providerOptions || {};
277
+ // A host's `lang_id` names a locale from the service's own roster, which a
278
+ // content tag such as `es` is not: SchoolCity reads one it lacks as en-US.
279
+ const pinned = typeof providerOptions.lang_id === "string" &&
280
+ providerOptions.lang_id.trim()
245
281
  ? providerOptions.lang_id
246
- : config.language || "en-US";
282
+ : undefined;
283
+ const langId = pinned ?? contentLanguageOf(config) ?? (config.language || "en-US");
247
284
  const cache = typeof providerOptions.cache === "boolean" ? providerOptions.cache : true;
248
285
  return {
249
286
  text,
250
287
  speedRate: resolveSpeedRate(config),
288
+ // Both spellings: servers that bind JSON camelCase never see `lang_id`.
251
289
  lang_id: langId,
290
+ langId,
252
291
  cache,
253
292
  };
254
293
  },
255
294
  parseResponse: async (response, config, headers, signal, text) => {
256
295
  const data = await response.json();
257
- const marksHeaders = {};
258
- if (config.includeAuthOnAssetFetch) {
259
- for (const [key, value] of Object.entries(headers)) {
260
- if (key.toLowerCase() === "authorization") {
261
- marksHeaders[key] = value;
262
- }
263
- }
264
- }
296
+ const marksHeaders = assetFetchHeaders(headers, config);
265
297
  let speechMarks = [];
266
298
  const inlineSpeechMarks = parseInlineSpeechMarks(data.speechMarks);
267
299
  if (inlineSpeechMarks.length > 0) {
@@ -270,11 +302,13 @@ const customAdapter = {
270
302
  else if (typeof data.word === "string" && data.word.length > 0) {
271
303
  const marksUrl = parseAssetUrl(data.word, config);
272
304
  if (marksUrl !== null) {
273
- const effectiveMarksHeaders = isOriginTrustedForAuth(marksUrl, config)
305
+ const marksTrusted = isOriginTrustedForAuth(marksUrl, config);
306
+ const effectiveMarksHeaders = marksTrusted
274
307
  ? marksHeaders
275
308
  : scrubAuthHeaders(marksHeaders);
276
309
  const marksResponse = await fetch(marksUrl.toString(), {
277
310
  headers: effectiveMarksHeaders,
311
+ ...fetchCredentials(config, marksTrusted),
278
312
  signal,
279
313
  // Fail loud on redirects; the origin allow-list decision
280
314
  // is made pre-redirect and a silent hop could leak auth
@@ -321,7 +355,11 @@ class ServerTTSProviderImpl {
321
355
  */
322
356
  highlightCursor = -1;
323
357
  intentionallyStopped = false;
358
+ // Set while a synthesis request is in flight.
324
359
  activeSynthesisController = null;
360
+ // Starts the current audio element; a pause before it sounds leaves the
361
+ // start to resume.
362
+ startCurrentAudio = null;
325
363
  synthesisRunId = 0;
326
364
  telemetryReporter;
327
365
  onPlaybackStart;
@@ -348,7 +386,23 @@ class ServerTTSProviderImpl {
348
386
  const synthesisController = new AbortController();
349
387
  this.activeSynthesisController = synthesisController;
350
388
  // Call server API to synthesize speech
351
- const { audioUrl, wordTimings } = await this.synthesizeSpeech(text, synthesisController.signal, runId);
389
+ let synthesized;
390
+ try {
391
+ synthesized = await this.synthesizeSpeech(text, synthesisController.signal, runId);
392
+ }
393
+ catch (error) {
394
+ // A stop or a newer speak aborted this synthesis: not a failure.
395
+ if (runId !== this.synthesisRunId || synthesisController.signal.aborted) {
396
+ return;
397
+ }
398
+ throw error;
399
+ }
400
+ finally {
401
+ if (this.activeSynthesisController === synthesisController) {
402
+ this.activeSynthesisController = null;
403
+ }
404
+ }
405
+ const { audioUrl, wordTimings } = synthesized;
352
406
  if (runId !== this.synthesisRunId) {
353
407
  URL.revokeObjectURL(audioUrl);
354
408
  return;
@@ -363,11 +417,14 @@ class ServerTTSProviderImpl {
363
417
  if (this.config.volume !== undefined) {
364
418
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
365
419
  }
366
- // Setup event handlers
367
- audio.onplay = () => {
368
- if (runId !== this.synthesisRunId || this.currentAudio !== audio) {
420
+ // A stop or a newer speak supersedes this audio: its late events must
421
+ // neither clear the current audio's state nor fail the current speak.
422
+ const superseded = () => runId !== this.synthesisRunId || this.currentAudio !== audio;
423
+ // `playing`, not `play`: `play` fires when playback is requested, before
424
+ // the audio has buffered and sounds.
425
+ audio.onplaying = () => {
426
+ if (superseded())
369
427
  return;
370
- }
371
428
  this.pausedState = false;
372
429
  try {
373
430
  this.onPlaybackStart?.();
@@ -381,35 +438,40 @@ class ServerTTSProviderImpl {
381
438
  }
382
439
  };
383
440
  audio.onended = () => {
384
- this.stopWordHighlighting();
385
441
  URL.revokeObjectURL(audioUrl);
386
- this.currentAudio = null;
387
- this.wordTimings = [];
388
- this.highlightCursor = -1;
442
+ if (!superseded())
443
+ this.clearCurrentAudio();
389
444
  resolve();
390
445
  };
391
- audio.onerror = (event) => {
392
- this.stopWordHighlighting();
446
+ audio.onerror = () => {
393
447
  URL.revokeObjectURL(audioUrl);
394
- this.currentAudio = null;
395
- this.wordTimings = [];
396
- this.highlightCursor = -1;
397
- void event;
398
- // Only reject if this wasn't an intentional stop
399
- if (!this.intentionallyStopped) {
400
- reject(new Error("Failed to play audio from server"));
401
- }
402
- else {
403
- // Intentional stop, resolve normally
448
+ if (superseded() || this.intentionallyStopped) {
404
449
  resolve();
450
+ return;
405
451
  }
452
+ this.clearCurrentAudio();
453
+ reject(new Error("Failed to play audio from server"));
406
454
  };
407
455
  audio.onpause = () => {
456
+ if (superseded())
457
+ return;
408
458
  this.stopWordHighlighting();
409
459
  this.pausedState = true;
410
460
  };
411
- // Start playback
412
- audio.play().catch(reject);
461
+ const start = () => {
462
+ audio.play().catch((error) => {
463
+ // A pause before the audio sounds aborts its play; resume starts it.
464
+ const aborted = error?.name === "AbortError";
465
+ if (aborted && this.pausedState && this.currentAudio === audio) {
466
+ return;
467
+ }
468
+ reject(error);
469
+ });
470
+ };
471
+ this.startCurrentAudio = start;
472
+ // A pause during synthesis holds the audio until resume.
473
+ if (!this.pausedState)
474
+ start();
413
475
  });
414
476
  }
415
477
  /**
@@ -418,7 +480,7 @@ class ServerTTSProviderImpl {
418
480
  async synthesizeSpeech(text, signal, runId) {
419
481
  const synthStartedAt = Date.now();
420
482
  await this.emitTelemetry("pie-tool-backend-call-start", {
421
- toolId: "tts",
483
+ toolId: TOOL_ID,
422
484
  backend: this.config.provider || "server",
423
485
  operation: "synthesize-speech",
424
486
  });
@@ -439,11 +501,14 @@ class ServerTTSProviderImpl {
439
501
  headers,
440
502
  body: JSON.stringify(requestBody),
441
503
  signal,
504
+ ...fetchCredentials(this.config),
442
505
  });
443
506
  }
444
507
  catch (error) {
508
+ if (signal.aborted)
509
+ throw error;
445
510
  await this.emitTelemetry("pie-tool-backend-call-error", {
446
- toolId: "tts",
511
+ toolId: TOOL_ID,
447
512
  backend: this.config.provider || "server",
448
513
  operation: "synthesize-speech",
449
514
  duration: Date.now() - synthStartedAt,
@@ -483,7 +548,7 @@ class ServerTTSProviderImpl {
483
548
  : undefined) ||
484
549
  `Server returned ${response.status}`;
485
550
  await this.emitTelemetry("pie-tool-backend-call-error", {
486
- toolId: "tts",
551
+ toolId: TOOL_ID,
487
552
  backend: this.config.provider || "server",
488
553
  operation: "synthesize-speech",
489
554
  duration: Date.now() - synthStartedAt,
@@ -521,20 +586,15 @@ class ServerTTSProviderImpl {
521
586
  const audioAssetUrl = normalized.audio.url;
522
587
  const assetFetchStartedAt = Date.now();
523
588
  await this.emitTelemetry("pie-tool-backend-call-start", {
524
- toolId: "tts",
589
+ toolId: TOOL_ID,
525
590
  backend: this.config.provider || "server",
526
591
  operation: "fetch-synthesized-audio-asset",
527
592
  });
528
- const assetHeaders = {};
529
- if (this.config.includeAuthOnAssetFetch) {
530
- if (this.config.authToken) {
531
- assetHeaders["Authorization"] = `Bearer ${this.config.authToken}`;
532
- }
533
- }
593
+ const assetHeaders = assetFetchHeaders(headers, this.config);
534
594
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
535
595
  if (parsedAssetUrl === null) {
536
596
  await this.emitTelemetry("pie-tool-backend-call-error", {
537
- toolId: "tts",
597
+ toolId: TOOL_ID,
538
598
  backend: this.config.provider || "server",
539
599
  operation: "fetch-synthesized-audio-asset",
540
600
  duration: Date.now() - assetFetchStartedAt,
@@ -543,7 +603,8 @@ class ServerTTSProviderImpl {
543
603
  });
544
604
  throw new Error("TTS asset URL rejected (non-http(s) or malformed)");
545
605
  }
546
- const effectiveAssetHeaders = isOriginTrustedForAuth(parsedAssetUrl, this.config)
606
+ const assetTrusted = isOriginTrustedForAuth(parsedAssetUrl, this.config);
607
+ const effectiveAssetHeaders = assetTrusted
547
608
  ? assetHeaders
548
609
  : scrubAuthHeaders(assetHeaders);
549
610
  const audioResponse = await (async () => {
@@ -551,12 +612,15 @@ class ServerTTSProviderImpl {
551
612
  return await fetch(parsedAssetUrl.toString(), {
552
613
  headers: effectiveAssetHeaders,
553
614
  signal,
615
+ ...fetchCredentials(this.config, assetTrusted),
554
616
  redirect: "error",
555
617
  });
556
618
  }
557
619
  catch (error) {
620
+ if (signal.aborted)
621
+ throw error;
558
622
  await this.emitTelemetry("pie-tool-backend-call-error", {
559
- toolId: "tts",
623
+ toolId: TOOL_ID,
560
624
  backend: this.config.provider || "server",
561
625
  operation: "fetch-synthesized-audio-asset",
562
626
  duration: Date.now() - assetFetchStartedAt,
@@ -568,7 +632,7 @@ class ServerTTSProviderImpl {
568
632
  })();
569
633
  if (!audioResponse.ok) {
570
634
  await this.emitTelemetry("pie-tool-backend-call-error", {
571
- toolId: "tts",
635
+ toolId: TOOL_ID,
572
636
  backend: this.config.provider || "server",
573
637
  operation: "fetch-synthesized-audio-asset",
574
638
  duration: Date.now() - assetFetchStartedAt,
@@ -580,7 +644,7 @@ class ServerTTSProviderImpl {
580
644
  }
581
645
  audioBlob = await audioResponse.blob();
582
646
  await this.emitTelemetry("pie-tool-backend-call-success", {
583
- toolId: "tts",
647
+ toolId: TOOL_ID,
584
648
  backend: this.config.provider || "server",
585
649
  operation: "fetch-synthesized-audio-asset",
586
650
  duration: Date.now() - assetFetchStartedAt,
@@ -590,7 +654,7 @@ class ServerTTSProviderImpl {
590
654
  // Convert speech marks to word timings
591
655
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
592
656
  await this.emitTelemetry("pie-tool-backend-call-success", {
593
- toolId: "tts",
657
+ toolId: TOOL_ID,
594
658
  backend: this.config.provider || "server",
595
659
  operation: "synthesize-speech",
596
660
  duration: Date.now() - synthStartedAt,
@@ -710,25 +774,39 @@ class ServerTTSProviderImpl {
710
774
  this.highlightInterval = null;
711
775
  }
712
776
  }
777
+ /** Pause playback, or, while synthesis is in flight, hold its audio. */
713
778
  pause() {
714
- if (this.currentAudio && !this.pausedState) {
779
+ if (this.pausedState)
780
+ return;
781
+ if (this.currentAudio) {
715
782
  this.currentAudio.pause();
716
783
  this.stopWordHighlighting();
717
- this.pausedState = true;
718
784
  }
785
+ else if (!this.activeSynthesisController) {
786
+ return;
787
+ }
788
+ this.pausedState = true;
719
789
  }
720
790
  resume() {
721
- if (this.currentAudio && this.pausedState) {
722
- this.currentAudio.play();
723
- this.pausedState = false;
724
- // Resume word highlighting
725
- if (this.onWordBoundary && this.wordTimings.length > 0) {
726
- this.startWordHighlighting();
727
- }
791
+ if (!this.pausedState)
792
+ return;
793
+ this.pausedState = false;
794
+ if (!this.currentAudio)
795
+ return;
796
+ this.startCurrentAudio?.();
797
+ if (this.onWordBoundary && this.wordTimings.length > 0) {
798
+ this.startWordHighlighting();
728
799
  }
729
800
  }
801
+ clearCurrentAudio() {
802
+ this.stopWordHighlighting();
803
+ this.currentAudio = null;
804
+ this.wordTimings = [];
805
+ this.highlightCursor = -1;
806
+ }
730
807
  stop() {
731
808
  this.synthesisRunId += 1;
809
+ this.startCurrentAudio = null;
732
810
  if (this.activeSynthesisController) {
733
811
  this.activeSynthesisController.abort();
734
812
  this.activeSynthesisController = null;
@@ -777,6 +855,15 @@ class ServerTTSProviderImpl {
777
855
  // Voice change requires resynthesis, affects next speak()
778
856
  this.config.voice = settings.voice;
779
857
  }
858
+ // Only the per-speak language: the toolkit's other options would replace
859
+ // the speed bucket a rate change set above.
860
+ if (settings.providerOptions &&
861
+ "contentLanguage" in settings.providerOptions) {
862
+ this.config.providerOptions = {
863
+ ...this.config.providerOptions,
864
+ contentLanguage: settings.providerOptions.contentLanguage,
865
+ };
866
+ }
780
867
  }
781
868
  }
782
869
  /**
@@ -821,14 +908,14 @@ export class ServerTTSProvider {
821
908
  if (serverConfig.validateEndpoint) {
822
909
  const validationStartedAt = Date.now();
823
910
  await this.emitTelemetry("pie-tool-backend-call-start", {
824
- toolId: "tts",
911
+ toolId: TOOL_ID,
825
912
  backend: serverConfig.provider || "server",
826
913
  operation: "validate-endpoint",
827
914
  });
828
915
  const available = await this.testAPIAvailability();
829
916
  if (!available) {
830
917
  await this.emitTelemetry("pie-tool-backend-call-error", {
831
- toolId: "tts",
918
+ toolId: TOOL_ID,
832
919
  backend: serverConfig.provider || "server",
833
920
  operation: "validate-endpoint",
834
921
  duration: Date.now() - validationStartedAt,
@@ -838,7 +925,7 @@ export class ServerTTSProvider {
838
925
  throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
839
926
  }
840
927
  await this.emitTelemetry("pie-tool-backend-call-success", {
841
- toolId: "tts",
928
+ toolId: TOOL_ID,
842
929
  backend: serverConfig.provider || "server",
843
930
  operation: "validate-endpoint",
844
931
  duration: Date.now() - validationStartedAt,
@@ -867,19 +954,26 @@ export class ServerTTSProvider {
867
954
  clearTimeout(timeoutId);
868
955
  return true;
869
956
  }
870
- const validationUrl = mode === "voices"
871
- ? resolveVoicesValidationUrl(this.config)
872
- : this.adapter.resolveSynthesisUrl(this.config);
957
+ const validationUrls = mode === "voices"
958
+ ? resolveVoicesValidationUrls(this.config)
959
+ : [this.adapter.resolveSynthesisUrl(this.config)];
873
960
  const method = mode === "voices" ? "GET" : "OPTIONS";
874
961
  try {
875
- const response = await fetch(validationUrl, {
876
- method,
877
- headers,
878
- signal: controller.signal,
879
- });
962
+ let reachable = false;
963
+ for (const validationUrl of validationUrls) {
964
+ const response = await fetch(validationUrl, {
965
+ method,
966
+ headers,
967
+ signal: controller.signal,
968
+ ...fetchCredentials(this.config),
969
+ });
970
+ // Some endpoints may not accept OPTIONS; treat 405 as reachable.
971
+ reachable = response.ok || response.status === 405;
972
+ if (reachable || response.status !== 404)
973
+ break;
974
+ }
880
975
  clearTimeout(timeoutId);
881
- // Some endpoints may not accept OPTIONS; treat 405 as reachable.
882
- return response.ok || response.status === 405;
976
+ return reachable;
883
977
  }
884
978
  catch {
885
979
  clearTimeout(timeoutId);
@@ -890,21 +984,6 @@ export class ServerTTSProvider {
890
984
  return false;
891
985
  }
892
986
  }
893
- supportsFeature(feature) {
894
- switch (feature) {
895
- case "pause":
896
- case "resume":
897
- case "wordBoundary":
898
- case "voiceSelection":
899
- case "rateControl":
900
- return true;
901
- case "pitchControl":
902
- // Depends on server provider, assume no for safety
903
- return false;
904
- default:
905
- return false;
906
- }
907
- }
908
987
  getCapabilities() {
909
988
  const mode = this.config ? resolveTransportMode(this.config) : "pie";
910
989
  return {
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@
2
2
  * Client-side TTS provider for calling server APIs
3
3
  * @module @pie-players/tts-client-server
4
4
  */
5
- export type { ServerTTSProviderConfig } from "./ServerTTSProvider.js";
5
+ export type { ServerTTSProviderConfig, ServerTTSProviderOptions, } from "./ServerTTSProvider.js";
6
6
  export { ServerTTSProvider } from "./ServerTTSProvider.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/tts-client-server",
3
- "version": "0.3.73",
3
+ "version": "0.3.75",
4
4
  "description": "Client-side TTS provider that calls server API for synthesis",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,13 +38,13 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@pie-players/pie-players-shared": "0.3.73",
42
- "@pie-players/pie-tts": "0.3.73",
43
- "@pie-players/tts-server-core": "0.3.73"
41
+ "@pie-players/pie-players-shared": "0.3.75",
42
+ "@pie-players/pie-tts": "0.3.75",
43
+ "@pie-players/tts-server-core": "0.3.75"
44
44
  },
45
45
  "devDependencies": {
46
46
  "typescript": "^5.9.3",
47
- "vitest": "^4.1.11"
47
+ "vitest": "^5.0.3"
48
48
  },
49
49
  "sideEffects": false,
50
50
  "homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/tts-client-server#readme",