@pie-players/tts-client-server 1.0.0-beta.0 → 1.0.0-beta.10

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PIE Framework contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -57,8 +57,6 @@ export interface ServerTTSProviderConfig extends TTSConfig {
57
57
  language?: string;
58
58
  /** Polly engine selection when provider is AWS Polly */
59
59
  engine?: "standard" | "neural";
60
- /** Volume level 0-1 */
61
- volume?: number;
62
60
  /**
63
61
  * Transport mode determines request/response translation strategy.
64
62
  * - pie: POST {apiEndpoint}/synthesize, expects inline base64 audio + speech marks
@@ -12,8 +12,9 @@
12
12
  * `ServerTTSProvider`, which picks the adapter, runs the optional endpoint probe
13
13
  * and hands out implementations.
14
14
  */
15
+ import { anchorSpeechMarks, normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/pie-tts";
16
+ import { languageTagWithoutVariants } from "@pie-players/pie-players-shared/i18n/language-tags";
15
17
  import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
16
- import { anchorSpeechMarks, normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
17
18
  // Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
18
19
  const logger = createPieLogger("server-tts-provider", isTtsDebugEnabled);
19
20
  // Telemetry names the tool by its canonical id, as the toolkit does.
@@ -22,9 +23,6 @@ const TOOL_ID = "textToSpeech";
22
23
  const HIGHLIGHT_INTERVAL_MS = 50;
23
24
  // How long the endpoint probe waits before reporting the server unreachable.
24
25
  const ENDPOINT_PROBE_TIMEOUT_MS = 5000;
25
- // Debug-log previews of an error body and of the text under synthesis.
26
- const RAW_BODY_PREVIEW_LENGTH = 500;
27
- const TEXT_PREVIEW_LENGTH = 120;
28
26
  /**
29
27
  * Parse a TTS asset URL (speech marks or audio). Rejects anything that is
30
28
  * not an absolute http(s) URL; relative URLs are resolved against
@@ -200,11 +198,15 @@ const resolveSpeedRate = (config) => {
200
198
  return speedRate;
201
199
  return resolveSpeedRateBucket(config.rate);
202
200
  };
203
- /** The language a speak named for its content, if any. */
201
+ /**
202
+ * The language a speak named for its content, if any, without variant or
203
+ * private-use subtags: voices are keyed by language and region, so
204
+ * `en-GB-x-gulf` is read by an `en-GB` voice.
205
+ */
204
206
  const contentLanguageOf = (config) => {
205
207
  const language = config.providerOptions?.contentLanguage;
206
208
  return typeof language === "string" && language.trim()
207
- ? language.trim()
209
+ ? languageTagWithoutVariants(language)
208
210
  : undefined;
209
211
  };
210
212
  const parseInlineSpeechMarks = (input) => {
@@ -271,6 +273,7 @@ const pieAdapter = {
271
273
  voice: config.voice,
272
274
  language: contentLanguageOf(config) ?? config.language,
273
275
  rate: config.rate,
276
+ ...(config.pitch !== undefined ? { pitch: config.pitch } : {}),
274
277
  engine,
275
278
  sampleRate,
276
279
  format,
@@ -365,6 +368,28 @@ const ADAPTERS = {
365
368
  pie: pieAdapter,
366
369
  custom: customAdapter,
367
370
  };
371
+ /** Release a rejected response without waiting for its underlying stream. */
372
+ function cancelResponseBody(response) {
373
+ try {
374
+ void response.body?.cancel().catch(() => { });
375
+ }
376
+ catch {
377
+ // Cleanup must not replace the status-only failure.
378
+ }
379
+ }
380
+ /**
381
+ * A fixed-message error that keeps the original's standard name, such as
382
+ * `NotAllowedError` for blocked autoplay, so hosts can still branch on it. The
383
+ * original message and cause are dropped: they can carry signed URLs.
384
+ */
385
+ function redactedError(message, original) {
386
+ const error = new Error(message);
387
+ const name = original?.name;
388
+ if (typeof name === "string" && /^[A-Z][A-Za-z]*Error$/.test(name)) {
389
+ error.name = name;
390
+ }
391
+ return error;
392
+ }
368
393
  /**
369
394
  * Provider implementation that handles audio playback
370
395
  */
@@ -381,12 +406,12 @@ class ServerTTSProviderImpl {
381
406
  * spoken position instead of replaying the passage from its first word.
382
407
  */
383
408
  highlightCursor = -1;
384
- intentionallyStopped = false;
385
409
  // Set while a synthesis request is in flight.
386
410
  activeSynthesisController = null;
387
411
  // Starts the current audio element; a pause before it sounds leaves the
388
412
  // start to resume.
389
413
  startCurrentAudio = null;
414
+ settleActivePlayback = null;
390
415
  synthesisRunId = 0;
391
416
  telemetryReporter;
392
417
  onPlaybackStart;
@@ -400,13 +425,17 @@ class ServerTTSProviderImpl {
400
425
  try {
401
426
  await this.telemetryReporter?.(eventName, payload);
402
427
  }
403
- catch (error) {
404
- logger.warn("telemetry callback failed:", error);
428
+ catch {
429
+ logger.warn("telemetry callback failed");
430
+ }
431
+ }
432
+ assertSynthesisActive(signal, runId) {
433
+ if (signal.aborted || runId !== this.synthesisRunId) {
434
+ throw new DOMException("TTS request was aborted", "AbortError");
405
435
  }
406
436
  }
407
437
  async speak(text) {
408
438
  this.stop();
409
- this.intentionallyStopped = false;
410
439
  const runId = ++this.synthesisRunId;
411
440
  const synthesisController = new AbortController();
412
441
  this.activeSynthesisController = synthesisController;
@@ -436,6 +465,32 @@ class ServerTTSProviderImpl {
436
465
  return new Promise((resolve, reject) => {
437
466
  const audio = new Audio(audioUrl);
438
467
  this.currentAudio = audio;
468
+ let settled = false;
469
+ const settle = (error) => {
470
+ if (settled)
471
+ return;
472
+ settled = true;
473
+ audio.onplaying = null;
474
+ audio.onended = null;
475
+ audio.onerror = null;
476
+ audio.onpause = null;
477
+ audio.pause();
478
+ audio.src = "";
479
+ URL.revokeObjectURL(audioUrl);
480
+ if (this.currentAudio === audio) {
481
+ this.clearCurrentAudio();
482
+ this.pausedState = false;
483
+ this.startCurrentAudio = null;
484
+ }
485
+ if (this.settleActivePlayback === settle) {
486
+ this.settleActivePlayback = null;
487
+ }
488
+ if (error)
489
+ reject(error);
490
+ else
491
+ resolve();
492
+ };
493
+ this.settleActivePlayback = settle;
439
494
  if (this.config.volume !== undefined) {
440
495
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
441
496
  }
@@ -453,27 +508,18 @@ class ServerTTSProviderImpl {
453
508
  wordBoundaries: this.wordTimings.length > 0,
454
509
  });
455
510
  }
456
- catch (error) {
457
- logger.warn("playback-start callback failed:", error);
511
+ catch {
512
+ logger.warn("playback-start callback failed");
458
513
  }
459
514
  if (this.onWordBoundary && this.wordTimings.length > 0) {
460
515
  this.startWordHighlighting();
461
516
  }
462
517
  };
463
- audio.onended = () => {
464
- URL.revokeObjectURL(audioUrl);
465
- if (!superseded())
466
- this.clearCurrentAudio();
467
- resolve();
468
- };
518
+ audio.onended = () => settle();
469
519
  audio.onerror = () => {
470
- URL.revokeObjectURL(audioUrl);
471
- if (superseded() || this.intentionallyStopped) {
472
- resolve();
473
- return;
474
- }
475
- this.clearCurrentAudio();
476
- reject(new Error("Failed to play audio from server"));
520
+ settle(superseded()
521
+ ? undefined
522
+ : new Error("Failed to play audio from server"));
477
523
  };
478
524
  audio.onpause = () => {
479
525
  if (superseded())
@@ -488,7 +534,9 @@ class ServerTTSProviderImpl {
488
534
  if (aborted && this.pausedState && this.currentAudio === audio) {
489
535
  return;
490
536
  }
491
- reject(error);
537
+ settle(superseded()
538
+ ? undefined
539
+ : redactedError("Failed to start audio playback", error));
492
540
  });
493
541
  };
494
542
  this.startCurrentAudio = start;
@@ -503,6 +551,7 @@ class ServerTTSProviderImpl {
503
551
  async synthesizeSpeech(text, signal, runId) {
504
552
  const synthStartedAt = Date.now();
505
553
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "synthesize-speech"));
554
+ this.assertSynthesisActive(signal, runId);
506
555
  const headers = {
507
556
  "Content-Type": "application/json",
508
557
  ...this.config.headers,
@@ -523,76 +572,51 @@ class ServerTTSProviderImpl {
523
572
  });
524
573
  }
525
574
  catch (error) {
526
- if (signal.aborted)
527
- throw error;
575
+ this.assertSynthesisActive(signal, runId);
528
576
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
529
577
  duration: Date.now() - synthStartedAt,
530
578
  errorType: "TTSBackendNetworkError",
531
- message: error instanceof Error ? error.message : String(error),
579
+ message: "TTS synthesis network request failed",
532
580
  }));
533
- throw error;
581
+ this.assertSynthesisActive(signal, runId);
582
+ throw redactedError("TTS synthesis network request failed", error);
534
583
  }
535
584
  })();
536
585
  if (!response.ok) {
537
- const rawBody = await response.text().catch(() => "");
538
- let errorData = {};
539
- if (rawBody) {
540
- try {
541
- errorData = JSON.parse(rawBody);
542
- }
543
- catch (parseError) {
544
- logger.warn("non-OK response body was not JSON", {
545
- status: response.status,
546
- url: synthUrl,
547
- bodyLength: rawBody.length,
548
- parseError: parseError instanceof Error
549
- ? parseError.message
550
- : String(parseError),
551
- });
552
- // A provider's error body can echo the SSML it rejected, so the body
553
- // itself stays behind the debug gate; the length above is what says
554
- // a body arrived and was not JSON.
555
- logger.debug("non-OK response body", {
556
- rawBodyPreview: rawBody.slice(0, RAW_BODY_PREVIEW_LENGTH),
557
- });
558
- }
559
- }
560
- const errorMessage = (typeof errorData.message === "string" && errorData.message) ||
561
- (typeof errorData.error?.message === "string"
562
- ? errorData.error.message
563
- : undefined) ||
564
- `Server returned ${response.status}`;
586
+ // A provider can echo request text or signed URLs in its body. Do not
587
+ // read it, or wait for a stalled stream before reporting the status.
588
+ cancelResponseBody(response);
589
+ const errorMessage = `Server returned ${response.status}`;
565
590
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
566
591
  duration: Date.now() - synthStartedAt,
567
592
  statusCode: response.status,
568
593
  errorType: "TTSBackendRequestError",
569
594
  message: errorMessage,
570
595
  }));
596
+ this.assertSynthesisActive(signal, runId);
571
597
  logger.error("synthesize request failed", {
572
598
  status: response.status,
573
- url: synthUrl,
574
599
  backend: this.config.provider || "server",
575
- message: errorMessage,
576
600
  textLength: text.length,
577
601
  });
578
- // The text under synthesis is item content and the provider's error body
579
- // can echo it, so both stay behind the debug gate. `errorMessage` above is
580
- // thrown to the caller regardless, and `textLength` is what correlates a
581
- // failure with a payload without publishing what the learner is reading.
582
- const textPreview = text
583
- .replace(/\s+/g, " ")
584
- .trim()
585
- .slice(0, TEXT_PREVIEW_LENGTH);
586
- logger.debug("synthesize request failed for text", {
587
- responseBody: errorData,
588
- textPreview: textPreview.length < text.length ? `${textPreview}…` : textPreview,
589
- });
590
602
  throw new Error(errorMessage);
591
603
  }
592
- const normalized = await this.adapter.parseResponse(response, this.config, headers, signal, text);
593
- if (runId !== this.synthesisRunId || signal.aborted) {
594
- throw new Error("Synthesis superseded by a newer request");
604
+ let normalized;
605
+ try {
606
+ normalized = await this.adapter.parseResponse(response, this.config, headers, signal, text);
607
+ }
608
+ catch {
609
+ this.assertSynthesisActive(signal, runId);
610
+ const message = "TTS synthesis response could not be processed";
611
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
612
+ duration: Date.now() - synthStartedAt,
613
+ errorType: "TTSBackendResponseError",
614
+ message,
615
+ }));
616
+ this.assertSynthesisActive(signal, runId);
617
+ throw new Error(message);
595
618
  }
619
+ this.assertSynthesisActive(signal, runId);
596
620
  let audioBlob;
597
621
  if (normalized.audio.kind === "base64") {
598
622
  audioBlob = this.base64ToBlob(normalized.audio.data, normalized.audio.contentType);
@@ -601,6 +625,7 @@ class ServerTTSProviderImpl {
601
625
  const audioAssetUrl = normalized.audio.url;
602
626
  const assetFetchStartedAt = Date.now();
603
627
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "fetch-synthesized-audio-asset"));
628
+ this.assertSynthesisActive(signal, runId);
604
629
  const assetHeaders = assetFetchHeaders(headers, this.config);
605
630
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
606
631
  if (parsedAssetUrl === null) {
@@ -625,17 +650,18 @@ class ServerTTSProviderImpl {
625
650
  });
626
651
  }
627
652
  catch (error) {
628
- if (signal.aborted)
629
- throw error;
653
+ this.assertSynthesisActive(signal, runId);
630
654
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
631
655
  duration: Date.now() - assetFetchStartedAt,
632
656
  errorType: "TTSAssetNetworkError",
633
- message: error instanceof Error ? error.message : String(error),
657
+ message: "TTS synthesized audio request failed",
634
658
  }));
635
- throw error;
659
+ this.assertSynthesisActive(signal, runId);
660
+ throw redactedError("TTS synthesized audio request failed", error);
636
661
  }
637
662
  })();
638
663
  if (!audioResponse.ok) {
664
+ cancelResponseBody(audioResponse);
639
665
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
640
666
  duration: Date.now() - assetFetchStartedAt,
641
667
  statusCode: audioResponse.status,
@@ -644,16 +670,30 @@ class ServerTTSProviderImpl {
644
670
  }));
645
671
  throw new Error(`Failed to download synthesized audio (${audioResponse.status})`);
646
672
  }
647
- audioBlob = await audioResponse.blob();
673
+ try {
674
+ audioBlob = await audioResponse.blob();
675
+ }
676
+ catch {
677
+ this.assertSynthesisActive(signal, runId);
678
+ const message = "TTS synthesized audio response could not be read";
679
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
680
+ duration: Date.now() - assetFetchStartedAt,
681
+ errorType: "TTSAssetResponseError",
682
+ message,
683
+ }));
684
+ this.assertSynthesisActive(signal, runId);
685
+ throw new Error(message);
686
+ }
648
687
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
649
688
  duration: Date.now() - assetFetchStartedAt,
650
689
  }));
651
690
  }
652
- const audioUrl = URL.createObjectURL(audioBlob);
653
691
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
654
692
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "synthesize-speech", {
655
693
  duration: Date.now() - synthStartedAt,
656
694
  }));
695
+ this.assertSynthesisActive(signal, runId);
696
+ const audioUrl = URL.createObjectURL(audioBlob);
657
697
  return { audioUrl, wordTimings };
658
698
  }
659
699
  /**
@@ -807,25 +847,17 @@ class ServerTTSProviderImpl {
807
847
  this.activeSynthesisController = null;
808
848
  }
809
849
  this.stopWordHighlighting();
810
- if (this.currentAudio) {
811
- // Mark as intentionally stopped to prevent error handler from rejecting
812
- this.intentionallyStopped = true;
813
- this.currentAudio.pause();
814
- if (this.currentAudio.src) {
815
- URL.revokeObjectURL(this.currentAudio.src);
816
- }
817
- this.currentAudio.src = "";
818
- this.currentAudio = null;
819
- }
850
+ this.settleActivePlayback?.();
851
+ this.clearCurrentAudio();
820
852
  this.pausedState = false;
821
853
  this.wordTimings = [];
822
854
  this.highlightCursor = -1;
823
855
  }
824
856
  /**
825
857
  * Store rate, pitch, voice and the per-speak language for the next
826
- * synthesis. Server-side synthesis bakes them into the audio and speech
827
- * marks, so an active speed change is TTSService's job: it replays from the
828
- * current segment.
858
+ * synthesis, and volume for the next playback. Server-side synthesis bakes
859
+ * the first four into the audio and speech marks, so an active speed change
860
+ * is TTSService's job: it replays from the current segment.
829
861
  */
830
862
  updateSettings(settings) {
831
863
  if (settings.rate !== undefined) {
@@ -838,6 +870,9 @@ class ServerTTSProviderImpl {
838
870
  if (settings.pitch !== undefined) {
839
871
  this.config.pitch = settings.pitch;
840
872
  }
873
+ if (settings.volume !== undefined) {
874
+ this.config.volume = settings.volume;
875
+ }
841
876
  if (settings.voice !== undefined) {
842
877
  this.config.voice = settings.voice;
843
878
  }
@@ -867,8 +902,8 @@ export class ServerTTSProvider {
867
902
  try {
868
903
  await this.telemetryReporter?.(eventName, payload);
869
904
  }
870
- catch (error) {
871
- logger.warn("telemetry callback failed:", error);
905
+ catch {
906
+ logger.warn("telemetry callback failed");
872
907
  }
873
908
  }
874
909
  /**
@@ -895,9 +930,9 @@ export class ServerTTSProvider {
895
930
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(serverConfig, "validate-endpoint", {
896
931
  duration: Date.now() - validationStartedAt,
897
932
  errorType: "TTSEndpointValidationError",
898
- message: `Server TTS API not available at ${serverConfig.apiEndpoint}`,
933
+ message: "Server TTS API is not available",
899
934
  }));
900
- throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
935
+ throw new Error("Server TTS API is not available");
901
936
  }
902
937
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(serverConfig, "validate-endpoint", {
903
938
  duration: Date.now() - validationStartedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/tts-client-server",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.10",
4
4
  "description": "Client-side TTS provider that calls server API for synthesis",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,8 +8,10 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- }
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./package.json": "./package.json"
13
15
  },
14
16
  "files": [
15
17
  "dist"
@@ -37,13 +39,12 @@
37
39
  "access": "public"
38
40
  },
39
41
  "dependencies": {
40
- "@pie-players/pie-players-shared": "1.0.0-beta.0",
41
- "@pie-players/pie-tts": "1.0.0-beta.0",
42
- "@pie-players/tts-server-core": "1.0.0-beta.0"
42
+ "@pie-players/pie-players-shared": "1.0.0-beta.10",
43
+ "@pie-players/pie-tts": "1.0.0-beta.10"
43
44
  },
44
45
  "devDependencies": {
45
46
  "typescript": "^5.9.3",
46
- "vitest": "^5.0.3"
47
+ "vitest": "^5.0.5"
47
48
  },
48
49
  "sideEffects": false,
49
50
  "homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/tts-client-server#readme",