@pie-players/tts-client-server 0.3.74 → 1.0.0-beta.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
@@ -1,23 +1,21 @@
1
1
  # @pie-players/tts-client-server
2
2
 
3
- Client-side TTS provider that calls a server API for synthesis with speech marks support.
4
-
5
- For the cross-package TTS architecture and provider layering, see
6
- [TTS Architecture](../../docs/accessibility/tts-architecture.md). This README
7
- focuses on the browser-side server provider API.
8
-
9
- ## Overview
10
-
11
- This package provides a browser-side TTS provider that offloads synthesis to a server API. The server handles provider selection (AWS Polly, Google Cloud TTS, etc.) and credential management, while the client plays audio and coordinates word highlighting.
3
+ `ServerTTSProvider`, the browser-side text-to-speech (TTS) provider that sends
4
+ synthesis to a server API the host runs. The server picks the vendor (AWS Polly,
5
+ Google Cloud TTS, or any provider built on `@pie-players/tts-server-core`) and
6
+ holds its credentials; the client plays the returned audio and reports word
7
+ boundaries from the server's speech marks. This README covers the provider's API
8
+ for host integrators adding server-backed read-aloud. For the package layering,
9
+ see [TTS Architecture](../../docs/accessibility/tts-architecture.md); for the
10
+ runtime flow, the [TTS deep dive](../../docs/accessibility/tts-deep-dive.md).
12
11
 
13
12
  ## Features
14
13
 
15
- - ✅ **Server-Side Synthesis** - Keeps credentials secure on server
16
- - ✅ **Speech Marks** - Precise word-level timing from server
17
- - ✅ **Multiple Providers** - Server can use Polly, Google, ElevenLabs, etc.
18
- - ✅ **Word Highlighting** - 50ms polling for smooth synchronization
19
- - ✅ **Audio Playback** - HTMLAudioElement with pause/resume
20
- - ✅ **Blob URLs** - Efficient memory management
14
+ - **Server-side synthesis**: vendor credentials stay on the server
15
+ - **Speech marks**: word timings from the server drive highlighting
16
+ - **Word highlighting**: the audio clock is polled every 50ms
17
+ - **Audio playback**: an `HTMLAudioElement` playing a Blob URL, with pause and
18
+ resume
21
19
 
22
20
  ## Installation
23
21
 
@@ -30,25 +28,30 @@ npm install @pie-players/tts-client-server
30
28
  ### Basic Setup
31
29
 
32
30
  ```typescript
33
- import { ServerTTSProvider } from '@pie-players/tts-client-server';
31
+ import {
32
+ ServerTTSProvider,
33
+ type ServerTTSProviderConfig,
34
+ } from '@pie-players/tts-client-server';
34
35
  import { TTSService } from '@pie-players/pie-assessment-toolkit';
35
36
 
36
37
  const provider = new ServerTTSProvider();
37
-
38
- const ttsService = new TTSService();
39
- await ttsService.initialize(provider, {
40
- apiEndpoint: '/api/tts', // Your SvelteKit API route
38
+ const config: ServerTTSProviderConfig = {
39
+ apiEndpoint: '/api/tts', // the host's TTS API route
41
40
  provider: 'polly', // Server-side provider to use
42
41
  voice: 'Joanna',
43
42
  language: 'en-US',
44
- });
43
+ };
44
+
45
+ const ttsService = new TTSService();
46
+ await ttsService.initialize(provider, config);
45
47
  ```
46
48
 
47
49
  `apiEndpoint`, `provider`, `voice` and `language` are top-level fields; the
48
50
  provider reads none of them from `providerOptions`. `providerOptions` carries
49
51
  request extras: the PIE transport forwards its `engine` (when no top-level
50
52
  `engine` is set), `sampleRate`, `format` and `speechMarkTypes`, and the custom
51
- transport reads its `speedRate`.
53
+ transport reads its `speedRate` (else a bucket from `rate`), `lang_id` (else the
54
+ content language, else `language`, else `en-US`) and `cache` (default `true`).
52
55
 
53
56
  `apiEndpoint` is required when using `ServerTTSProvider` directly, and
54
57
  `initialize` throws without it. Toolkit-level `tools.providers.textToSpeech`
@@ -68,18 +71,26 @@ await ttsService.initialize(provider, {
68
71
 
69
72
  ```typescript
70
73
  // The provider automatically coordinates word highlighting
71
- await ttsService.speak('Hello world, this is a test.', {
72
- contentElement: document.getElementById('content'),
73
- });
74
+ await ttsService.speak(document.getElementById('content'));
74
75
  ```
75
76
 
76
77
  ## Transport Modes
77
78
 
78
- `ServerTTSProvider` now supports two transport modes:
79
+ `ServerTTSProvider` supports two transport modes:
79
80
 
80
- - `pie` (default): POST `${apiEndpoint}/synthesize`, inline base64 audio + inline speech marks
81
+ - `pie`: POST `${apiEndpoint}/synthesize`, inline base64 audio + inline speech marks
81
82
  - `custom`: POST to root endpoint, then fetch `audioContent` and JSONL marks URLs
82
83
 
84
+ With `transportMode` unset, the transport is `custom` when `provider` is
85
+ `"custom"` and `pie` otherwise. `endpointMode` defaults to `synthesizePath` for
86
+ `pie` and `rootPost` for `custom`. The toolkit's `tools.providers.textToSpeech`
87
+ applies the same default from `serverProvider`.
88
+
89
+ The provider reports SSML support only on the `pie` transport with `provider`
90
+ `polly` (the default) or `google`. There the toolkit voices generated math as
91
+ SSML; everywhere else, the custom transport included, generated speech is plain
92
+ text.
93
+
83
94
  ### PIE mode request
84
95
 
85
96
  **Request:**
@@ -113,18 +124,6 @@ await ttsService.speak('Hello world, this is a test.', {
113
124
  }
114
125
  ```
115
126
 
116
- ### PIE mode response
117
-
118
- ```json
119
- {
120
- "audio": "base64-encoded-audio",
121
- "contentType": "audio/mpeg",
122
- "speechMarks": [
123
- { "time": 0, "type": "word", "start": 0, "end": 5, "value": "Hello" }
124
- ]
125
- }
126
- ```
127
-
128
127
  ### Custom mode request
129
128
 
130
129
  ```json
@@ -138,7 +137,10 @@ await ttsService.speak('Hello world, this is a test.', {
138
137
  ```
139
138
 
140
139
  The language travels under both `lang_id` and `langId`, so a server that binds
141
- JSON in camelCase reads it without a mapping of its own.
140
+ JSON in camelCase reads it without a mapping of its own. It is the host's
141
+ `providerOptions.lang_id`, else the language a speak names, else `language`,
142
+ else `en-US`. In PIE mode, `language` is the language a speak names, else the
143
+ configured one.
142
144
 
143
145
  ### Custom mode response
144
146
 
@@ -157,7 +159,7 @@ Speech marks are fetched from the `word` URL and parsed as JSONL.
157
159
 
158
160
  ## SvelteKit Implementation Example
159
161
 
160
- See the implementation guide in [tts-architecture.md](../../docs/accessibility/tts-architecture.md).
162
+ The [`@pie-players/tts-server-polly` integration guide](../tts-server-polly/examples/INTEGRATION-GUIDE.md) builds these routes for Polly, and the [`@pie-players/tts-server-google` integration guide](../tts-server-google/examples/INTEGRATION-GUIDE.md) for Google.
161
163
 
162
164
  Example route structure:
163
165
  ```
@@ -176,8 +178,7 @@ interface ServerTTSProviderConfig {
176
178
  provider?: string; // Server provider ('polly', 'google', etc.)
177
179
  transportMode?: 'pie' | 'custom';
178
180
  endpointMode?: 'synthesizePath' | 'rootPost';
179
- endpointValidationMode?: 'voices' | 'endpoint' | 'none';
180
- validateEndpoint?: boolean; // Probe the endpoint during initialize()
181
+ endpointValidationMode?: 'voices' | 'endpoint' | 'none'; // Probe run by initialize(); default 'none'
181
182
  authToken?: string; // JWT or API key
182
183
  includeAuthOnAssetFetch?: boolean;
183
184
  assetOrigins?: string[]; // Trusted origins for Authorization header
@@ -231,33 +232,35 @@ for the host-wide contract this provider fits into.
231
232
 
232
233
  ## How It Works
233
234
 
234
- 1. **Client calls** `speak(text)`
235
+ 1. **TTSService calls** `speak(text)` with the text it resolved from the element or range
235
236
  2. **Adapter builds** backend-specific request payload
236
237
  3. **Provider POSTs** to resolved synthesis endpoint (`/synthesize` or root POST)
237
238
  4. **Adapter normalizes** response into audio + speech marks
238
239
  5. **Client loads** audio as Blob URL
239
240
  6. **Client plays** audio via HTMLAudioElement
240
241
  7. **Client polls** audio time every 50ms
241
- 8. **Client fires** word boundary callbacks at correct times
242
+ 8. **Client reports** the word at the current audio time through the word-boundary callback
242
243
  9. **TTSService** highlights words in DOM
243
244
 
244
245
  ## Word Highlighting Synchronization
245
246
 
246
- The provider uses a polling-based approach for reliable synchronization:
247
+ Every 50ms the provider reads the audio's current time and reports the last
248
+ word whose start time has arrived, once:
247
249
 
248
250
  ```typescript
249
- // Every 50ms, check current audio time
250
- const currentTime = audio.currentTime * 1000; // Convert to ms
251
-
252
- // Find words that should be highlighted
253
- for (const timing of wordTimings) {
254
- if (currentTime >= timing.time) {
255
- onWordBoundary('', timing.charIndex, timing.length);
256
- }
251
+ const currentTimeMs = audio.currentTime * 1000;
252
+ const index = lastTimingAtOrBefore(wordTimings, currentTimeMs);
253
+ if (index >= 0 && index !== lastReported) {
254
+ lastReported = index;
255
+ const timing = wordTimings[index];
256
+ onWordBoundary(timing.word, timing.charIndex, timing.length);
257
257
  }
258
258
  ```
259
259
 
260
- This is **much more reliable** than browser's `onboundary` events (which are broken in Safari and unreliable in Chrome).
260
+ Words a tick crossed are skipped: the callback names the word being spoken, so
261
+ replaying them would paint highlights the learner never sees and leave the
262
+ highlight behind the audio. A background tab's clamped timer resyncs on its next
263
+ tick for the same reason.
261
264
 
262
265
  ## Memory Management
263
266
 
@@ -272,19 +275,20 @@ The provider automatically manages Blob URLs:
272
275
 
273
276
  ```typescript
274
277
  try {
275
- await ttsService.speak('Hello world');
278
+ await ttsService.speak(document.getElementById('content'));
276
279
  } catch (error) {
277
280
  console.error('TTS failed:', error.message);
278
- // Fallback to browser TTS or show error
281
+ // A failed read rejects without switching provider; the toolkit coordinator
282
+ // falls back to browser TTS only when a provider fails to start.
279
283
  }
280
284
  ```
281
285
 
282
286
  ## Browser Compatibility
283
287
 
284
- - ✅ Chrome/Edge (latest)
285
- - ✅ Firefox (latest)
286
- - ✅ Safari (latest)
287
- - ✅ Mobile browsers
288
+ - Chrome/Edge (latest)
289
+ - Firefox (latest)
290
+ - Safari (latest)
291
+ - Mobile browsers
288
292
 
289
293
  Requires:
290
294
 
@@ -293,12 +297,11 @@ Requires:
293
297
  - `URL.createObjectURL`
294
298
  - `atob` for base64 decoding
295
299
 
296
- ## Performance
300
+ ## Caching
297
301
 
298
- - **Audio caching:** Server-side (Redis)
299
- - **Blob URLs:** Efficient memory usage
300
- - **50ms polling:** Smooth highlighting without jank
301
- - **Parallel requests:** Audio + marks fetched together
302
+ The client keeps no audio cache. Caching is server-side, through the host's
303
+ `ITTSCache` (`@pie-players/tts-server-core`); the custom transport also sends a
304
+ `cache` flag, `true` unless `providerOptions.cache` is `false`.
302
305
 
303
306
  ## License
304
307
 
@@ -4,12 +4,47 @@
4
4
  * Provides high-quality TTS by calling a server-side API that uses
5
5
  * providers like AWS Polly, Google Cloud TTS, etc.
6
6
  * Returns audio with precise word-level timing (speech marks).
7
+ *
8
+ * Layout: config types; the asset-URL and auth helpers that guard fetches of
9
+ * server-returned URLs; wire types; mode resolvers; the `pie` and `custom`
10
+ * transport adapters (`ADAPTERS`); `ServerTTSProviderImpl`, which owns one
11
+ * playback (synthesis, audio element, word highlighting); and
12
+ * `ServerTTSProvider`, which picks the adapter, runs the optional endpoint probe
13
+ * and hands out implementations.
14
+ */
15
+ import type { ITTSProvider, ITTSProviderImplementation, SpeedRateBucket, TTSConfig, TTSProviderCapabilities, TTSProviderOptions } from "@pie-players/pie-tts";
16
+ /**
17
+ * The provider options the server provider reads. `contentLanguage`, set per
18
+ * speak, names the language synthesized: it wins over `language`, and a
19
+ * `lang_id` the host sets wins over it.
7
20
  */
8
- import type { ITTSProvider, ITTSProviderImplementation, TTSConfig, TTSFeature, TTSProviderCapabilities } from "@pie-players/pie-tts";
21
+ export interface ServerTTSProviderOptions extends TTSProviderOptions {
22
+ /** Polly engine, on the pie transport, when `engine` is not set. */
23
+ engine?: "standard" | "neural";
24
+ /** Output sample rate, on the pie transport. */
25
+ sampleRate?: number;
26
+ /** Audio format, on the pie transport. */
27
+ format?: "mp3" | "ogg" | "pcm";
28
+ /** Speech mark types requested, on the pie transport. */
29
+ speechMarkTypes?: Array<"word" | "sentence" | "ssml">;
30
+ /**
31
+ * Speed bucket, on the custom transport; derived from `rate` when unset or
32
+ * not one of the three buckets.
33
+ */
34
+ speedRate?: SpeedRateBucket;
35
+ /**
36
+ * Language id, on the custom transport. Without it, the language a speak
37
+ * names, then `language`, then en-US.
38
+ */
39
+ lang_id?: string;
40
+ /** Server-side caching, on the custom transport. Defaults to true. */
41
+ cache?: boolean;
42
+ }
9
43
  /**
10
44
  * Configuration for ServerTTSProvider
11
45
  */
12
46
  export interface ServerTTSProviderConfig extends TTSConfig {
47
+ providerOptions?: ServerTTSProviderOptions;
13
48
  /** API endpoint base URL (e.g., '/api/tts' or 'https://api.example.com/tts') */
14
49
  apiEndpoint: string;
15
50
  /** Provider to use on server ('polly', 'google', 'elevenlabs', etc.) */
@@ -37,10 +72,12 @@ export interface ServerTTSProviderConfig extends TTSConfig {
37
72
  */
38
73
  endpointMode?: "synthesizePath" | "rootPost";
39
74
  /**
40
- * Endpoint validation mode used during initialize(validateEndpoint=true).
41
- * - voices: probe {apiEndpoint}/voices
42
- * - endpoint: probe resolved synthesis endpoint
43
- * - none: skip endpoint probe
75
+ * The probe `initialize()` runs before it resolves; a failed probe rejects it.
76
+ * - voices: GET {apiEndpoint}/voices, trying the provider's own route first
77
+ * - endpoint: OPTIONS on the resolved synthesis endpoint
78
+ * - none: no probe, so an unreachable server fails at the first synthesis
79
+ *
80
+ * @default "none"
44
81
  */
45
82
  endpointValidationMode?: "voices" | "endpoint" | "none";
46
83
  /**
@@ -68,14 +105,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
68
105
  * fetch on the browser default, `"same-origin"`.
69
106
  */
70
107
  credentials?: "omit" | "same-origin" | "include";
71
- /**
72
- * Validate API endpoint availability during initialization (slower but safer)
73
- *
74
- * @extension Performance vs safety tradeoff
75
- * @default false (fast initialization, fail on first synthesis if unavailable)
76
- * @note When true, adds 100-500ms to initialization time
77
- */
78
- validateEndpoint?: boolean;
79
108
  }
80
109
  /**
81
110
  * Server TTS Provider
@@ -85,8 +114,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
85
114
  */
86
115
  export declare class ServerTTSProvider implements ITTSProvider {
87
116
  readonly providerId = "server-tts";
88
- readonly providerName = "Server TTS";
89
- readonly version = "1.0.0";
90
117
  private config;
91
118
  private adapter;
92
119
  private telemetryReporter;
@@ -94,10 +121,8 @@ export declare class ServerTTSProvider implements ITTSProvider {
94
121
  /**
95
122
  * Initialize the server TTS provider.
96
123
  *
97
- * This is designed to be fast by default (no API calls).
98
- * Set validateEndpoint: true in config to test API availability during initialization.
99
- *
100
- * @performance Default: <10ms, With validation: 100-500ms
124
+ * Makes no request unless `endpointValidationMode` names a probe, which
125
+ * adds 100-500ms.
101
126
  */
102
127
  initialize(config: TTSConfig): Promise<ITTSProviderImplementation>;
103
128
  /**
@@ -106,7 +131,6 @@ export declare class ServerTTSProvider implements ITTSProvider {
106
131
  * @performance 100-500ms depending on network
107
132
  */
108
133
  private testAPIAvailability;
109
- supportsFeature(feature: TTSFeature): boolean;
110
134
  getCapabilities(): TTSProviderCapabilities;
111
135
  destroy(): void;
112
136
  }
@@ -4,10 +4,27 @@
4
4
  * Provides high-quality TTS by calling a server-side API that uses
5
5
  * providers like AWS Polly, Google Cloud TTS, etc.
6
6
  * Returns audio with precise word-level timing (speech marks).
7
+ *
8
+ * Layout: config types; the asset-URL and auth helpers that guard fetches of
9
+ * server-returned URLs; wire types; mode resolvers; the `pie` and `custom`
10
+ * transport adapters (`ADAPTERS`); `ServerTTSProviderImpl`, which owns one
11
+ * playback (synthesis, audio element, word highlighting); and
12
+ * `ServerTTSProvider`, which picks the adapter, runs the optional endpoint probe
13
+ * and hands out implementations.
7
14
  */
8
- import { createPieLogger, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared/pie";
9
- import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
10
- const logger = createPieLogger("server-tts-provider", () => isGlobalDebugEnabled());
15
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
16
+ import { anchorSpeechMarks, normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
17
+ // Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
18
+ const logger = createPieLogger("server-tts-provider", isTtsDebugEnabled);
19
+ // Telemetry names the tool by its canonical id, as the toolkit does.
20
+ const TOOL_ID = "textToSpeech";
21
+ // Word-highlight polling period; `startWordHighlighting` gives the reasons.
22
+ const HIGHLIGHT_INTERVAL_MS = 50;
23
+ // How long the endpoint probe waits before reporting the server unreachable.
24
+ const ENDPOINT_PROBE_TIMEOUT_MS = 5000;
25
+ // Debug-log previews of an error body and of the text under synthesis.
26
+ const RAW_BODY_PREVIEW_LENGTH = 500;
27
+ const TEXT_PREVIEW_LENGTH = 120;
11
28
  /**
12
29
  * Parse a TTS asset URL (speech marks or audio). Rejects anything that is
13
30
  * not an absolute http(s) URL; relative URLs are resolved against
@@ -113,14 +130,16 @@ function assetFetchHeaders(requestHeaders, config) {
113
130
  return assetHeaders;
114
131
  }
115
132
  const getTelemetryReporter = (config) => {
116
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
117
- ? config.providerOptions
118
- : {};
119
- const reporter = providerOptions.__pieTelemetry;
120
- return typeof reporter === "function"
121
- ? reporter
122
- : undefined;
133
+ const reporter = config.providerOptions?.__pieTelemetry;
134
+ return typeof reporter === "function" ? reporter : undefined;
123
135
  };
136
+ /** The payload of a `pie-tool-backend-call-*` telemetry event. */
137
+ const backendTelemetry = (config, operation, extra) => ({
138
+ toolId: TOOL_ID,
139
+ backend: config.provider || "server",
140
+ operation,
141
+ ...extra,
142
+ });
124
143
  const MAX_TEXT_LENGTH_BY_MODE = {
125
144
  pie: 3000,
126
145
  custom: 3000,
@@ -166,18 +185,28 @@ const resolveEndpointMode = (config, mode) => {
166
185
  return config.endpointMode;
167
186
  return mode === "custom" ? "rootPost" : "synthesizePath";
168
187
  };
169
- const resolveValidationMode = (config, mode) => {
170
- if (config.endpointValidationMode)
171
- return config.endpointValidationMode;
172
- return mode === "custom" ? "none" : "voices";
173
- };
188
+ const resolveValidationMode = (config) => config.endpointValidationMode === "voices" ||
189
+ config.endpointValidationMode === "endpoint"
190
+ ? config.endpointValidationMode
191
+ : "none";
192
+ const SPEED_RATE_BUCKETS = new Set([
193
+ "slow",
194
+ "medium",
195
+ "fast",
196
+ ]);
174
197
  const resolveSpeedRate = (config) => {
175
- const providerOptions = (config.providerOptions || {});
176
- if (typeof providerOptions.speedRate === "string") {
177
- return providerOptions.speedRate;
178
- }
198
+ const speedRate = config.providerOptions?.speedRate;
199
+ if (SPEED_RATE_BUCKETS.has(speedRate))
200
+ return speedRate;
179
201
  return resolveSpeedRateBucket(config.rate);
180
202
  };
203
+ /** The language a speak named for its content, if any. */
204
+ const contentLanguageOf = (config) => {
205
+ const language = config.providerOptions?.contentLanguage;
206
+ return typeof language === "string" && language.trim()
207
+ ? language.trim()
208
+ : undefined;
209
+ };
181
210
  const parseInlineSpeechMarks = (input) => {
182
211
  if (!Array.isArray(input))
183
212
  return [];
@@ -218,7 +247,7 @@ const pieAdapter = {
218
247
  return endpointMode === "rootPost" ? base : `${base}/synthesize`;
219
248
  },
220
249
  buildRequestBody: (text, config) => {
221
- const providerOptions = (config.providerOptions || {});
250
+ const providerOptions = config.providerOptions || {};
222
251
  const engine = typeof config.engine === "string"
223
252
  ? config.engine
224
253
  : typeof providerOptions.engine === "string"
@@ -240,7 +269,7 @@ const pieAdapter = {
240
269
  text,
241
270
  provider: config.provider || "polly",
242
271
  voice: config.voice,
243
- language: config.language,
272
+ language: contentLanguageOf(config) ?? config.language,
244
273
  rate: config.rate,
245
274
  engine,
246
275
  sampleRate,
@@ -249,7 +278,7 @@ const pieAdapter = {
249
278
  includeSpeechMarks: true,
250
279
  };
251
280
  },
252
- parseResponse: async (response, _config, _headers, _signal, _text) => {
281
+ parseResponse: async (response, _config, _headers, _signal, text) => {
253
282
  const data = await response.json();
254
283
  return {
255
284
  audio: {
@@ -257,7 +286,9 @@ const pieAdapter = {
257
286
  data: data.audio,
258
287
  contentType: data.contentType,
259
288
  },
260
- speechMarks: Array.isArray(data.speechMarks) ? data.speechMarks : [],
289
+ speechMarks: Array.isArray(data.speechMarks)
290
+ ? anchorSpeechMarks(data.speechMarks, text)
291
+ : [],
261
292
  };
262
293
  },
263
294
  };
@@ -269,10 +300,14 @@ const customAdapter = {
269
300
  return endpointMode === "synthesizePath" ? `${base}/synthesize` : base;
270
301
  },
271
302
  buildRequestBody: (text, config) => {
272
- const providerOptions = (config.providerOptions || {});
273
- const langId = typeof providerOptions.lang_id === "string"
303
+ const providerOptions = config.providerOptions || {};
304
+ // A host's `lang_id` names a locale from the service's own roster, which a
305
+ // content tag such as `es` is not: SchoolCity reads one it lacks as en-US.
306
+ const pinned = typeof providerOptions.lang_id === "string" &&
307
+ providerOptions.lang_id.trim()
274
308
  ? providerOptions.lang_id
275
- : config.language || "en-US";
309
+ : undefined;
310
+ const langId = pinned ?? contentLanguageOf(config) ?? (config.language || "en-US");
276
311
  const cache = typeof providerOptions.cache === "boolean" ? providerOptions.cache : true;
277
312
  return {
278
313
  text,
@@ -347,7 +382,11 @@ class ServerTTSProviderImpl {
347
382
  */
348
383
  highlightCursor = -1;
349
384
  intentionallyStopped = false;
385
+ // Set while a synthesis request is in flight.
350
386
  activeSynthesisController = null;
387
+ // Starts the current audio element; a pause before it sounds leaves the
388
+ // start to resume.
389
+ startCurrentAudio = null;
351
390
  synthesisRunId = 0;
352
391
  telemetryReporter;
353
392
  onPlaybackStart;
@@ -366,15 +405,28 @@ class ServerTTSProviderImpl {
366
405
  }
367
406
  }
368
407
  async speak(text) {
369
- // Stop any current playback
370
408
  this.stop();
371
- // Reset intentionally stopped flag for new playback
372
409
  this.intentionallyStopped = false;
373
410
  const runId = ++this.synthesisRunId;
374
411
  const synthesisController = new AbortController();
375
412
  this.activeSynthesisController = synthesisController;
376
- // Call server API to synthesize speech
377
- const { audioUrl, wordTimings } = await this.synthesizeSpeech(text, synthesisController.signal, runId);
413
+ let synthesized;
414
+ try {
415
+ synthesized = await this.synthesizeSpeech(text, synthesisController.signal, runId);
416
+ }
417
+ catch (error) {
418
+ // A stop or a newer speak aborted this synthesis: not a failure.
419
+ if (runId !== this.synthesisRunId || synthesisController.signal.aborted) {
420
+ return;
421
+ }
422
+ throw error;
423
+ }
424
+ finally {
425
+ if (this.activeSynthesisController === synthesisController) {
426
+ this.activeSynthesisController = null;
427
+ }
428
+ }
429
+ const { audioUrl, wordTimings } = synthesized;
378
430
  if (runId !== this.synthesisRunId) {
379
431
  URL.revokeObjectURL(audioUrl);
380
432
  return;
@@ -382,60 +434,67 @@ class ServerTTSProviderImpl {
382
434
  this.wordTimings = wordTimings;
383
435
  this.highlightCursor = -1;
384
436
  return new Promise((resolve, reject) => {
385
- // Create audio element
386
437
  const audio = new Audio(audioUrl);
387
438
  this.currentAudio = audio;
388
- // Apply volume from config
389
439
  if (this.config.volume !== undefined) {
390
440
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
391
441
  }
392
- // Setup event handlers
393
- audio.onplay = () => {
394
- if (runId !== this.synthesisRunId || this.currentAudio !== audio) {
442
+ // A stop or a newer speak supersedes this audio: its late events must
443
+ // neither clear the current audio's state nor fail the current speak.
444
+ const superseded = () => runId !== this.synthesisRunId || this.currentAudio !== audio;
445
+ // `playing`, not `play`: `play` fires when playback is requested, before
446
+ // the audio has buffered and sounds.
447
+ audio.onplaying = () => {
448
+ if (superseded())
395
449
  return;
396
- }
397
450
  this.pausedState = false;
398
451
  try {
399
- this.onPlaybackStart?.();
452
+ this.onPlaybackStart?.({
453
+ wordBoundaries: this.wordTimings.length > 0,
454
+ });
400
455
  }
401
456
  catch (error) {
402
457
  logger.warn("playback-start callback failed:", error);
403
458
  }
404
- // Start word highlighting
405
459
  if (this.onWordBoundary && this.wordTimings.length > 0) {
406
460
  this.startWordHighlighting();
407
461
  }
408
462
  };
409
463
  audio.onended = () => {
410
- this.stopWordHighlighting();
411
464
  URL.revokeObjectURL(audioUrl);
412
- this.currentAudio = null;
413
- this.wordTimings = [];
414
- this.highlightCursor = -1;
465
+ if (!superseded())
466
+ this.clearCurrentAudio();
415
467
  resolve();
416
468
  };
417
- audio.onerror = (event) => {
418
- this.stopWordHighlighting();
469
+ audio.onerror = () => {
419
470
  URL.revokeObjectURL(audioUrl);
420
- this.currentAudio = null;
421
- this.wordTimings = [];
422
- this.highlightCursor = -1;
423
- void event;
424
- // Only reject if this wasn't an intentional stop
425
- if (!this.intentionallyStopped) {
426
- reject(new Error("Failed to play audio from server"));
427
- }
428
- else {
429
- // Intentional stop, resolve normally
471
+ if (superseded() || this.intentionallyStopped) {
430
472
  resolve();
473
+ return;
431
474
  }
475
+ this.clearCurrentAudio();
476
+ reject(new Error("Failed to play audio from server"));
432
477
  };
433
478
  audio.onpause = () => {
479
+ if (superseded())
480
+ return;
434
481
  this.stopWordHighlighting();
435
482
  this.pausedState = true;
436
483
  };
437
- // Start playback
438
- audio.play().catch(reject);
484
+ const start = () => {
485
+ audio.play().catch((error) => {
486
+ // A pause before the audio sounds aborts its play; resume starts it.
487
+ const aborted = error?.name === "AbortError";
488
+ if (aborted && this.pausedState && this.currentAudio === audio) {
489
+ return;
490
+ }
491
+ reject(error);
492
+ });
493
+ };
494
+ this.startCurrentAudio = start;
495
+ // A pause during synthesis holds the audio until resume.
496
+ if (!this.pausedState)
497
+ start();
439
498
  });
440
499
  }
441
500
  /**
@@ -443,16 +502,11 @@ class ServerTTSProviderImpl {
443
502
  */
444
503
  async synthesizeSpeech(text, signal, runId) {
445
504
  const synthStartedAt = Date.now();
446
- await this.emitTelemetry("pie-tool-backend-call-start", {
447
- toolId: "tts",
448
- backend: this.config.provider || "server",
449
- operation: "synthesize-speech",
450
- });
505
+ await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "synthesize-speech"));
451
506
  const headers = {
452
507
  "Content-Type": "application/json",
453
508
  ...this.config.headers,
454
509
  };
455
- // Add authentication if provided
456
510
  if (this.config.authToken) {
457
511
  headers["Authorization"] = `Bearer ${this.config.authToken}`;
458
512
  }
@@ -469,14 +523,13 @@ class ServerTTSProviderImpl {
469
523
  });
470
524
  }
471
525
  catch (error) {
472
- await this.emitTelemetry("pie-tool-backend-call-error", {
473
- toolId: "tts",
474
- backend: this.config.provider || "server",
475
- operation: "synthesize-speech",
526
+ if (signal.aborted)
527
+ throw error;
528
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
476
529
  duration: Date.now() - synthStartedAt,
477
530
  errorType: "TTSBackendNetworkError",
478
531
  message: error instanceof Error ? error.message : String(error),
479
- });
532
+ }));
480
533
  throw error;
481
534
  }
482
535
  })();
@@ -500,7 +553,7 @@ class ServerTTSProviderImpl {
500
553
  // itself stays behind the debug gate; the length above is what says
501
554
  // a body arrived and was not JSON.
502
555
  logger.debug("non-OK response body", {
503
- rawBodyPreview: rawBody.slice(0, 500),
556
+ rawBodyPreview: rawBody.slice(0, RAW_BODY_PREVIEW_LENGTH),
504
557
  });
505
558
  }
506
559
  }
@@ -509,15 +562,12 @@ class ServerTTSProviderImpl {
509
562
  ? errorData.error.message
510
563
  : undefined) ||
511
564
  `Server returned ${response.status}`;
512
- await this.emitTelemetry("pie-tool-backend-call-error", {
513
- toolId: "tts",
514
- backend: this.config.provider || "server",
515
- operation: "synthesize-speech",
565
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
516
566
  duration: Date.now() - synthStartedAt,
517
567
  statusCode: response.status,
518
568
  errorType: "TTSBackendRequestError",
519
569
  message: errorMessage,
520
- });
570
+ }));
521
571
  logger.error("synthesize request failed", {
522
572
  status: response.status,
523
573
  url: synthUrl,
@@ -529,7 +579,10 @@ class ServerTTSProviderImpl {
529
579
  // can echo it, so both stay behind the debug gate. `errorMessage` above is
530
580
  // thrown to the caller regardless, and `textLength` is what correlates a
531
581
  // failure with a payload without publishing what the learner is reading.
532
- const textPreview = text.replace(/\s+/g, " ").trim().slice(0, 120);
582
+ const textPreview = text
583
+ .replace(/\s+/g, " ")
584
+ .trim()
585
+ .slice(0, TEXT_PREVIEW_LENGTH);
533
586
  logger.debug("synthesize request failed for text", {
534
587
  responseBody: errorData,
535
588
  textPreview: textPreview.length < text.length ? `${textPreview}…` : textPreview,
@@ -547,22 +600,15 @@ class ServerTTSProviderImpl {
547
600
  else {
548
601
  const audioAssetUrl = normalized.audio.url;
549
602
  const assetFetchStartedAt = Date.now();
550
- await this.emitTelemetry("pie-tool-backend-call-start", {
551
- toolId: "tts",
552
- backend: this.config.provider || "server",
553
- operation: "fetch-synthesized-audio-asset",
554
- });
603
+ await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "fetch-synthesized-audio-asset"));
555
604
  const assetHeaders = assetFetchHeaders(headers, this.config);
556
605
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
557
606
  if (parsedAssetUrl === null) {
558
- await this.emitTelemetry("pie-tool-backend-call-error", {
559
- toolId: "tts",
560
- backend: this.config.provider || "server",
561
- operation: "fetch-synthesized-audio-asset",
607
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
562
608
  duration: Date.now() - assetFetchStartedAt,
563
609
  errorType: "TTSAssetInvalidUrl",
564
610
  message: "TTS asset URL rejected (non-http(s) or malformed)",
565
- });
611
+ }));
566
612
  throw new Error("TTS asset URL rejected (non-http(s) or malformed)");
567
613
  }
568
614
  const assetTrusted = isOriginTrustedForAuth(parsedAssetUrl, this.config);
@@ -579,46 +625,35 @@ class ServerTTSProviderImpl {
579
625
  });
580
626
  }
581
627
  catch (error) {
582
- await this.emitTelemetry("pie-tool-backend-call-error", {
583
- toolId: "tts",
584
- backend: this.config.provider || "server",
585
- operation: "fetch-synthesized-audio-asset",
628
+ if (signal.aborted)
629
+ throw error;
630
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
586
631
  duration: Date.now() - assetFetchStartedAt,
587
632
  errorType: "TTSAssetNetworkError",
588
633
  message: error instanceof Error ? error.message : String(error),
589
- });
634
+ }));
590
635
  throw error;
591
636
  }
592
637
  })();
593
638
  if (!audioResponse.ok) {
594
- await this.emitTelemetry("pie-tool-backend-call-error", {
595
- toolId: "tts",
596
- backend: this.config.provider || "server",
597
- operation: "fetch-synthesized-audio-asset",
639
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
598
640
  duration: Date.now() - assetFetchStartedAt,
599
641
  statusCode: audioResponse.status,
600
642
  errorType: "TTSAssetFetchError",
601
643
  message: `Failed to download synthesized audio (${audioResponse.status})`,
602
- });
644
+ }));
603
645
  throw new Error(`Failed to download synthesized audio (${audioResponse.status})`);
604
646
  }
605
647
  audioBlob = await audioResponse.blob();
606
- await this.emitTelemetry("pie-tool-backend-call-success", {
607
- toolId: "tts",
608
- backend: this.config.provider || "server",
609
- operation: "fetch-synthesized-audio-asset",
648
+ await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
610
649
  duration: Date.now() - assetFetchStartedAt,
611
- });
650
+ }));
612
651
  }
613
652
  const audioUrl = URL.createObjectURL(audioBlob);
614
- // Convert speech marks to word timings
615
653
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
616
- await this.emitTelemetry("pie-tool-backend-call-success", {
617
- toolId: "tts",
618
- backend: this.config.provider || "server",
619
- operation: "synthesize-speech",
654
+ await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "synthesize-speech", {
620
655
  duration: Date.now() - synthStartedAt,
621
- });
656
+ }));
622
657
  return { audioUrl, wordTimings };
623
658
  }
624
659
  /**
@@ -702,7 +737,7 @@ class ServerTTSProviderImpl {
702
737
  currentTimeMs,
703
738
  });
704
739
  this.onWordBoundary?.(timing.word, timing.charIndex, timing.length);
705
- }, 50);
740
+ }, HIGHLIGHT_INTERVAL_MS);
706
741
  }
707
742
  /**
708
743
  * Index of the last timing whose start time has arrived at `currentTimeMs`,
@@ -734,25 +769,39 @@ class ServerTTSProviderImpl {
734
769
  this.highlightInterval = null;
735
770
  }
736
771
  }
772
+ /** Pause playback, or, while synthesis is in flight, hold its audio. */
737
773
  pause() {
738
- if (this.currentAudio && !this.pausedState) {
774
+ if (this.pausedState)
775
+ return;
776
+ if (this.currentAudio) {
739
777
  this.currentAudio.pause();
740
778
  this.stopWordHighlighting();
741
- this.pausedState = true;
742
779
  }
780
+ else if (!this.activeSynthesisController) {
781
+ return;
782
+ }
783
+ this.pausedState = true;
743
784
  }
744
785
  resume() {
745
- if (this.currentAudio && this.pausedState) {
746
- this.currentAudio.play();
747
- this.pausedState = false;
748
- // Resume word highlighting
749
- if (this.onWordBoundary && this.wordTimings.length > 0) {
750
- this.startWordHighlighting();
751
- }
786
+ if (!this.pausedState)
787
+ return;
788
+ this.pausedState = false;
789
+ if (!this.currentAudio)
790
+ return;
791
+ this.startCurrentAudio?.();
792
+ if (this.onWordBoundary && this.wordTimings.length > 0) {
793
+ this.startWordHighlighting();
752
794
  }
753
795
  }
796
+ clearCurrentAudio() {
797
+ this.stopWordHighlighting();
798
+ this.currentAudio = null;
799
+ this.wordTimings = [];
800
+ this.highlightCursor = -1;
801
+ }
754
802
  stop() {
755
803
  this.synthesisRunId += 1;
804
+ this.startCurrentAudio = null;
756
805
  if (this.activeSynthesisController) {
757
806
  this.activeSynthesisController.abort();
758
807
  this.activeSynthesisController = null;
@@ -772,20 +821,13 @@ class ServerTTSProviderImpl {
772
821
  this.wordTimings = [];
773
822
  this.highlightCursor = -1;
774
823
  }
775
- isPlaying() {
776
- return this.currentAudio !== null && !this.pausedState;
777
- }
778
- isPaused() {
779
- return this.pausedState;
780
- }
781
824
  /**
782
- * Update settings dynamically (rate, pitch, voice)
783
- * Note: Server-side synthesis bakes rate, pitch, and voice into the audio and
784
- * speech marks. Settings updates are stored for the next synthesis; active
785
- * speed changes are handled by TTSService replaying from the current segment.
825
+ * Store rate, pitch, voice and the per-speak language for the next
826
+ * synthesis. Server-side synthesis bakes them into the audio and speech
827
+ * marks, so an active speed change is TTSService's job: it replays from the
828
+ * current segment.
786
829
  */
787
830
  updateSettings(settings) {
788
- // Update config
789
831
  if (settings.rate !== undefined) {
790
832
  this.config.rate = settings.rate;
791
833
  this.config.providerOptions = {
@@ -794,13 +836,20 @@ class ServerTTSProviderImpl {
794
836
  };
795
837
  }
796
838
  if (settings.pitch !== undefined) {
797
- // Server-side pitch is baked into audio, so this only affects next speak()
798
839
  this.config.pitch = settings.pitch;
799
840
  }
800
841
  if (settings.voice !== undefined) {
801
- // Voice change requires resynthesis, affects next speak()
802
842
  this.config.voice = settings.voice;
803
843
  }
844
+ // Only the per-speak language: the toolkit's other options would replace
845
+ // the speed bucket a rate change set above.
846
+ if (settings.providerOptions &&
847
+ "contentLanguage" in settings.providerOptions) {
848
+ this.config.providerOptions = {
849
+ ...this.config.providerOptions,
850
+ contentLanguage: settings.providerOptions.contentLanguage,
851
+ };
852
+ }
804
853
  }
805
854
  }
806
855
  /**
@@ -811,8 +860,6 @@ class ServerTTSProviderImpl {
811
860
  */
812
861
  export class ServerTTSProvider {
813
862
  providerId = "server-tts";
814
- providerName = "Server TTS";
815
- version = "1.0.0";
816
863
  config = null;
817
864
  adapter = null;
818
865
  telemetryReporter;
@@ -827,10 +874,8 @@ export class ServerTTSProvider {
827
874
  /**
828
875
  * Initialize the server TTS provider.
829
876
  *
830
- * This is designed to be fast by default (no API calls).
831
- * Set validateEndpoint: true in config to test API availability during initialization.
832
- *
833
- * @performance Default: <10ms, With validation: 100-500ms
877
+ * Makes no request unless `endpointValidationMode` names a probe, which
878
+ * adds 100-500ms.
834
879
  */
835
880
  async initialize(config) {
836
881
  const serverConfig = config;
@@ -841,32 +886,22 @@ export class ServerTTSProvider {
841
886
  this.telemetryReporter = getTelemetryReporter(serverConfig);
842
887
  const transportMode = resolveTransportMode(serverConfig);
843
888
  this.adapter = ADAPTERS[transportMode];
844
- // Only test API availability if explicitly requested (slower but safer)
845
- if (serverConfig.validateEndpoint) {
889
+ const validationMode = resolveValidationMode(serverConfig);
890
+ if (validationMode !== "none") {
846
891
  const validationStartedAt = Date.now();
847
- await this.emitTelemetry("pie-tool-backend-call-start", {
848
- toolId: "tts",
849
- backend: serverConfig.provider || "server",
850
- operation: "validate-endpoint",
851
- });
852
- const available = await this.testAPIAvailability();
892
+ await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(serverConfig, "validate-endpoint"));
893
+ const available = await this.testAPIAvailability(validationMode);
853
894
  if (!available) {
854
- await this.emitTelemetry("pie-tool-backend-call-error", {
855
- toolId: "tts",
856
- backend: serverConfig.provider || "server",
857
- operation: "validate-endpoint",
895
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(serverConfig, "validate-endpoint", {
858
896
  duration: Date.now() - validationStartedAt,
859
897
  errorType: "TTSEndpointValidationError",
860
898
  message: `Server TTS API not available at ${serverConfig.apiEndpoint}`,
861
- });
899
+ }));
862
900
  throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
863
901
  }
864
- await this.emitTelemetry("pie-tool-backend-call-success", {
865
- toolId: "tts",
866
- backend: serverConfig.provider || "server",
867
- operation: "validate-endpoint",
902
+ await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(serverConfig, "validate-endpoint", {
868
903
  duration: Date.now() - validationStartedAt,
869
- });
904
+ }));
870
905
  }
871
906
  return new ServerTTSProviderImpl(serverConfig, this.adapter);
872
907
  }
@@ -875,7 +910,7 @@ export class ServerTTSProvider {
875
910
  *
876
911
  * @performance 100-500ms depending on network
877
912
  */
878
- async testAPIAvailability() {
913
+ async testAPIAvailability(mode) {
879
914
  if (!this.config || !this.adapter)
880
915
  return false;
881
916
  try {
@@ -883,14 +918,8 @@ export class ServerTTSProvider {
883
918
  if (this.config.authToken) {
884
919
  headers["Authorization"] = `Bearer ${this.config.authToken}`;
885
920
  }
886
- // Create abort controller for timeout
887
921
  const controller = new AbortController();
888
- const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
889
- const mode = resolveValidationMode(this.config, this.adapter.id);
890
- if (mode === "none") {
891
- clearTimeout(timeoutId);
892
- return true;
893
- }
922
+ const timeoutId = setTimeout(() => controller.abort(), ENDPOINT_PROBE_TIMEOUT_MS);
894
923
  const validationUrls = mode === "voices"
895
924
  ? resolveVoicesValidationUrls(this.config)
896
925
  : [this.adapter.resolveSynthesisUrl(this.config)];
@@ -921,30 +950,12 @@ export class ServerTTSProvider {
921
950
  return false;
922
951
  }
923
952
  }
924
- supportsFeature(feature) {
925
- switch (feature) {
926
- case "pause":
927
- case "resume":
928
- case "wordBoundary":
929
- case "voiceSelection":
930
- case "rateControl":
931
- return true;
932
- case "pitchControl":
933
- // Depends on server provider, assume no for safety
934
- return false;
935
- default:
936
- return false;
937
- }
938
- }
939
953
  getCapabilities() {
940
954
  const mode = this.config ? resolveTransportMode(this.config) : "pie";
941
955
  return {
942
- supportsPause: true,
943
- supportsResume: true,
944
- supportsWordBoundary: true, // ✅ Via speech marks from server
945
- supportsVoiceSelection: true,
946
- supportsRateControl: true,
947
- supportsPitchControl: false, // Depends on server provider
956
+ // Word boundaries come from the server's speech marks; a response
957
+ // without them reports so at its playback start.
958
+ supportsWordBoundary: true,
948
959
  supportsSSML: resolveSupportsSSML(this.config),
949
960
  maxTextLength: MAX_TEXT_LENGTH_BY_MODE[mode],
950
961
  };
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.74",
3
+ "version": "1.0.0-beta.0",
4
4
  "description": "Client-side TTS provider that calls server API for synthesis",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,8 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "dev": "tsc --watch",
20
- "test": "vitest run --passWithNoTests",
21
- "test:coverage": "vitest --coverage"
20
+ "test": "vitest run"
22
21
  },
23
22
  "keywords": [
24
23
  "tts",
@@ -38,9 +37,9 @@
38
37
  "access": "public"
39
38
  },
40
39
  "dependencies": {
41
- "@pie-players/pie-players-shared": "0.3.74",
42
- "@pie-players/pie-tts": "0.3.74",
43
- "@pie-players/tts-server-core": "0.3.74"
40
+ "@pie-players/pie-players-shared": "1.0.0-beta.0",
41
+ "@pie-players/pie-tts": "1.0.0-beta.0",
42
+ "@pie-players/tts-server-core": "1.0.0-beta.0"
44
43
  },
45
44
  "devDependencies": {
46
45
  "typescript": "^5.9.3",