@pie-players/tts-client-server 0.3.74 → 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 CHANGED
@@ -30,18 +30,22 @@ npm install @pie-players/tts-client-server
30
30
  ### Basic Setup
31
31
 
32
32
  ```typescript
33
- import { ServerTTSProvider } from '@pie-players/tts-client-server';
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
+ };
46
+
47
+ const ttsService = new TTSService();
48
+ await ttsService.initialize(provider, config);
45
49
  ```
46
50
 
47
51
  `apiEndpoint`, `provider`, `voice` and `language` are top-level fields; the
@@ -68,9 +72,7 @@ await ttsService.initialize(provider, {
68
72
 
69
73
  ```typescript
70
74
  // The provider automatically coordinates word highlighting
71
- await ttsService.speak('Hello world, this is a test.', {
72
- contentElement: document.getElementById('content'),
73
- });
75
+ await ttsService.speak(document.getElementById('content'));
74
76
  ```
75
77
 
76
78
  ## Transport Modes
@@ -138,7 +140,10 @@ await ttsService.speak('Hello world, this is a test.', {
138
140
  ```
139
141
 
140
142
  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.
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.
142
147
 
143
148
  ### Custom mode response
144
149
 
@@ -231,7 +236,7 @@ for the host-wide contract this provider fits into.
231
236
 
232
237
  ## How It Works
233
238
 
234
- 1. **Client calls** `speak(text)`
239
+ 1. **Client calls** `speak(element)`
235
240
  2. **Adapter builds** backend-specific request payload
236
241
  3. **Provider POSTs** to resolved synthesis endpoint (`/synthesize` or root POST)
237
242
  4. **Adapter normalizes** response into audio + speech marks
@@ -272,7 +277,7 @@ The provider automatically manages Blob URLs:
272
277
 
273
278
  ```typescript
274
279
  try {
275
- await ttsService.speak('Hello world');
280
+ await ttsService.speak(document.getElementById('content'));
276
281
  } catch (error) {
277
282
  console.error('TTS failed:', error.message);
278
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, TTSFeature, TTSProviderCapabilities } from "@pie-players/pie-tts";
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.) */
@@ -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
  }
@@ -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, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared/pie";
8
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
9
9
  import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
10
- const logger = createPieLogger("server-tts-provider", () => isGlobalDebugEnabled());
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
@@ -113,13 +116,8 @@ function assetFetchHeaders(requestHeaders, config) {
113
116
  return assetHeaders;
114
117
  }
115
118
  const getTelemetryReporter = (config) => {
116
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
117
- ? config.providerOptions
118
- : {};
119
- const reporter = providerOptions.__pieTelemetry;
120
- return typeof reporter === "function"
121
- ? reporter
122
- : undefined;
119
+ const reporter = config.providerOptions?.__pieTelemetry;
120
+ return typeof reporter === "function" ? reporter : undefined;
123
121
  };
124
122
  const MAX_TEXT_LENGTH_BY_MODE = {
125
123
  pie: 3000,
@@ -172,12 +170,18 @@ const resolveValidationMode = (config, mode) => {
172
170
  return mode === "custom" ? "none" : "voices";
173
171
  };
174
172
  const resolveSpeedRate = (config) => {
175
- const providerOptions = (config.providerOptions || {});
176
- if (typeof providerOptions.speedRate === "string") {
177
- return providerOptions.speedRate;
178
- }
173
+ const speedRate = config.providerOptions?.speedRate;
174
+ if (typeof speedRate === "string")
175
+ return speedRate;
179
176
  return resolveSpeedRateBucket(config.rate);
180
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
+ };
181
185
  const parseInlineSpeechMarks = (input) => {
182
186
  if (!Array.isArray(input))
183
187
  return [];
@@ -218,7 +222,7 @@ const pieAdapter = {
218
222
  return endpointMode === "rootPost" ? base : `${base}/synthesize`;
219
223
  },
220
224
  buildRequestBody: (text, config) => {
221
- const providerOptions = (config.providerOptions || {});
225
+ const providerOptions = config.providerOptions || {};
222
226
  const engine = typeof config.engine === "string"
223
227
  ? config.engine
224
228
  : typeof providerOptions.engine === "string"
@@ -240,7 +244,7 @@ const pieAdapter = {
240
244
  text,
241
245
  provider: config.provider || "polly",
242
246
  voice: config.voice,
243
- language: config.language,
247
+ language: contentLanguageOf(config) ?? config.language,
244
248
  rate: config.rate,
245
249
  engine,
246
250
  sampleRate,
@@ -269,10 +273,14 @@ const customAdapter = {
269
273
  return endpointMode === "synthesizePath" ? `${base}/synthesize` : base;
270
274
  },
271
275
  buildRequestBody: (text, config) => {
272
- const providerOptions = (config.providerOptions || {});
273
- const langId = typeof providerOptions.lang_id === "string"
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()
274
281
  ? providerOptions.lang_id
275
- : config.language || "en-US";
282
+ : undefined;
283
+ const langId = pinned ?? contentLanguageOf(config) ?? (config.language || "en-US");
276
284
  const cache = typeof providerOptions.cache === "boolean" ? providerOptions.cache : true;
277
285
  return {
278
286
  text,
@@ -347,7 +355,11 @@ class ServerTTSProviderImpl {
347
355
  */
348
356
  highlightCursor = -1;
349
357
  intentionallyStopped = false;
358
+ // Set while a synthesis request is in flight.
350
359
  activeSynthesisController = null;
360
+ // Starts the current audio element; a pause before it sounds leaves the
361
+ // start to resume.
362
+ startCurrentAudio = null;
351
363
  synthesisRunId = 0;
352
364
  telemetryReporter;
353
365
  onPlaybackStart;
@@ -374,7 +386,23 @@ class ServerTTSProviderImpl {
374
386
  const synthesisController = new AbortController();
375
387
  this.activeSynthesisController = synthesisController;
376
388
  // Call server API to synthesize speech
377
- const { audioUrl, wordTimings } = await this.synthesizeSpeech(text, synthesisController.signal, runId);
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;
378
406
  if (runId !== this.synthesisRunId) {
379
407
  URL.revokeObjectURL(audioUrl);
380
408
  return;
@@ -389,11 +417,14 @@ class ServerTTSProviderImpl {
389
417
  if (this.config.volume !== undefined) {
390
418
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
391
419
  }
392
- // Setup event handlers
393
- audio.onplay = () => {
394
- if (runId !== this.synthesisRunId || this.currentAudio !== audio) {
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())
395
427
  return;
396
- }
397
428
  this.pausedState = false;
398
429
  try {
399
430
  this.onPlaybackStart?.();
@@ -407,35 +438,40 @@ class ServerTTSProviderImpl {
407
438
  }
408
439
  };
409
440
  audio.onended = () => {
410
- this.stopWordHighlighting();
411
441
  URL.revokeObjectURL(audioUrl);
412
- this.currentAudio = null;
413
- this.wordTimings = [];
414
- this.highlightCursor = -1;
442
+ if (!superseded())
443
+ this.clearCurrentAudio();
415
444
  resolve();
416
445
  };
417
- audio.onerror = (event) => {
418
- this.stopWordHighlighting();
446
+ audio.onerror = () => {
419
447
  URL.revokeObjectURL(audioUrl);
420
- this.currentAudio = null;
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
448
+ if (superseded() || this.intentionallyStopped) {
430
449
  resolve();
450
+ return;
431
451
  }
452
+ this.clearCurrentAudio();
453
+ reject(new Error("Failed to play audio from server"));
432
454
  };
433
455
  audio.onpause = () => {
456
+ if (superseded())
457
+ return;
434
458
  this.stopWordHighlighting();
435
459
  this.pausedState = true;
436
460
  };
437
- // Start playback
438
- audio.play().catch(reject);
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();
439
475
  });
440
476
  }
441
477
  /**
@@ -444,7 +480,7 @@ class ServerTTSProviderImpl {
444
480
  async synthesizeSpeech(text, signal, runId) {
445
481
  const synthStartedAt = Date.now();
446
482
  await this.emitTelemetry("pie-tool-backend-call-start", {
447
- toolId: "tts",
483
+ toolId: TOOL_ID,
448
484
  backend: this.config.provider || "server",
449
485
  operation: "synthesize-speech",
450
486
  });
@@ -469,8 +505,10 @@ class ServerTTSProviderImpl {
469
505
  });
470
506
  }
471
507
  catch (error) {
508
+ if (signal.aborted)
509
+ throw error;
472
510
  await this.emitTelemetry("pie-tool-backend-call-error", {
473
- toolId: "tts",
511
+ toolId: TOOL_ID,
474
512
  backend: this.config.provider || "server",
475
513
  operation: "synthesize-speech",
476
514
  duration: Date.now() - synthStartedAt,
@@ -510,7 +548,7 @@ class ServerTTSProviderImpl {
510
548
  : undefined) ||
511
549
  `Server returned ${response.status}`;
512
550
  await this.emitTelemetry("pie-tool-backend-call-error", {
513
- toolId: "tts",
551
+ toolId: TOOL_ID,
514
552
  backend: this.config.provider || "server",
515
553
  operation: "synthesize-speech",
516
554
  duration: Date.now() - synthStartedAt,
@@ -548,7 +586,7 @@ class ServerTTSProviderImpl {
548
586
  const audioAssetUrl = normalized.audio.url;
549
587
  const assetFetchStartedAt = Date.now();
550
588
  await this.emitTelemetry("pie-tool-backend-call-start", {
551
- toolId: "tts",
589
+ toolId: TOOL_ID,
552
590
  backend: this.config.provider || "server",
553
591
  operation: "fetch-synthesized-audio-asset",
554
592
  });
@@ -556,7 +594,7 @@ class ServerTTSProviderImpl {
556
594
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
557
595
  if (parsedAssetUrl === null) {
558
596
  await this.emitTelemetry("pie-tool-backend-call-error", {
559
- toolId: "tts",
597
+ toolId: TOOL_ID,
560
598
  backend: this.config.provider || "server",
561
599
  operation: "fetch-synthesized-audio-asset",
562
600
  duration: Date.now() - assetFetchStartedAt,
@@ -579,8 +617,10 @@ class ServerTTSProviderImpl {
579
617
  });
580
618
  }
581
619
  catch (error) {
620
+ if (signal.aborted)
621
+ throw error;
582
622
  await this.emitTelemetry("pie-tool-backend-call-error", {
583
- toolId: "tts",
623
+ toolId: TOOL_ID,
584
624
  backend: this.config.provider || "server",
585
625
  operation: "fetch-synthesized-audio-asset",
586
626
  duration: Date.now() - assetFetchStartedAt,
@@ -592,7 +632,7 @@ class ServerTTSProviderImpl {
592
632
  })();
593
633
  if (!audioResponse.ok) {
594
634
  await this.emitTelemetry("pie-tool-backend-call-error", {
595
- toolId: "tts",
635
+ toolId: TOOL_ID,
596
636
  backend: this.config.provider || "server",
597
637
  operation: "fetch-synthesized-audio-asset",
598
638
  duration: Date.now() - assetFetchStartedAt,
@@ -604,7 +644,7 @@ class ServerTTSProviderImpl {
604
644
  }
605
645
  audioBlob = await audioResponse.blob();
606
646
  await this.emitTelemetry("pie-tool-backend-call-success", {
607
- toolId: "tts",
647
+ toolId: TOOL_ID,
608
648
  backend: this.config.provider || "server",
609
649
  operation: "fetch-synthesized-audio-asset",
610
650
  duration: Date.now() - assetFetchStartedAt,
@@ -614,7 +654,7 @@ class ServerTTSProviderImpl {
614
654
  // Convert speech marks to word timings
615
655
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
616
656
  await this.emitTelemetry("pie-tool-backend-call-success", {
617
- toolId: "tts",
657
+ toolId: TOOL_ID,
618
658
  backend: this.config.provider || "server",
619
659
  operation: "synthesize-speech",
620
660
  duration: Date.now() - synthStartedAt,
@@ -734,25 +774,39 @@ class ServerTTSProviderImpl {
734
774
  this.highlightInterval = null;
735
775
  }
736
776
  }
777
+ /** Pause playback, or, while synthesis is in flight, hold its audio. */
737
778
  pause() {
738
- if (this.currentAudio && !this.pausedState) {
779
+ if (this.pausedState)
780
+ return;
781
+ if (this.currentAudio) {
739
782
  this.currentAudio.pause();
740
783
  this.stopWordHighlighting();
741
- this.pausedState = true;
742
784
  }
785
+ else if (!this.activeSynthesisController) {
786
+ return;
787
+ }
788
+ this.pausedState = true;
743
789
  }
744
790
  resume() {
745
- if (this.currentAudio && this.pausedState) {
746
- this.currentAudio.play();
747
- this.pausedState = false;
748
- // Resume word highlighting
749
- if (this.onWordBoundary && this.wordTimings.length > 0) {
750
- this.startWordHighlighting();
751
- }
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();
752
799
  }
753
800
  }
801
+ clearCurrentAudio() {
802
+ this.stopWordHighlighting();
803
+ this.currentAudio = null;
804
+ this.wordTimings = [];
805
+ this.highlightCursor = -1;
806
+ }
754
807
  stop() {
755
808
  this.synthesisRunId += 1;
809
+ this.startCurrentAudio = null;
756
810
  if (this.activeSynthesisController) {
757
811
  this.activeSynthesisController.abort();
758
812
  this.activeSynthesisController = null;
@@ -801,6 +855,15 @@ class ServerTTSProviderImpl {
801
855
  // Voice change requires resynthesis, affects next speak()
802
856
  this.config.voice = settings.voice;
803
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
+ }
804
867
  }
805
868
  }
806
869
  /**
@@ -845,14 +908,14 @@ export class ServerTTSProvider {
845
908
  if (serverConfig.validateEndpoint) {
846
909
  const validationStartedAt = Date.now();
847
910
  await this.emitTelemetry("pie-tool-backend-call-start", {
848
- toolId: "tts",
911
+ toolId: TOOL_ID,
849
912
  backend: serverConfig.provider || "server",
850
913
  operation: "validate-endpoint",
851
914
  });
852
915
  const available = await this.testAPIAvailability();
853
916
  if (!available) {
854
917
  await this.emitTelemetry("pie-tool-backend-call-error", {
855
- toolId: "tts",
918
+ toolId: TOOL_ID,
856
919
  backend: serverConfig.provider || "server",
857
920
  operation: "validate-endpoint",
858
921
  duration: Date.now() - validationStartedAt,
@@ -862,7 +925,7 @@ export class ServerTTSProvider {
862
925
  throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
863
926
  }
864
927
  await this.emitTelemetry("pie-tool-backend-call-success", {
865
- toolId: "tts",
928
+ toolId: TOOL_ID,
866
929
  backend: serverConfig.provider || "server",
867
930
  operation: "validate-endpoint",
868
931
  duration: Date.now() - validationStartedAt,
@@ -921,21 +984,6 @@ export class ServerTTSProvider {
921
984
  return false;
922
985
  }
923
986
  }
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
987
  getCapabilities() {
940
988
  const mode = this.config ? resolveTransportMode(this.config) : "pie";
941
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.74",
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,9 +38,9 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@pie-players/pie-players-shared": "0.3.74",
42
- "@pie-players/pie-tts": "0.3.74",
43
- "@pie-players/tts-server-core": "0.3.74"
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",