@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 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
- getVoices(_options?: GetVoicesOptions): Promise<Voice[]>;
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
- const rate = Number(request.rate ?? 1);
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(TTSErrorCode.PROVIDER_ERROR, typeof body.message === "string"
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(TTSErrorCode.PROVIDER_ERROR, `SchoolCity marks fetch failed (${marksResponse.status})`, { word, status: marksResponse.status }, this.providerId);
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(TTSErrorCode.PROVIDER_ERROR, `Failed to fetch SchoolCity audio asset (${audioResponse.status})`, { audioContent, status: audioResponse.status }, this.providerId);
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
- async getVoices(_options) {
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
@@ -3,3 +3,4 @@
3
3
  * @module @pie-players/tts-server-sc
4
4
  */
5
5
  export { SchoolCityServerProvider } from "./SchoolCityServerProvider.js";
6
+ export { defaultVoiceForSchoolCityLanguage, isSupportedSchoolCityLanguage, SCHOOLCITY_DEFAULT_VOICES, schoolCityVoices, } from "./sc-voices.js";
@@ -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.67",
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.67",
41
+ "@pie-players/tts-server-core": "0.3.68",
42
42
  "jose": "^6.2.8",
43
43
  "tldts": "^7.0.26"
44
44
  },