@pie-players/tts-client-server 0.3.75 → 1.0.0-beta.1
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 +54 -56
- package/dist/ServerTTSProvider.d.ts +21 -23
- package/dist/ServerTTSProvider.js +84 -117
- package/package.json +5 -6
package/README.md
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
# @pie-players/tts-client-server
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
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
|
|
|
@@ -38,7 +36,7 @@ import { TTSService } from '@pie-players/pie-assessment-toolkit';
|
|
|
38
36
|
|
|
39
37
|
const provider = new ServerTTSProvider();
|
|
40
38
|
const config: ServerTTSProviderConfig = {
|
|
41
|
-
apiEndpoint: '/api/tts', //
|
|
39
|
+
apiEndpoint: '/api/tts', // the host's TTS API route
|
|
42
40
|
provider: 'polly', // Server-side provider to use
|
|
43
41
|
voice: 'Joanna',
|
|
44
42
|
language: 'en-US',
|
|
@@ -52,7 +50,8 @@ await ttsService.initialize(provider, config);
|
|
|
52
50
|
provider reads none of them from `providerOptions`. `providerOptions` carries
|
|
53
51
|
request extras: the PIE transport forwards its `engine` (when no top-level
|
|
54
52
|
`engine` is set), `sampleRate`, `format` and `speechMarkTypes`, and the custom
|
|
55
|
-
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`).
|
|
56
55
|
|
|
57
56
|
`apiEndpoint` is required when using `ServerTTSProvider` directly, and
|
|
58
57
|
`initialize` throws without it. Toolkit-level `tools.providers.textToSpeech`
|
|
@@ -77,11 +76,21 @@ await ttsService.speak(document.getElementById('content'));
|
|
|
77
76
|
|
|
78
77
|
## Transport Modes
|
|
79
78
|
|
|
80
|
-
`ServerTTSProvider`
|
|
79
|
+
`ServerTTSProvider` supports two transport modes:
|
|
81
80
|
|
|
82
|
-
- `pie
|
|
81
|
+
- `pie`: POST `${apiEndpoint}/synthesize`, inline base64 audio + inline speech marks
|
|
83
82
|
- `custom`: POST to root endpoint, then fetch `audioContent` and JSONL marks URLs
|
|
84
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
|
+
|
|
85
94
|
### PIE mode request
|
|
86
95
|
|
|
87
96
|
**Request:**
|
|
@@ -115,18 +124,6 @@ await ttsService.speak(document.getElementById('content'));
|
|
|
115
124
|
}
|
|
116
125
|
```
|
|
117
126
|
|
|
118
|
-
### PIE mode response
|
|
119
|
-
|
|
120
|
-
```json
|
|
121
|
-
{
|
|
122
|
-
"audio": "base64-encoded-audio",
|
|
123
|
-
"contentType": "audio/mpeg",
|
|
124
|
-
"speechMarks": [
|
|
125
|
-
{ "time": 0, "type": "word", "start": 0, "end": 5, "value": "Hello" }
|
|
126
|
-
]
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
127
|
### Custom mode request
|
|
131
128
|
|
|
132
129
|
```json
|
|
@@ -162,7 +159,7 @@ Speech marks are fetched from the `word` URL and parsed as JSONL.
|
|
|
162
159
|
|
|
163
160
|
## SvelteKit Implementation Example
|
|
164
161
|
|
|
165
|
-
|
|
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.
|
|
166
163
|
|
|
167
164
|
Example route structure:
|
|
168
165
|
```
|
|
@@ -181,8 +178,7 @@ interface ServerTTSProviderConfig {
|
|
|
181
178
|
provider?: string; // Server provider ('polly', 'google', etc.)
|
|
182
179
|
transportMode?: 'pie' | 'custom';
|
|
183
180
|
endpointMode?: 'synthesizePath' | 'rootPost';
|
|
184
|
-
endpointValidationMode?: 'voices' | 'endpoint' | 'none';
|
|
185
|
-
validateEndpoint?: boolean; // Probe the endpoint during initialize()
|
|
181
|
+
endpointValidationMode?: 'voices' | 'endpoint' | 'none'; // Probe run by initialize(); default 'none'
|
|
186
182
|
authToken?: string; // JWT or API key
|
|
187
183
|
includeAuthOnAssetFetch?: boolean;
|
|
188
184
|
assetOrigins?: string[]; // Trusted origins for Authorization header
|
|
@@ -236,33 +232,35 @@ for the host-wide contract this provider fits into.
|
|
|
236
232
|
|
|
237
233
|
## How It Works
|
|
238
234
|
|
|
239
|
-
1. **
|
|
235
|
+
1. **TTSService calls** `speak(text)` with the text it resolved from the element or range
|
|
240
236
|
2. **Adapter builds** backend-specific request payload
|
|
241
237
|
3. **Provider POSTs** to resolved synthesis endpoint (`/synthesize` or root POST)
|
|
242
238
|
4. **Adapter normalizes** response into audio + speech marks
|
|
243
239
|
5. **Client loads** audio as Blob URL
|
|
244
240
|
6. **Client plays** audio via HTMLAudioElement
|
|
245
241
|
7. **Client polls** audio time every 50ms
|
|
246
|
-
8. **Client
|
|
242
|
+
8. **Client reports** the word at the current audio time through the word-boundary callback
|
|
247
243
|
9. **TTSService** highlights words in DOM
|
|
248
244
|
|
|
249
245
|
## Word Highlighting Synchronization
|
|
250
246
|
|
|
251
|
-
|
|
247
|
+
Every 50ms the provider reads the audio's current time and reports the last
|
|
248
|
+
word whose start time has arrived, once:
|
|
252
249
|
|
|
253
250
|
```typescript
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
onWordBoundary('', timing.charIndex, timing.length);
|
|
261
|
-
}
|
|
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);
|
|
262
257
|
}
|
|
263
258
|
```
|
|
264
259
|
|
|
265
|
-
|
|
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.
|
|
266
264
|
|
|
267
265
|
## Memory Management
|
|
268
266
|
|
|
@@ -280,16 +278,17 @@ try {
|
|
|
280
278
|
await ttsService.speak(document.getElementById('content'));
|
|
281
279
|
} catch (error) {
|
|
282
280
|
console.error('TTS failed:', error.message);
|
|
283
|
-
//
|
|
281
|
+
// A failed read rejects without switching provider; the toolkit coordinator
|
|
282
|
+
// falls back to browser TTS only when a provider fails to start.
|
|
284
283
|
}
|
|
285
284
|
```
|
|
286
285
|
|
|
287
286
|
## Browser Compatibility
|
|
288
287
|
|
|
289
|
-
-
|
|
290
|
-
-
|
|
291
|
-
-
|
|
292
|
-
-
|
|
288
|
+
- Chrome/Edge (latest)
|
|
289
|
+
- Firefox (latest)
|
|
290
|
+
- Safari (latest)
|
|
291
|
+
- Mobile browsers
|
|
293
292
|
|
|
294
293
|
Requires:
|
|
295
294
|
|
|
@@ -298,12 +297,11 @@ Requires:
|
|
|
298
297
|
- `URL.createObjectURL`
|
|
299
298
|
- `atob` for base64 decoding
|
|
300
299
|
|
|
301
|
-
##
|
|
300
|
+
## Caching
|
|
302
301
|
|
|
303
|
-
|
|
304
|
-
-
|
|
305
|
-
|
|
306
|
-
- **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`.
|
|
307
305
|
|
|
308
306
|
## License
|
|
309
307
|
|
|
@@ -4,8 +4,15 @@
|
|
|
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 type { ITTSProvider, ITTSProviderImplementation, TTSConfig, TTSProviderCapabilities, TTSProviderOptions } from "@pie-players/pie-tts";
|
|
15
|
+
import type { ITTSProvider, ITTSProviderImplementation, SpeedRateBucket, TTSConfig, TTSProviderCapabilities, TTSProviderOptions } from "@pie-players/pie-tts";
|
|
9
16
|
/**
|
|
10
17
|
* The provider options the server provider reads. `contentLanguage`, set per
|
|
11
18
|
* speak, names the language synthesized: it wins over `language`, and a
|
|
@@ -20,8 +27,11 @@ export interface ServerTTSProviderOptions extends TTSProviderOptions {
|
|
|
20
27
|
format?: "mp3" | "ogg" | "pcm";
|
|
21
28
|
/** Speech mark types requested, on the pie transport. */
|
|
22
29
|
speechMarkTypes?: Array<"word" | "sentence" | "ssml">;
|
|
23
|
-
/**
|
|
24
|
-
|
|
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;
|
|
25
35
|
/**
|
|
26
36
|
* Language id, on the custom transport. Without it, the language a speak
|
|
27
37
|
* names, then `language`, then en-US.
|
|
@@ -47,8 +57,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
|
|
|
47
57
|
language?: string;
|
|
48
58
|
/** Polly engine selection when provider is AWS Polly */
|
|
49
59
|
engine?: "standard" | "neural";
|
|
50
|
-
/** Volume level 0-1 */
|
|
51
|
-
volume?: number;
|
|
52
60
|
/**
|
|
53
61
|
* Transport mode determines request/response translation strategy.
|
|
54
62
|
* - pie: POST {apiEndpoint}/synthesize, expects inline base64 audio + speech marks
|
|
@@ -62,10 +70,12 @@ export interface ServerTTSProviderConfig extends TTSConfig {
|
|
|
62
70
|
*/
|
|
63
71
|
endpointMode?: "synthesizePath" | "rootPost";
|
|
64
72
|
/**
|
|
65
|
-
*
|
|
66
|
-
* - voices:
|
|
67
|
-
* - endpoint:
|
|
68
|
-
* - none:
|
|
73
|
+
* The probe `initialize()` runs before it resolves; a failed probe rejects it.
|
|
74
|
+
* - voices: GET {apiEndpoint}/voices, trying the provider's own route first
|
|
75
|
+
* - endpoint: OPTIONS on the resolved synthesis endpoint
|
|
76
|
+
* - none: no probe, so an unreachable server fails at the first synthesis
|
|
77
|
+
*
|
|
78
|
+
* @default "none"
|
|
69
79
|
*/
|
|
70
80
|
endpointValidationMode?: "voices" | "endpoint" | "none";
|
|
71
81
|
/**
|
|
@@ -93,14 +103,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
|
|
|
93
103
|
* fetch on the browser default, `"same-origin"`.
|
|
94
104
|
*/
|
|
95
105
|
credentials?: "omit" | "same-origin" | "include";
|
|
96
|
-
/**
|
|
97
|
-
* Validate API endpoint availability during initialization (slower but safer)
|
|
98
|
-
*
|
|
99
|
-
* @extension Performance vs safety tradeoff
|
|
100
|
-
* @default false (fast initialization, fail on first synthesis if unavailable)
|
|
101
|
-
* @note When true, adds 100-500ms to initialization time
|
|
102
|
-
*/
|
|
103
|
-
validateEndpoint?: boolean;
|
|
104
106
|
}
|
|
105
107
|
/**
|
|
106
108
|
* Server TTS Provider
|
|
@@ -110,8 +112,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
|
|
|
110
112
|
*/
|
|
111
113
|
export declare class ServerTTSProvider implements ITTSProvider {
|
|
112
114
|
readonly providerId = "server-tts";
|
|
113
|
-
readonly providerName = "Server TTS";
|
|
114
|
-
readonly version = "1.0.0";
|
|
115
115
|
private config;
|
|
116
116
|
private adapter;
|
|
117
117
|
private telemetryReporter;
|
|
@@ -119,10 +119,8 @@ export declare class ServerTTSProvider implements ITTSProvider {
|
|
|
119
119
|
/**
|
|
120
120
|
* Initialize the server TTS provider.
|
|
121
121
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* @performance Default: <10ms, With validation: 100-500ms
|
|
122
|
+
* Makes no request unless `endpointValidationMode` names a probe, which
|
|
123
|
+
* adds 100-500ms.
|
|
126
124
|
*/
|
|
127
125
|
initialize(config: TTSConfig): Promise<ITTSProviderImplementation>;
|
|
128
126
|
/**
|
|
@@ -4,13 +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
15
|
import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
|
|
9
|
-
import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
|
|
16
|
+
import { anchorSpeechMarks, normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
|
|
10
17
|
// Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
|
|
11
18
|
const logger = createPieLogger("server-tts-provider", isTtsDebugEnabled);
|
|
12
19
|
// Telemetry names the tool by its canonical id, as the toolkit does.
|
|
13
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;
|
|
14
28
|
/**
|
|
15
29
|
* Parse a TTS asset URL (speech marks or audio). Rejects anything that is
|
|
16
30
|
* not an absolute http(s) URL; relative URLs are resolved against
|
|
@@ -119,6 +133,13 @@ const getTelemetryReporter = (config) => {
|
|
|
119
133
|
const reporter = config.providerOptions?.__pieTelemetry;
|
|
120
134
|
return typeof reporter === "function" ? reporter : undefined;
|
|
121
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
|
+
});
|
|
122
143
|
const MAX_TEXT_LENGTH_BY_MODE = {
|
|
123
144
|
pie: 3000,
|
|
124
145
|
custom: 3000,
|
|
@@ -164,14 +185,18 @@ const resolveEndpointMode = (config, mode) => {
|
|
|
164
185
|
return config.endpointMode;
|
|
165
186
|
return mode === "custom" ? "rootPost" : "synthesizePath";
|
|
166
187
|
};
|
|
167
|
-
const resolveValidationMode = (config
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
]);
|
|
172
197
|
const resolveSpeedRate = (config) => {
|
|
173
198
|
const speedRate = config.providerOptions?.speedRate;
|
|
174
|
-
if (
|
|
199
|
+
if (SPEED_RATE_BUCKETS.has(speedRate))
|
|
175
200
|
return speedRate;
|
|
176
201
|
return resolveSpeedRateBucket(config.rate);
|
|
177
202
|
};
|
|
@@ -246,6 +271,7 @@ const pieAdapter = {
|
|
|
246
271
|
voice: config.voice,
|
|
247
272
|
language: contentLanguageOf(config) ?? config.language,
|
|
248
273
|
rate: config.rate,
|
|
274
|
+
...(config.pitch !== undefined ? { pitch: config.pitch } : {}),
|
|
249
275
|
engine,
|
|
250
276
|
sampleRate,
|
|
251
277
|
format,
|
|
@@ -253,7 +279,7 @@ const pieAdapter = {
|
|
|
253
279
|
includeSpeechMarks: true,
|
|
254
280
|
};
|
|
255
281
|
},
|
|
256
|
-
parseResponse: async (response, _config, _headers, _signal,
|
|
282
|
+
parseResponse: async (response, _config, _headers, _signal, text) => {
|
|
257
283
|
const data = await response.json();
|
|
258
284
|
return {
|
|
259
285
|
audio: {
|
|
@@ -261,7 +287,9 @@ const pieAdapter = {
|
|
|
261
287
|
data: data.audio,
|
|
262
288
|
contentType: data.contentType,
|
|
263
289
|
},
|
|
264
|
-
speechMarks: Array.isArray(data.speechMarks)
|
|
290
|
+
speechMarks: Array.isArray(data.speechMarks)
|
|
291
|
+
? anchorSpeechMarks(data.speechMarks, text)
|
|
292
|
+
: [],
|
|
265
293
|
};
|
|
266
294
|
},
|
|
267
295
|
};
|
|
@@ -378,14 +406,11 @@ class ServerTTSProviderImpl {
|
|
|
378
406
|
}
|
|
379
407
|
}
|
|
380
408
|
async speak(text) {
|
|
381
|
-
// Stop any current playback
|
|
382
409
|
this.stop();
|
|
383
|
-
// Reset intentionally stopped flag for new playback
|
|
384
410
|
this.intentionallyStopped = false;
|
|
385
411
|
const runId = ++this.synthesisRunId;
|
|
386
412
|
const synthesisController = new AbortController();
|
|
387
413
|
this.activeSynthesisController = synthesisController;
|
|
388
|
-
// Call server API to synthesize speech
|
|
389
414
|
let synthesized;
|
|
390
415
|
try {
|
|
391
416
|
synthesized = await this.synthesizeSpeech(text, synthesisController.signal, runId);
|
|
@@ -410,10 +435,8 @@ class ServerTTSProviderImpl {
|
|
|
410
435
|
this.wordTimings = wordTimings;
|
|
411
436
|
this.highlightCursor = -1;
|
|
412
437
|
return new Promise((resolve, reject) => {
|
|
413
|
-
// Create audio element
|
|
414
438
|
const audio = new Audio(audioUrl);
|
|
415
439
|
this.currentAudio = audio;
|
|
416
|
-
// Apply volume from config
|
|
417
440
|
if (this.config.volume !== undefined) {
|
|
418
441
|
audio.volume = Math.max(0, Math.min(1, this.config.volume));
|
|
419
442
|
}
|
|
@@ -427,12 +450,13 @@ class ServerTTSProviderImpl {
|
|
|
427
450
|
return;
|
|
428
451
|
this.pausedState = false;
|
|
429
452
|
try {
|
|
430
|
-
this.onPlaybackStart?.(
|
|
453
|
+
this.onPlaybackStart?.({
|
|
454
|
+
wordBoundaries: this.wordTimings.length > 0,
|
|
455
|
+
});
|
|
431
456
|
}
|
|
432
457
|
catch (error) {
|
|
433
458
|
logger.warn("playback-start callback failed:", error);
|
|
434
459
|
}
|
|
435
|
-
// Start word highlighting
|
|
436
460
|
if (this.onWordBoundary && this.wordTimings.length > 0) {
|
|
437
461
|
this.startWordHighlighting();
|
|
438
462
|
}
|
|
@@ -479,16 +503,11 @@ class ServerTTSProviderImpl {
|
|
|
479
503
|
*/
|
|
480
504
|
async synthesizeSpeech(text, signal, runId) {
|
|
481
505
|
const synthStartedAt = Date.now();
|
|
482
|
-
await this.emitTelemetry("pie-tool-backend-call-start",
|
|
483
|
-
toolId: TOOL_ID,
|
|
484
|
-
backend: this.config.provider || "server",
|
|
485
|
-
operation: "synthesize-speech",
|
|
486
|
-
});
|
|
506
|
+
await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "synthesize-speech"));
|
|
487
507
|
const headers = {
|
|
488
508
|
"Content-Type": "application/json",
|
|
489
509
|
...this.config.headers,
|
|
490
510
|
};
|
|
491
|
-
// Add authentication if provided
|
|
492
511
|
if (this.config.authToken) {
|
|
493
512
|
headers["Authorization"] = `Bearer ${this.config.authToken}`;
|
|
494
513
|
}
|
|
@@ -507,14 +526,11 @@ class ServerTTSProviderImpl {
|
|
|
507
526
|
catch (error) {
|
|
508
527
|
if (signal.aborted)
|
|
509
528
|
throw error;
|
|
510
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
511
|
-
toolId: TOOL_ID,
|
|
512
|
-
backend: this.config.provider || "server",
|
|
513
|
-
operation: "synthesize-speech",
|
|
529
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
|
|
514
530
|
duration: Date.now() - synthStartedAt,
|
|
515
531
|
errorType: "TTSBackendNetworkError",
|
|
516
532
|
message: error instanceof Error ? error.message : String(error),
|
|
517
|
-
});
|
|
533
|
+
}));
|
|
518
534
|
throw error;
|
|
519
535
|
}
|
|
520
536
|
})();
|
|
@@ -538,7 +554,7 @@ class ServerTTSProviderImpl {
|
|
|
538
554
|
// itself stays behind the debug gate; the length above is what says
|
|
539
555
|
// a body arrived and was not JSON.
|
|
540
556
|
logger.debug("non-OK response body", {
|
|
541
|
-
rawBodyPreview: rawBody.slice(0,
|
|
557
|
+
rawBodyPreview: rawBody.slice(0, RAW_BODY_PREVIEW_LENGTH),
|
|
542
558
|
});
|
|
543
559
|
}
|
|
544
560
|
}
|
|
@@ -547,15 +563,12 @@ class ServerTTSProviderImpl {
|
|
|
547
563
|
? errorData.error.message
|
|
548
564
|
: undefined) ||
|
|
549
565
|
`Server returned ${response.status}`;
|
|
550
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
551
|
-
toolId: TOOL_ID,
|
|
552
|
-
backend: this.config.provider || "server",
|
|
553
|
-
operation: "synthesize-speech",
|
|
566
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
|
|
554
567
|
duration: Date.now() - synthStartedAt,
|
|
555
568
|
statusCode: response.status,
|
|
556
569
|
errorType: "TTSBackendRequestError",
|
|
557
570
|
message: errorMessage,
|
|
558
|
-
});
|
|
571
|
+
}));
|
|
559
572
|
logger.error("synthesize request failed", {
|
|
560
573
|
status: response.status,
|
|
561
574
|
url: synthUrl,
|
|
@@ -567,7 +580,10 @@ class ServerTTSProviderImpl {
|
|
|
567
580
|
// can echo it, so both stay behind the debug gate. `errorMessage` above is
|
|
568
581
|
// thrown to the caller regardless, and `textLength` is what correlates a
|
|
569
582
|
// failure with a payload without publishing what the learner is reading.
|
|
570
|
-
const textPreview = text
|
|
583
|
+
const textPreview = text
|
|
584
|
+
.replace(/\s+/g, " ")
|
|
585
|
+
.trim()
|
|
586
|
+
.slice(0, TEXT_PREVIEW_LENGTH);
|
|
571
587
|
logger.debug("synthesize request failed for text", {
|
|
572
588
|
responseBody: errorData,
|
|
573
589
|
textPreview: textPreview.length < text.length ? `${textPreview}…` : textPreview,
|
|
@@ -585,22 +601,15 @@ class ServerTTSProviderImpl {
|
|
|
585
601
|
else {
|
|
586
602
|
const audioAssetUrl = normalized.audio.url;
|
|
587
603
|
const assetFetchStartedAt = Date.now();
|
|
588
|
-
await this.emitTelemetry("pie-tool-backend-call-start",
|
|
589
|
-
toolId: TOOL_ID,
|
|
590
|
-
backend: this.config.provider || "server",
|
|
591
|
-
operation: "fetch-synthesized-audio-asset",
|
|
592
|
-
});
|
|
604
|
+
await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "fetch-synthesized-audio-asset"));
|
|
593
605
|
const assetHeaders = assetFetchHeaders(headers, this.config);
|
|
594
606
|
const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
|
|
595
607
|
if (parsedAssetUrl === null) {
|
|
596
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
597
|
-
toolId: TOOL_ID,
|
|
598
|
-
backend: this.config.provider || "server",
|
|
599
|
-
operation: "fetch-synthesized-audio-asset",
|
|
608
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
|
|
600
609
|
duration: Date.now() - assetFetchStartedAt,
|
|
601
610
|
errorType: "TTSAssetInvalidUrl",
|
|
602
611
|
message: "TTS asset URL rejected (non-http(s) or malformed)",
|
|
603
|
-
});
|
|
612
|
+
}));
|
|
604
613
|
throw new Error("TTS asset URL rejected (non-http(s) or malformed)");
|
|
605
614
|
}
|
|
606
615
|
const assetTrusted = isOriginTrustedForAuth(parsedAssetUrl, this.config);
|
|
@@ -619,46 +628,33 @@ class ServerTTSProviderImpl {
|
|
|
619
628
|
catch (error) {
|
|
620
629
|
if (signal.aborted)
|
|
621
630
|
throw error;
|
|
622
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
623
|
-
toolId: TOOL_ID,
|
|
624
|
-
backend: this.config.provider || "server",
|
|
625
|
-
operation: "fetch-synthesized-audio-asset",
|
|
631
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
|
|
626
632
|
duration: Date.now() - assetFetchStartedAt,
|
|
627
633
|
errorType: "TTSAssetNetworkError",
|
|
628
634
|
message: error instanceof Error ? error.message : String(error),
|
|
629
|
-
});
|
|
635
|
+
}));
|
|
630
636
|
throw error;
|
|
631
637
|
}
|
|
632
638
|
})();
|
|
633
639
|
if (!audioResponse.ok) {
|
|
634
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
635
|
-
toolId: TOOL_ID,
|
|
636
|
-
backend: this.config.provider || "server",
|
|
637
|
-
operation: "fetch-synthesized-audio-asset",
|
|
640
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
|
|
638
641
|
duration: Date.now() - assetFetchStartedAt,
|
|
639
642
|
statusCode: audioResponse.status,
|
|
640
643
|
errorType: "TTSAssetFetchError",
|
|
641
644
|
message: `Failed to download synthesized audio (${audioResponse.status})`,
|
|
642
|
-
});
|
|
645
|
+
}));
|
|
643
646
|
throw new Error(`Failed to download synthesized audio (${audioResponse.status})`);
|
|
644
647
|
}
|
|
645
648
|
audioBlob = await audioResponse.blob();
|
|
646
|
-
await this.emitTelemetry("pie-tool-backend-call-success", {
|
|
647
|
-
toolId: TOOL_ID,
|
|
648
|
-
backend: this.config.provider || "server",
|
|
649
|
-
operation: "fetch-synthesized-audio-asset",
|
|
649
|
+
await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
|
|
650
650
|
duration: Date.now() - assetFetchStartedAt,
|
|
651
|
-
});
|
|
651
|
+
}));
|
|
652
652
|
}
|
|
653
653
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
654
|
-
// Convert speech marks to word timings
|
|
655
654
|
const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
|
|
656
|
-
await this.emitTelemetry("pie-tool-backend-call-success", {
|
|
657
|
-
toolId: TOOL_ID,
|
|
658
|
-
backend: this.config.provider || "server",
|
|
659
|
-
operation: "synthesize-speech",
|
|
655
|
+
await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "synthesize-speech", {
|
|
660
656
|
duration: Date.now() - synthStartedAt,
|
|
661
|
-
});
|
|
657
|
+
}));
|
|
662
658
|
return { audioUrl, wordTimings };
|
|
663
659
|
}
|
|
664
660
|
/**
|
|
@@ -742,7 +738,7 @@ class ServerTTSProviderImpl {
|
|
|
742
738
|
currentTimeMs,
|
|
743
739
|
});
|
|
744
740
|
this.onWordBoundary?.(timing.word, timing.charIndex, timing.length);
|
|
745
|
-
},
|
|
741
|
+
}, HIGHLIGHT_INTERVAL_MS);
|
|
746
742
|
}
|
|
747
743
|
/**
|
|
748
744
|
* Index of the last timing whose start time has arrived at `currentTimeMs`,
|
|
@@ -826,20 +822,13 @@ class ServerTTSProviderImpl {
|
|
|
826
822
|
this.wordTimings = [];
|
|
827
823
|
this.highlightCursor = -1;
|
|
828
824
|
}
|
|
829
|
-
isPlaying() {
|
|
830
|
-
return this.currentAudio !== null && !this.pausedState;
|
|
831
|
-
}
|
|
832
|
-
isPaused() {
|
|
833
|
-
return this.pausedState;
|
|
834
|
-
}
|
|
835
825
|
/**
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
826
|
+
* Store rate, pitch, voice and the per-speak language for the next
|
|
827
|
+
* synthesis, and volume for the next playback. Server-side synthesis bakes
|
|
828
|
+
* the first four into the audio and speech marks, so an active speed change
|
|
829
|
+
* is TTSService's job: it replays from the current segment.
|
|
840
830
|
*/
|
|
841
831
|
updateSettings(settings) {
|
|
842
|
-
// Update config
|
|
843
832
|
if (settings.rate !== undefined) {
|
|
844
833
|
this.config.rate = settings.rate;
|
|
845
834
|
this.config.providerOptions = {
|
|
@@ -848,11 +837,12 @@ class ServerTTSProviderImpl {
|
|
|
848
837
|
};
|
|
849
838
|
}
|
|
850
839
|
if (settings.pitch !== undefined) {
|
|
851
|
-
// Server-side pitch is baked into audio, so this only affects next speak()
|
|
852
840
|
this.config.pitch = settings.pitch;
|
|
853
841
|
}
|
|
842
|
+
if (settings.volume !== undefined) {
|
|
843
|
+
this.config.volume = settings.volume;
|
|
844
|
+
}
|
|
854
845
|
if (settings.voice !== undefined) {
|
|
855
|
-
// Voice change requires resynthesis, affects next speak()
|
|
856
846
|
this.config.voice = settings.voice;
|
|
857
847
|
}
|
|
858
848
|
// Only the per-speak language: the toolkit's other options would replace
|
|
@@ -874,8 +864,6 @@ class ServerTTSProviderImpl {
|
|
|
874
864
|
*/
|
|
875
865
|
export class ServerTTSProvider {
|
|
876
866
|
providerId = "server-tts";
|
|
877
|
-
providerName = "Server TTS";
|
|
878
|
-
version = "1.0.0";
|
|
879
867
|
config = null;
|
|
880
868
|
adapter = null;
|
|
881
869
|
telemetryReporter;
|
|
@@ -890,10 +878,8 @@ export class ServerTTSProvider {
|
|
|
890
878
|
/**
|
|
891
879
|
* Initialize the server TTS provider.
|
|
892
880
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
* @performance Default: <10ms, With validation: 100-500ms
|
|
881
|
+
* Makes no request unless `endpointValidationMode` names a probe, which
|
|
882
|
+
* adds 100-500ms.
|
|
897
883
|
*/
|
|
898
884
|
async initialize(config) {
|
|
899
885
|
const serverConfig = config;
|
|
@@ -904,32 +890,22 @@ export class ServerTTSProvider {
|
|
|
904
890
|
this.telemetryReporter = getTelemetryReporter(serverConfig);
|
|
905
891
|
const transportMode = resolveTransportMode(serverConfig);
|
|
906
892
|
this.adapter = ADAPTERS[transportMode];
|
|
907
|
-
|
|
908
|
-
if (
|
|
893
|
+
const validationMode = resolveValidationMode(serverConfig);
|
|
894
|
+
if (validationMode !== "none") {
|
|
909
895
|
const validationStartedAt = Date.now();
|
|
910
|
-
await this.emitTelemetry("pie-tool-backend-call-start",
|
|
911
|
-
|
|
912
|
-
backend: serverConfig.provider || "server",
|
|
913
|
-
operation: "validate-endpoint",
|
|
914
|
-
});
|
|
915
|
-
const available = await this.testAPIAvailability();
|
|
896
|
+
await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(serverConfig, "validate-endpoint"));
|
|
897
|
+
const available = await this.testAPIAvailability(validationMode);
|
|
916
898
|
if (!available) {
|
|
917
|
-
await this.emitTelemetry("pie-tool-backend-call-error", {
|
|
918
|
-
toolId: TOOL_ID,
|
|
919
|
-
backend: serverConfig.provider || "server",
|
|
920
|
-
operation: "validate-endpoint",
|
|
899
|
+
await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(serverConfig, "validate-endpoint", {
|
|
921
900
|
duration: Date.now() - validationStartedAt,
|
|
922
901
|
errorType: "TTSEndpointValidationError",
|
|
923
902
|
message: `Server TTS API not available at ${serverConfig.apiEndpoint}`,
|
|
924
|
-
});
|
|
903
|
+
}));
|
|
925
904
|
throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
|
|
926
905
|
}
|
|
927
|
-
await this.emitTelemetry("pie-tool-backend-call-success", {
|
|
928
|
-
toolId: TOOL_ID,
|
|
929
|
-
backend: serverConfig.provider || "server",
|
|
930
|
-
operation: "validate-endpoint",
|
|
906
|
+
await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(serverConfig, "validate-endpoint", {
|
|
931
907
|
duration: Date.now() - validationStartedAt,
|
|
932
|
-
});
|
|
908
|
+
}));
|
|
933
909
|
}
|
|
934
910
|
return new ServerTTSProviderImpl(serverConfig, this.adapter);
|
|
935
911
|
}
|
|
@@ -938,7 +914,7 @@ export class ServerTTSProvider {
|
|
|
938
914
|
*
|
|
939
915
|
* @performance 100-500ms depending on network
|
|
940
916
|
*/
|
|
941
|
-
async testAPIAvailability() {
|
|
917
|
+
async testAPIAvailability(mode) {
|
|
942
918
|
if (!this.config || !this.adapter)
|
|
943
919
|
return false;
|
|
944
920
|
try {
|
|
@@ -946,14 +922,8 @@ export class ServerTTSProvider {
|
|
|
946
922
|
if (this.config.authToken) {
|
|
947
923
|
headers["Authorization"] = `Bearer ${this.config.authToken}`;
|
|
948
924
|
}
|
|
949
|
-
// Create abort controller for timeout
|
|
950
925
|
const controller = new AbortController();
|
|
951
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
952
|
-
const mode = resolveValidationMode(this.config, this.adapter.id);
|
|
953
|
-
if (mode === "none") {
|
|
954
|
-
clearTimeout(timeoutId);
|
|
955
|
-
return true;
|
|
956
|
-
}
|
|
926
|
+
const timeoutId = setTimeout(() => controller.abort(), ENDPOINT_PROBE_TIMEOUT_MS);
|
|
957
927
|
const validationUrls = mode === "voices"
|
|
958
928
|
? resolveVoicesValidationUrls(this.config)
|
|
959
929
|
: [this.adapter.resolveSynthesisUrl(this.config)];
|
|
@@ -987,12 +957,9 @@ export class ServerTTSProvider {
|
|
|
987
957
|
getCapabilities() {
|
|
988
958
|
const mode = this.config ? resolveTransportMode(this.config) : "pie";
|
|
989
959
|
return {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
supportsWordBoundary: true,
|
|
993
|
-
supportsVoiceSelection: true,
|
|
994
|
-
supportsRateControl: true,
|
|
995
|
-
supportsPitchControl: false, // Depends on server provider
|
|
960
|
+
// Word boundaries come from the server's speech marks; a response
|
|
961
|
+
// without them reports so at its playback start.
|
|
962
|
+
supportsWordBoundary: true,
|
|
996
963
|
supportsSSML: resolveSupportsSSML(this.config),
|
|
997
964
|
maxTextLength: MAX_TEXT_LENGTH_BY_MODE[mode],
|
|
998
965
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pie-players/tts-client-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
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
|
|
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.
|
|
42
|
-
"@pie-players/pie-tts": "0.
|
|
43
|
-
"@pie-players/tts-server-core": "0.
|
|
40
|
+
"@pie-players/pie-players-shared": "1.0.0-beta.1",
|
|
41
|
+
"@pie-players/pie-tts": "1.0.0-beta.1",
|
|
42
|
+
"@pie-players/tts-server-core": "1.0.0-beta.1"
|
|
44
43
|
},
|
|
45
44
|
"devDependencies": {
|
|
46
45
|
"typescript": "^5.9.3",
|