@pie-players/tts-server-sc 0.3.67 → 0.3.68
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 +39 -0
- package/dist/SchoolCityServerProvider.d.ts +9 -1
- package/dist/SchoolCityServerProvider.js +16 -131
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/sc-voices.d.ts +61 -0
- package/dist/sc-voices.js +264 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -160,6 +160,45 @@ const result = await provider.synthesize({
|
|
|
160
160
|
});
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
## Voice discovery
|
|
164
|
+
|
|
165
|
+
The service serves 29 locales with one default voice each, and exposes no
|
|
166
|
+
endpoint to ask which. `getVoices()` answers from a table transcribed from the
|
|
167
|
+
map the service reads (`src/helpers/voices.js`), so it costs no request and
|
|
168
|
+
works before any credential is configured:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await provider.getVoices(); // all 29 locales
|
|
172
|
+
await provider.getVoices({ language: "es" }); // es-ES, es-MX, es-US
|
|
173
|
+
await provider.getVoices({ language: "en-GB" }); // en-GB and en-GB-WLS
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`language` is a language range matched per RFC 4647 basic filtering, so a bare
|
|
177
|
+
primary subtag reaches every locale under it. Every voice is a Polly *standard*
|
|
178
|
+
voice — the service sends no `Engine` — so `quality: "neural"` answers with an
|
|
179
|
+
empty list rather than a voice that cannot be produced.
|
|
180
|
+
|
|
181
|
+
A `Voice.id` is the Polly `VoiceId`, which is what `synthesize` takes back as
|
|
182
|
+
`voice`. The service accepts any voice Polly knows; the table names only the
|
|
183
|
+
default per locale, which is what it substitutes when a request omits `voice`.
|
|
184
|
+
|
|
185
|
+
The same data is available without a provider instance, which is what a route
|
|
186
|
+
serving a picker wants:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import {
|
|
190
|
+
schoolCityVoices,
|
|
191
|
+
isSupportedSchoolCityLanguage,
|
|
192
|
+
} from "@pie-players/tts-server-sc";
|
|
193
|
+
|
|
194
|
+
schoolCityVoices({ gender: "male" });
|
|
195
|
+
isSupportedSchoolCityLanguage("es-419"); // false
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Check the locale before synthesizing. An unrecognized `lang_id` is not an error
|
|
199
|
+
upstream — the service rewrites it to `en-US` and returns English audio, so an
|
|
200
|
+
unserved locale fails silently rather than loudly.
|
|
201
|
+
|
|
163
202
|
## Dogfood adapter example (section-demos shape)
|
|
164
203
|
|
|
165
204
|
If you need to return the SchoolCity-style response shape (`audioContent`, `word`) while reusing provider logic:
|
|
@@ -74,7 +74,15 @@ export declare class SchoolCityServerProvider extends BaseTTSProvider {
|
|
|
74
74
|
private withTimeout;
|
|
75
75
|
synthesizeWithAssets(request: SynthesizeRequest): Promise<SchoolCitySynthesizeAssetsResult>;
|
|
76
76
|
synthesize(request: SynthesizeRequest): Promise<SynthesizeResponse>;
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The service's locale roster, from the table it reads itself.
|
|
79
|
+
*
|
|
80
|
+
* Not a network call: the service has no voice or locale endpoint to ask, so
|
|
81
|
+
* `sc-voices.ts` transcribes the map behind `POST /` instead. Returning `[]`,
|
|
82
|
+
* as this did, left the 29 locales reachable only by a caller that already
|
|
83
|
+
* knew a `lang_id` to hardcode.
|
|
84
|
+
*/
|
|
85
|
+
getVoices(options?: GetVoicesOptions): Promise<Voice[]>;
|
|
78
86
|
getCapabilities(): ServerProviderCapabilities;
|
|
79
87
|
}
|
|
80
88
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SignJWT } from "jose";
|
|
2
2
|
import { getDomain } from "tldts";
|
|
3
|
-
import { BaseTTSProvider, TTSError, TTSErrorCode, } from "@pie-players/tts-server-core";
|
|
3
|
+
import { BaseTTSProvider, normalizeSpeechMarks, resolveSpeedRateBucket, resolveTTSErrorCodeForHttpStatus, TTSError, TTSErrorCode, } from "@pie-players/tts-server-core";
|
|
4
|
+
import { schoolCityVoices } from "./sc-voices.js";
|
|
4
5
|
const DEFAULT_LANGUAGE = "en-US";
|
|
5
6
|
const DEFAULT_SPEED_RATE = "medium";
|
|
6
7
|
const DEFAULT_CACHE = true;
|
|
@@ -207,17 +208,6 @@ const fetchAssetWithManualRedirects = async (initialUrl, config, baseUrl, fetchI
|
|
|
207
208
|
// Unreachable: loop either returns or throws.
|
|
208
209
|
throw new TTSError(TTSErrorCode.PROVIDER_ERROR, "SchoolCity asset fetch terminated unexpectedly", undefined, "schoolcity-tts");
|
|
209
210
|
};
|
|
210
|
-
const asFiniteNumber = (value) => {
|
|
211
|
-
const next = Number(value);
|
|
212
|
-
return Number.isFinite(next) ? next : null;
|
|
213
|
-
};
|
|
214
|
-
const sortSpeechMarks = (marks) => [...marks].sort((left, right) => {
|
|
215
|
-
if (left.time !== right.time)
|
|
216
|
-
return left.time - right.time;
|
|
217
|
-
if (left.start !== right.start)
|
|
218
|
-
return left.start - right.start;
|
|
219
|
-
return left.end - right.end;
|
|
220
|
-
});
|
|
221
211
|
const parseResponseBody = async (response) => {
|
|
222
212
|
const raw = await response.text();
|
|
223
213
|
if (!raw)
|
|
@@ -236,12 +226,7 @@ const toSpeedRate = (request, fallback) => {
|
|
|
236
226
|
if (explicit === "slow" || explicit === "medium" || explicit === "fast") {
|
|
237
227
|
return explicit;
|
|
238
228
|
}
|
|
239
|
-
|
|
240
|
-
if (!Number.isFinite(rate) || rate <= 0.95)
|
|
241
|
-
return "slow";
|
|
242
|
-
if (rate >= 1.5)
|
|
243
|
-
return "fast";
|
|
244
|
-
return fallback;
|
|
229
|
+
return resolveSpeedRateBucket(request.rate, fallback);
|
|
245
230
|
};
|
|
246
231
|
const toLanguage = (request, fallback) => {
|
|
247
232
|
const providerOptions = (request.providerOptions || {});
|
|
@@ -275,114 +260,6 @@ const normalizeTextForSchoolCity = (input) => {
|
|
|
275
260
|
const withoutOpen = text.replace(/^\s*<speak[^>]*>/i, "");
|
|
276
261
|
return withoutOpen.replace(/<\/speak>\s*$/i, "").trim();
|
|
277
262
|
};
|
|
278
|
-
const parseWordMarksJsonl = (raw) => {
|
|
279
|
-
const marks = [];
|
|
280
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
281
|
-
const trimmed = line.trim();
|
|
282
|
-
if (!trimmed)
|
|
283
|
-
continue;
|
|
284
|
-
try {
|
|
285
|
-
const parsed = JSON.parse(trimmed);
|
|
286
|
-
if (parsed.type && parsed.type !== "word")
|
|
287
|
-
continue;
|
|
288
|
-
const time = asFiniteNumber(parsed.time);
|
|
289
|
-
const start = asFiniteNumber(parsed.start);
|
|
290
|
-
const end = asFiniteNumber(parsed.end);
|
|
291
|
-
if (time === null || start === null || end === null)
|
|
292
|
-
continue;
|
|
293
|
-
const value = typeof parsed.value === "string" ? parsed.value : "";
|
|
294
|
-
marks.push({
|
|
295
|
-
time,
|
|
296
|
-
type: "word",
|
|
297
|
-
start,
|
|
298
|
-
end,
|
|
299
|
-
value,
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
catch {
|
|
303
|
-
// Ignore malformed JSONL rows while preserving valid marks.
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return sortSpeechMarks(marks);
|
|
307
|
-
};
|
|
308
|
-
const normalizeMarkTimeUnits = (marks) => {
|
|
309
|
-
if (marks.length < 2)
|
|
310
|
-
return marks;
|
|
311
|
-
const times = marks.map((mark) => mark.time).filter((time) => time >= 0);
|
|
312
|
-
const maxTime = times.length ? Math.max(...times) : 0;
|
|
313
|
-
const deltas = [];
|
|
314
|
-
for (let index = 1; index < times.length; index += 1) {
|
|
315
|
-
const delta = times[index] - times[index - 1];
|
|
316
|
-
if (delta > 0 && Number.isFinite(delta))
|
|
317
|
-
deltas.push(delta);
|
|
318
|
-
}
|
|
319
|
-
const medianDelta = deltas.length > 0
|
|
320
|
-
? [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]
|
|
321
|
-
: 0;
|
|
322
|
-
const shapeSuggestsSeconds = (maxTime > 0 && maxTime < 100 && marks.length > 3) ||
|
|
323
|
-
(medianDelta > 0 && medianDelta < 10);
|
|
324
|
-
if (!shapeSuggestsSeconds)
|
|
325
|
-
return marks;
|
|
326
|
-
return marks.map((mark) => ({ ...mark, time: mark.time * 1000 }));
|
|
327
|
-
};
|
|
328
|
-
const estimateOffsetShift = (marks, requestText) => {
|
|
329
|
-
if (!marks.length || !requestText.length)
|
|
330
|
-
return 0;
|
|
331
|
-
const textLower = requestText.toLowerCase();
|
|
332
|
-
const candidates = [];
|
|
333
|
-
let cursor = 0;
|
|
334
|
-
for (const mark of marks) {
|
|
335
|
-
const token = mark.value.trim().toLowerCase();
|
|
336
|
-
if (!token)
|
|
337
|
-
continue;
|
|
338
|
-
const found = textLower.indexOf(token, cursor);
|
|
339
|
-
if (found < 0)
|
|
340
|
-
continue;
|
|
341
|
-
const delta = mark.start - found;
|
|
342
|
-
if (delta >= 0)
|
|
343
|
-
candidates.push(delta);
|
|
344
|
-
cursor = found + token.length;
|
|
345
|
-
if (candidates.length >= 8)
|
|
346
|
-
break;
|
|
347
|
-
}
|
|
348
|
-
if (candidates.length > 0) {
|
|
349
|
-
const ordered = [...candidates].sort((a, b) => a - b);
|
|
350
|
-
return ordered[Math.floor(ordered.length / 2)];
|
|
351
|
-
}
|
|
352
|
-
return Math.max(0, Math.floor(marks[0].start));
|
|
353
|
-
};
|
|
354
|
-
const rebaseOffsetsToRequestText = (marks, requestText) => {
|
|
355
|
-
if (!marks.length || !requestText.length)
|
|
356
|
-
return marks;
|
|
357
|
-
const textLength = requestText.length;
|
|
358
|
-
const maxEnd = Math.max(...marks.map((mark) => mark.end));
|
|
359
|
-
if (maxEnd <= textLength + 2)
|
|
360
|
-
return marks;
|
|
361
|
-
const shift = estimateOffsetShift(marks, requestText);
|
|
362
|
-
if (shift <= 0)
|
|
363
|
-
return marks;
|
|
364
|
-
return marks.map((mark) => ({
|
|
365
|
-
...mark,
|
|
366
|
-
start: mark.start - shift,
|
|
367
|
-
end: mark.end - shift,
|
|
368
|
-
}));
|
|
369
|
-
};
|
|
370
|
-
const clampMarkRanges = (marks, requestText) => {
|
|
371
|
-
if (!requestText.length)
|
|
372
|
-
return marks;
|
|
373
|
-
const textLength = requestText.length;
|
|
374
|
-
return sortSpeechMarks(marks.map((mark) => {
|
|
375
|
-
const start = Math.max(0, Math.min(textLength, Math.floor(mark.start)));
|
|
376
|
-
const end = Math.max(start + 1, Math.min(textLength, Math.floor(mark.end)));
|
|
377
|
-
return { ...mark, start, end };
|
|
378
|
-
}));
|
|
379
|
-
};
|
|
380
|
-
const normalizeSpeechMarks = (raw, requestText) => {
|
|
381
|
-
const parsed = parseWordMarksJsonl(raw);
|
|
382
|
-
const withTimes = normalizeMarkTimeUnits(parsed);
|
|
383
|
-
const rebased = rebaseOffsetsToRequestText(withTimes, requestText);
|
|
384
|
-
return clampMarkRanges(rebased, requestText);
|
|
385
|
-
};
|
|
386
263
|
export class SchoolCityServerProvider extends BaseTTSProvider {
|
|
387
264
|
providerId = "schoolcity-tts";
|
|
388
265
|
providerName = "SchoolCity TTS";
|
|
@@ -467,7 +344,7 @@ export class SchoolCityServerProvider extends BaseTTSProvider {
|
|
|
467
344
|
});
|
|
468
345
|
const body = (await parseResponseBody(response));
|
|
469
346
|
if (!response.ok) {
|
|
470
|
-
throw new TTSError(
|
|
347
|
+
throw new TTSError(resolveTTSErrorCodeForHttpStatus(response.status), typeof body.message === "string"
|
|
471
348
|
? body.message
|
|
472
349
|
: `SchoolCity synthesis failed (${response.status})`, { status: response.status, body }, this.providerId);
|
|
473
350
|
}
|
|
@@ -512,7 +389,7 @@ export class SchoolCityServerProvider extends BaseTTSProvider {
|
|
|
512
389
|
const marksUrl = validateAssetUrl(word, this.config, this.baseUrl);
|
|
513
390
|
const marksResponse = await fetchAssetWithManualRedirects(marksUrl, this.config, this.baseUrl, this.fetchImpl, { signal: timeout.signal });
|
|
514
391
|
if (!marksResponse.ok) {
|
|
515
|
-
throw new TTSError(
|
|
392
|
+
throw new TTSError(resolveTTSErrorCodeForHttpStatus(marksResponse.status), `SchoolCity marks fetch failed (${marksResponse.status})`, { word, status: marksResponse.status }, this.providerId);
|
|
516
393
|
}
|
|
517
394
|
const marksRaw = await marksResponse.text();
|
|
518
395
|
speechMarks = normalizeSpeechMarks(marksRaw, payload.text);
|
|
@@ -548,7 +425,7 @@ export class SchoolCityServerProvider extends BaseTTSProvider {
|
|
|
548
425
|
audioTimeout.clear();
|
|
549
426
|
}
|
|
550
427
|
if (!audioResponse.ok) {
|
|
551
|
-
throw new TTSError(
|
|
428
|
+
throw new TTSError(resolveTTSErrorCodeForHttpStatus(audioResponse.status), `Failed to fetch SchoolCity audio asset (${audioResponse.status})`, { audioContent, status: audioResponse.status }, this.providerId);
|
|
552
429
|
}
|
|
553
430
|
const arrayBuffer = await audioResponse.arrayBuffer();
|
|
554
431
|
const audio = Buffer.from(arrayBuffer);
|
|
@@ -566,9 +443,17 @@ export class SchoolCityServerProvider extends BaseTTSProvider {
|
|
|
566
443
|
},
|
|
567
444
|
};
|
|
568
445
|
}
|
|
569
|
-
|
|
446
|
+
/**
|
|
447
|
+
* The service's locale roster, from the table it reads itself.
|
|
448
|
+
*
|
|
449
|
+
* Not a network call: the service has no voice or locale endpoint to ask, so
|
|
450
|
+
* `sc-voices.ts` transcribes the map behind `POST /` instead. Returning `[]`,
|
|
451
|
+
* as this did, left the 29 locales reachable only by a caller that already
|
|
452
|
+
* knew a `lang_id` to hardcode.
|
|
453
|
+
*/
|
|
454
|
+
async getVoices(options) {
|
|
570
455
|
this.ensureInitialized();
|
|
571
|
-
return
|
|
456
|
+
return schoolCityVoices(options);
|
|
572
457
|
}
|
|
573
458
|
getCapabilities() {
|
|
574
459
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,5 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export type { SchoolCityProviderConfig, SchoolCitySynthesizeAssetsResult, } from "./SchoolCityServerProvider.js";
|
|
6
6
|
export { SchoolCityServerProvider } from "./SchoolCityServerProvider.js";
|
|
7
|
+
export type { SchoolCityVoiceEntry } from "./sc-voices.js";
|
|
8
|
+
export { defaultVoiceForSchoolCityLanguage, isSupportedSchoolCityLanguage, SCHOOLCITY_DEFAULT_VOICES, schoolCityVoices, } from "./sc-voices.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locale roster the SchoolCity TTS service actually serves.
|
|
3
|
+
*
|
|
4
|
+
* The service is AWS Polly behind a two-route Fastify app, and it carries one
|
|
5
|
+
* default voice per locale in `src/helpers/voices.js`. That map is the source of
|
|
6
|
+
* truth transcribed here, and it does two jobs upstream: it supplies the
|
|
7
|
+
* `VoiceId` when a request omits `voice`, and it gates `lang_id` — an
|
|
8
|
+
* unrecognized `lang_id` is **silently rewritten to `en-US`** rather than
|
|
9
|
+
* refused, so a caller asking for an unserved locale gets English audio and no
|
|
10
|
+
* error. `isSupportedSchoolCityLanguage` exists so a caller can see that coming.
|
|
11
|
+
*
|
|
12
|
+
* Why a transcription rather than a lookup: the service exposes no voice or
|
|
13
|
+
* locale endpoint. `GET /ping` and an authenticated `POST /` are the whole
|
|
14
|
+
* surface, so there is nothing to query and this table is the only way a caller
|
|
15
|
+
* can learn which locales exist. The cost is that it goes stale silently if the
|
|
16
|
+
* service adds a locale — a deliberate trade, and the reason each entry is
|
|
17
|
+
* traceable to a single upstream line rather than derived.
|
|
18
|
+
*
|
|
19
|
+
* Only the *default* voice per locale is listed. The service accepts any
|
|
20
|
+
* `VoiceId` Polly knows, and `voices.js` names alternates for most locales in
|
|
21
|
+
* trailing comments, so a per-locale voice roster is reachable — but it would be
|
|
22
|
+
* assembled from hand-maintained comments rather than from the map the service
|
|
23
|
+
* reads, so it is left out.
|
|
24
|
+
*/
|
|
25
|
+
import type { GetVoicesOptions, Voice } from "@pie-players/tts-server-core";
|
|
26
|
+
/** One locale the service serves, and the voice it speaks by default. */
|
|
27
|
+
export interface SchoolCityVoiceEntry {
|
|
28
|
+
/** `lang_id` as the service spells it. */
|
|
29
|
+
readonly languageCode: string;
|
|
30
|
+
/**
|
|
31
|
+
* Display name, matching Polly's own `LanguageName` for the same voice so
|
|
32
|
+
* this provider and `tts-server-polly` agree on the label for one voice id.
|
|
33
|
+
*/
|
|
34
|
+
readonly language: string;
|
|
35
|
+
/** Polly `VoiceId`, sent as `voice` when the caller names none. */
|
|
36
|
+
readonly voiceId: string;
|
|
37
|
+
/**
|
|
38
|
+
* Absent where the upstream map records no gender. `voices.js` annotates most
|
|
39
|
+
* locales with the female and male voices available, and two — `arb` and
|
|
40
|
+
* `cmn-CN` — carry no annotation at all. Inferring those from elsewhere would
|
|
41
|
+
* put a fact in this table that its source does not support, so they stay
|
|
42
|
+
* unset and a gender-filtered query does not return them.
|
|
43
|
+
*/
|
|
44
|
+
readonly gender?: "male" | "female";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Transcribed from `sc-texttospeech-api/src/helpers/voices.js`, in upstream
|
|
48
|
+
* order. Genders come from that file's own trailing comments.
|
|
49
|
+
*/
|
|
50
|
+
export declare const SCHOOLCITY_DEFAULT_VOICES: readonly SchoolCityVoiceEntry[];
|
|
51
|
+
/** The service's roster as `Voice` records, narrowed by `options`. */
|
|
52
|
+
export declare const schoolCityVoices: (options?: GetVoicesOptions) => Voice[];
|
|
53
|
+
/**
|
|
54
|
+
* Whether the service serves this locale, matched exactly as `lang_id` is.
|
|
55
|
+
*
|
|
56
|
+
* Worth checking before synthesis: an unserved `lang_id` is not an error
|
|
57
|
+
* upstream, it is English audio.
|
|
58
|
+
*/
|
|
59
|
+
export declare const isSupportedSchoolCityLanguage: (languageCode: string) => boolean;
|
|
60
|
+
/** The `VoiceId` the service would choose for a locale, if it serves it. */
|
|
61
|
+
export declare const defaultVoiceForSchoolCityLanguage: (languageCode: string) => string | undefined;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locale roster the SchoolCity TTS service actually serves.
|
|
3
|
+
*
|
|
4
|
+
* The service is AWS Polly behind a two-route Fastify app, and it carries one
|
|
5
|
+
* default voice per locale in `src/helpers/voices.js`. That map is the source of
|
|
6
|
+
* truth transcribed here, and it does two jobs upstream: it supplies the
|
|
7
|
+
* `VoiceId` when a request omits `voice`, and it gates `lang_id` — an
|
|
8
|
+
* unrecognized `lang_id` is **silently rewritten to `en-US`** rather than
|
|
9
|
+
* refused, so a caller asking for an unserved locale gets English audio and no
|
|
10
|
+
* error. `isSupportedSchoolCityLanguage` exists so a caller can see that coming.
|
|
11
|
+
*
|
|
12
|
+
* Why a transcription rather than a lookup: the service exposes no voice or
|
|
13
|
+
* locale endpoint. `GET /ping` and an authenticated `POST /` are the whole
|
|
14
|
+
* surface, so there is nothing to query and this table is the only way a caller
|
|
15
|
+
* can learn which locales exist. The cost is that it goes stale silently if the
|
|
16
|
+
* service adds a locale — a deliberate trade, and the reason each entry is
|
|
17
|
+
* traceable to a single upstream line rather than derived.
|
|
18
|
+
*
|
|
19
|
+
* Only the *default* voice per locale is listed. The service accepts any
|
|
20
|
+
* `VoiceId` Polly knows, and `voices.js` names alternates for most locales in
|
|
21
|
+
* trailing comments, so a per-locale voice roster is reachable — but it would be
|
|
22
|
+
* assembled from hand-maintained comments rather than from the map the service
|
|
23
|
+
* reads, so it is left out.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Transcribed from `sc-texttospeech-api/src/helpers/voices.js`, in upstream
|
|
27
|
+
* order. Genders come from that file's own trailing comments.
|
|
28
|
+
*/
|
|
29
|
+
export const SCHOOLCITY_DEFAULT_VOICES = [
|
|
30
|
+
{ languageCode: "arb", language: "Arabic", voiceId: "Zeina" },
|
|
31
|
+
{ languageCode: "cmn-CN", language: "Chinese Mandarin", voiceId: "Zhiyu" },
|
|
32
|
+
{
|
|
33
|
+
languageCode: "da-DK",
|
|
34
|
+
language: "Danish",
|
|
35
|
+
voiceId: "Naja",
|
|
36
|
+
gender: "female",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
languageCode: "nl-NL",
|
|
40
|
+
language: "Dutch",
|
|
41
|
+
voiceId: "Lotte",
|
|
42
|
+
gender: "female",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
languageCode: "en-AU",
|
|
46
|
+
language: "Australian English",
|
|
47
|
+
voiceId: "Nicole",
|
|
48
|
+
gender: "female",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
languageCode: "en-GB",
|
|
52
|
+
language: "British English",
|
|
53
|
+
voiceId: "Amy",
|
|
54
|
+
gender: "female",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
languageCode: "en-IN",
|
|
58
|
+
language: "Indian English",
|
|
59
|
+
voiceId: "Aditi",
|
|
60
|
+
gender: "female",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
languageCode: "en-US",
|
|
64
|
+
language: "US English",
|
|
65
|
+
voiceId: "Salli",
|
|
66
|
+
gender: "female",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
languageCode: "en-GB-WLS",
|
|
70
|
+
language: "Welsh English",
|
|
71
|
+
voiceId: "Geraint",
|
|
72
|
+
gender: "male",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
languageCode: "fr-FR",
|
|
76
|
+
language: "French",
|
|
77
|
+
voiceId: "Mathieu",
|
|
78
|
+
gender: "male",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
languageCode: "fr-CA",
|
|
82
|
+
language: "Canadian French",
|
|
83
|
+
voiceId: "Chantal",
|
|
84
|
+
gender: "female",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
languageCode: "de-DE",
|
|
88
|
+
language: "German",
|
|
89
|
+
voiceId: "Vicki",
|
|
90
|
+
gender: "female",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
languageCode: "hi-IN",
|
|
94
|
+
language: "Hindi",
|
|
95
|
+
voiceId: "Aditi",
|
|
96
|
+
gender: "female",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
languageCode: "is-IS",
|
|
100
|
+
language: "Icelandic",
|
|
101
|
+
voiceId: "Dora",
|
|
102
|
+
gender: "female",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
languageCode: "it-IT",
|
|
106
|
+
language: "Italian",
|
|
107
|
+
voiceId: "Bianca",
|
|
108
|
+
gender: "female",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
languageCode: "ja-JP",
|
|
112
|
+
language: "Japanese",
|
|
113
|
+
voiceId: "Mizuki",
|
|
114
|
+
gender: "female",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
languageCode: "ko-KR",
|
|
118
|
+
language: "Korean",
|
|
119
|
+
voiceId: "Seoyeon",
|
|
120
|
+
gender: "female",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
languageCode: "nb-NO",
|
|
124
|
+
language: "Norwegian",
|
|
125
|
+
voiceId: "Liv",
|
|
126
|
+
gender: "female",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
languageCode: "pl-PL",
|
|
130
|
+
language: "Polish",
|
|
131
|
+
voiceId: "Ewa",
|
|
132
|
+
gender: "female",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
languageCode: "pt-BR",
|
|
136
|
+
language: "Brazilian Portuguese",
|
|
137
|
+
voiceId: "Vitoria",
|
|
138
|
+
gender: "female",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
languageCode: "pt-PT",
|
|
142
|
+
language: "Portuguese",
|
|
143
|
+
voiceId: "Ines",
|
|
144
|
+
gender: "female",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
languageCode: "ro-RO",
|
|
148
|
+
language: "Romanian",
|
|
149
|
+
voiceId: "Carmen",
|
|
150
|
+
gender: "female",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
languageCode: "ru-RU",
|
|
154
|
+
language: "Russian",
|
|
155
|
+
voiceId: "Tatyana",
|
|
156
|
+
gender: "female",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
languageCode: "es-ES",
|
|
160
|
+
language: "Castilian Spanish",
|
|
161
|
+
voiceId: "Conchita",
|
|
162
|
+
gender: "female",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
languageCode: "es-MX",
|
|
166
|
+
language: "Mexican Spanish",
|
|
167
|
+
voiceId: "Mia",
|
|
168
|
+
gender: "female",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
languageCode: "es-US",
|
|
172
|
+
language: "US Spanish",
|
|
173
|
+
voiceId: "Miguel",
|
|
174
|
+
gender: "male",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
languageCode: "sv-SE",
|
|
178
|
+
language: "Swedish",
|
|
179
|
+
voiceId: "Astrid",
|
|
180
|
+
gender: "female",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
languageCode: "tr-TR",
|
|
184
|
+
language: "Turkish",
|
|
185
|
+
voiceId: "Filiz",
|
|
186
|
+
gender: "female",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
languageCode: "cy-GB",
|
|
190
|
+
language: "Welsh",
|
|
191
|
+
voiceId: "Gwyneth",
|
|
192
|
+
gender: "female",
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
/**
|
|
196
|
+
* Every voice the service serves is a Polly *standard* voice: no `Engine` is
|
|
197
|
+
* ever sent on the synthesis call, and standard is Polly's default.
|
|
198
|
+
*/
|
|
199
|
+
const SCHOOLCITY_VOICE_QUALITY = "standard";
|
|
200
|
+
const normalizeTag = (tag) => tag.trim().toLowerCase().replace(/_/g, "-");
|
|
201
|
+
/**
|
|
202
|
+
* RFC 4647 basic filtering: a range matches a tag it equals, or a tag it prefixes
|
|
203
|
+
* at a subtag boundary. So `en` reaches every English locale and `en-GB` reaches
|
|
204
|
+
* `en-GB-WLS`, while `en-G` reaches nothing.
|
|
205
|
+
*/
|
|
206
|
+
const languageMatches = (languageCode, filter) => {
|
|
207
|
+
const tag = normalizeTag(languageCode);
|
|
208
|
+
const range = normalizeTag(filter);
|
|
209
|
+
if (!range)
|
|
210
|
+
return true;
|
|
211
|
+
return tag === range || tag.startsWith(`${range}-`);
|
|
212
|
+
};
|
|
213
|
+
const toVoice = (entry) => ({
|
|
214
|
+
// The service identifies a voice by its Polly `VoiceId`, and that is what a
|
|
215
|
+
// caller passes back as `SynthesizeRequest.voice`.
|
|
216
|
+
id: entry.voiceId,
|
|
217
|
+
name: entry.voiceId,
|
|
218
|
+
language: entry.language,
|
|
219
|
+
languageCode: entry.languageCode,
|
|
220
|
+
...(entry.gender ? { gender: entry.gender } : {}),
|
|
221
|
+
quality: SCHOOLCITY_VOICE_QUALITY,
|
|
222
|
+
supportedFeatures: {
|
|
223
|
+
// The service always sends `TextType: "ssml"`, so SSML is not optional here.
|
|
224
|
+
ssml: true,
|
|
225
|
+
emotions: false,
|
|
226
|
+
styles: false,
|
|
227
|
+
},
|
|
228
|
+
providerMetadata: {
|
|
229
|
+
/** This is the voice the service picks for the locale when none is named. */
|
|
230
|
+
isServiceDefault: true,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
/** The service's roster as `Voice` records, narrowed by `options`. */
|
|
234
|
+
export const schoolCityVoices = (options) => {
|
|
235
|
+
// Every entry is standard, so any other quality asks for a voice the service
|
|
236
|
+
// cannot produce.
|
|
237
|
+
if (options?.quality && options.quality !== SCHOOLCITY_VOICE_QUALITY) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
return SCHOOLCITY_DEFAULT_VOICES.filter((entry) => {
|
|
241
|
+
if (options?.language &&
|
|
242
|
+
!languageMatches(entry.languageCode, options.language)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (options?.gender && entry.gender !== options.gender)
|
|
246
|
+
return false;
|
|
247
|
+
return true;
|
|
248
|
+
}).map(toVoice);
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Whether the service serves this locale, matched exactly as `lang_id` is.
|
|
252
|
+
*
|
|
253
|
+
* Worth checking before synthesis: an unserved `lang_id` is not an error
|
|
254
|
+
* upstream, it is English audio.
|
|
255
|
+
*/
|
|
256
|
+
export const isSupportedSchoolCityLanguage = (languageCode) => {
|
|
257
|
+
const tag = normalizeTag(languageCode);
|
|
258
|
+
return SCHOOLCITY_DEFAULT_VOICES.some((entry) => normalizeTag(entry.languageCode) === tag);
|
|
259
|
+
};
|
|
260
|
+
/** The `VoiceId` the service would choose for a locale, if it serves it. */
|
|
261
|
+
export const defaultVoiceForSchoolCityLanguage = (languageCode) => {
|
|
262
|
+
const tag = normalizeTag(languageCode);
|
|
263
|
+
return SCHOOLCITY_DEFAULT_VOICES.find((entry) => normalizeTag(entry.languageCode) === tag)?.voiceId;
|
|
264
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pie-players/tts-server-sc",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.68",
|
|
4
4
|
"description": "SchoolCity-backed provider for server-side TTS with speech marks support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@pie-players/tts-server-core": "0.3.
|
|
41
|
+
"@pie-players/tts-server-core": "0.3.68",
|
|
42
42
|
"jose": "^6.2.8",
|
|
43
43
|
"tldts": "^7.0.26"
|
|
44
44
|
},
|