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