@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 +69 -66
- package/dist/ServerTTSProvider.d.ts +44 -20
- package/dist/ServerTTSProvider.js +192 -181
- package/dist/index.d.ts +1 -1
- 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
|
|
|
@@ -30,25 +28,30 @@ npm install @pie-players/tts-client-server
|
|
|
30
28
|
### Basic Setup
|
|
31
29
|
|
|
32
30
|
```typescript
|
|
33
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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`
|
|
79
|
+
`ServerTTSProvider` supports two transport modes:
|
|
79
80
|
|
|
80
|
-
- `pie
|
|
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
|
-
|
|
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. **
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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('
|
|
278
|
+
await ttsService.speak(document.getElementById('content'));
|
|
276
279
|
} catch (error) {
|
|
277
280
|
console.error('TTS failed:', error.message);
|
|
278
|
-
//
|
|
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
|
-
-
|
|
285
|
-
-
|
|
286
|
-
-
|
|
287
|
-
-
|
|
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
|
-
##
|
|
300
|
+
## Caching
|
|
297
301
|
|
|
298
|
-
|
|
299
|
-
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
41
|
-
* - voices:
|
|
42
|
-
* - endpoint:
|
|
43
|
-
* - none:
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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,
|
|
9
|
-
import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
|
|
10
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
176
|
-
if (
|
|
177
|
-
return
|
|
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 =
|
|
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,
|
|
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)
|
|
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 =
|
|
273
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
393
|
-
audio
|
|
394
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
this.highlightCursor = -1;
|
|
465
|
+
if (!superseded())
|
|
466
|
+
this.clearCurrentAudio();
|
|
415
467
|
resolve();
|
|
416
468
|
};
|
|
417
|
-
audio.onerror = (
|
|
418
|
-
this.stopWordHighlighting();
|
|
469
|
+
audio.onerror = () => {
|
|
419
470
|
URL.revokeObjectURL(audioUrl);
|
|
420
|
-
this.
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
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
|
-
*
|
|
831
|
-
*
|
|
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
|
-
|
|
845
|
-
if (
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
supportsWordBoundary: true,
|
|
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
|
+
"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
|
|
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.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",
|