@pie-players/tts-client-server 1.0.0-beta.6 → 1.0.0-beta.8

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.
@@ -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) => {
@@ -366,6 +368,28 @@ const ADAPTERS = {
366
368
  pie: pieAdapter,
367
369
  custom: customAdapter,
368
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
+ }
369
393
  /**
370
394
  * Provider implementation that handles audio playback
371
395
  */
@@ -382,12 +406,12 @@ class ServerTTSProviderImpl {
382
406
  * spoken position instead of replaying the passage from its first word.
383
407
  */
384
408
  highlightCursor = -1;
385
- intentionallyStopped = false;
386
409
  // Set while a synthesis request is in flight.
387
410
  activeSynthesisController = null;
388
411
  // Starts the current audio element; a pause before it sounds leaves the
389
412
  // start to resume.
390
413
  startCurrentAudio = null;
414
+ settleActivePlayback = null;
391
415
  synthesisRunId = 0;
392
416
  telemetryReporter;
393
417
  onPlaybackStart;
@@ -401,13 +425,17 @@ class ServerTTSProviderImpl {
401
425
  try {
402
426
  await this.telemetryReporter?.(eventName, payload);
403
427
  }
404
- catch (error) {
405
- 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");
406
435
  }
407
436
  }
408
437
  async speak(text) {
409
438
  this.stop();
410
- this.intentionallyStopped = false;
411
439
  const runId = ++this.synthesisRunId;
412
440
  const synthesisController = new AbortController();
413
441
  this.activeSynthesisController = synthesisController;
@@ -437,6 +465,32 @@ class ServerTTSProviderImpl {
437
465
  return new Promise((resolve, reject) => {
438
466
  const audio = new Audio(audioUrl);
439
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;
440
494
  if (this.config.volume !== undefined) {
441
495
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
442
496
  }
@@ -454,27 +508,18 @@ class ServerTTSProviderImpl {
454
508
  wordBoundaries: this.wordTimings.length > 0,
455
509
  });
456
510
  }
457
- catch (error) {
458
- logger.warn("playback-start callback failed:", error);
511
+ catch {
512
+ logger.warn("playback-start callback failed");
459
513
  }
460
514
  if (this.onWordBoundary && this.wordTimings.length > 0) {
461
515
  this.startWordHighlighting();
462
516
  }
463
517
  };
464
- audio.onended = () => {
465
- URL.revokeObjectURL(audioUrl);
466
- if (!superseded())
467
- this.clearCurrentAudio();
468
- resolve();
469
- };
518
+ audio.onended = () => settle();
470
519
  audio.onerror = () => {
471
- URL.revokeObjectURL(audioUrl);
472
- if (superseded() || this.intentionallyStopped) {
473
- resolve();
474
- return;
475
- }
476
- this.clearCurrentAudio();
477
- reject(new Error("Failed to play audio from server"));
520
+ settle(superseded()
521
+ ? undefined
522
+ : new Error("Failed to play audio from server"));
478
523
  };
479
524
  audio.onpause = () => {
480
525
  if (superseded())
@@ -489,7 +534,9 @@ class ServerTTSProviderImpl {
489
534
  if (aborted && this.pausedState && this.currentAudio === audio) {
490
535
  return;
491
536
  }
492
- reject(error);
537
+ settle(superseded()
538
+ ? undefined
539
+ : redactedError("Failed to start audio playback", error));
493
540
  });
494
541
  };
495
542
  this.startCurrentAudio = start;
@@ -504,6 +551,7 @@ class ServerTTSProviderImpl {
504
551
  async synthesizeSpeech(text, signal, runId) {
505
552
  const synthStartedAt = Date.now();
506
553
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "synthesize-speech"));
554
+ this.assertSynthesisActive(signal, runId);
507
555
  const headers = {
508
556
  "Content-Type": "application/json",
509
557
  ...this.config.headers,
@@ -524,76 +572,51 @@ class ServerTTSProviderImpl {
524
572
  });
525
573
  }
526
574
  catch (error) {
527
- if (signal.aborted)
528
- throw error;
575
+ this.assertSynthesisActive(signal, runId);
529
576
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
530
577
  duration: Date.now() - synthStartedAt,
531
578
  errorType: "TTSBackendNetworkError",
532
- message: error instanceof Error ? error.message : String(error),
579
+ message: "TTS synthesis network request failed",
533
580
  }));
534
- throw error;
581
+ this.assertSynthesisActive(signal, runId);
582
+ throw redactedError("TTS synthesis network request failed", error);
535
583
  }
536
584
  })();
537
585
  if (!response.ok) {
538
- const rawBody = await response.text().catch(() => "");
539
- let errorData = {};
540
- if (rawBody) {
541
- try {
542
- errorData = JSON.parse(rawBody);
543
- }
544
- catch (parseError) {
545
- logger.warn("non-OK response body was not JSON", {
546
- status: response.status,
547
- url: synthUrl,
548
- bodyLength: rawBody.length,
549
- parseError: parseError instanceof Error
550
- ? parseError.message
551
- : String(parseError),
552
- });
553
- // A provider's error body can echo the SSML it rejected, so the body
554
- // itself stays behind the debug gate; the length above is what says
555
- // a body arrived and was not JSON.
556
- logger.debug("non-OK response body", {
557
- rawBodyPreview: rawBody.slice(0, RAW_BODY_PREVIEW_LENGTH),
558
- });
559
- }
560
- }
561
- const errorMessage = (typeof errorData.message === "string" && errorData.message) ||
562
- (typeof errorData.error?.message === "string"
563
- ? errorData.error.message
564
- : undefined) ||
565
- `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}`;
566
590
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
567
591
  duration: Date.now() - synthStartedAt,
568
592
  statusCode: response.status,
569
593
  errorType: "TTSBackendRequestError",
570
594
  message: errorMessage,
571
595
  }));
596
+ this.assertSynthesisActive(signal, runId);
572
597
  logger.error("synthesize request failed", {
573
598
  status: response.status,
574
- url: synthUrl,
575
599
  backend: this.config.provider || "server",
576
- message: errorMessage,
577
600
  textLength: text.length,
578
601
  });
579
- // The text under synthesis is item content and the provider's error body
580
- // can echo it, so both stay behind the debug gate. `errorMessage` above is
581
- // thrown to the caller regardless, and `textLength` is what correlates a
582
- // failure with a payload without publishing what the learner is reading.
583
- const textPreview = text
584
- .replace(/\s+/g, " ")
585
- .trim()
586
- .slice(0, TEXT_PREVIEW_LENGTH);
587
- logger.debug("synthesize request failed for text", {
588
- responseBody: errorData,
589
- textPreview: textPreview.length < text.length ? `${textPreview}…` : textPreview,
590
- });
591
602
  throw new Error(errorMessage);
592
603
  }
593
- const normalized = await this.adapter.parseResponse(response, this.config, headers, signal, text);
594
- if (runId !== this.synthesisRunId || signal.aborted) {
595
- 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);
596
618
  }
619
+ this.assertSynthesisActive(signal, runId);
597
620
  let audioBlob;
598
621
  if (normalized.audio.kind === "base64") {
599
622
  audioBlob = this.base64ToBlob(normalized.audio.data, normalized.audio.contentType);
@@ -602,6 +625,7 @@ class ServerTTSProviderImpl {
602
625
  const audioAssetUrl = normalized.audio.url;
603
626
  const assetFetchStartedAt = Date.now();
604
627
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "fetch-synthesized-audio-asset"));
628
+ this.assertSynthesisActive(signal, runId);
605
629
  const assetHeaders = assetFetchHeaders(headers, this.config);
606
630
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
607
631
  if (parsedAssetUrl === null) {
@@ -626,17 +650,18 @@ class ServerTTSProviderImpl {
626
650
  });
627
651
  }
628
652
  catch (error) {
629
- if (signal.aborted)
630
- throw error;
653
+ this.assertSynthesisActive(signal, runId);
631
654
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
632
655
  duration: Date.now() - assetFetchStartedAt,
633
656
  errorType: "TTSAssetNetworkError",
634
- message: error instanceof Error ? error.message : String(error),
657
+ message: "TTS synthesized audio request failed",
635
658
  }));
636
- throw error;
659
+ this.assertSynthesisActive(signal, runId);
660
+ throw redactedError("TTS synthesized audio request failed", error);
637
661
  }
638
662
  })();
639
663
  if (!audioResponse.ok) {
664
+ cancelResponseBody(audioResponse);
640
665
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
641
666
  duration: Date.now() - assetFetchStartedAt,
642
667
  statusCode: audioResponse.status,
@@ -645,16 +670,30 @@ class ServerTTSProviderImpl {
645
670
  }));
646
671
  throw new Error(`Failed to download synthesized audio (${audioResponse.status})`);
647
672
  }
648
- 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
+ }
649
687
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
650
688
  duration: Date.now() - assetFetchStartedAt,
651
689
  }));
652
690
  }
653
- const audioUrl = URL.createObjectURL(audioBlob);
654
691
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
655
692
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "synthesize-speech", {
656
693
  duration: Date.now() - synthStartedAt,
657
694
  }));
695
+ this.assertSynthesisActive(signal, runId);
696
+ const audioUrl = URL.createObjectURL(audioBlob);
658
697
  return { audioUrl, wordTimings };
659
698
  }
660
699
  /**
@@ -808,16 +847,8 @@ class ServerTTSProviderImpl {
808
847
  this.activeSynthesisController = null;
809
848
  }
810
849
  this.stopWordHighlighting();
811
- if (this.currentAudio) {
812
- // Mark as intentionally stopped to prevent error handler from rejecting
813
- this.intentionallyStopped = true;
814
- this.currentAudio.pause();
815
- if (this.currentAudio.src) {
816
- URL.revokeObjectURL(this.currentAudio.src);
817
- }
818
- this.currentAudio.src = "";
819
- this.currentAudio = null;
820
- }
850
+ this.settleActivePlayback?.();
851
+ this.clearCurrentAudio();
821
852
  this.pausedState = false;
822
853
  this.wordTimings = [];
823
854
  this.highlightCursor = -1;
@@ -871,8 +902,8 @@ export class ServerTTSProvider {
871
902
  try {
872
903
  await this.telemetryReporter?.(eventName, payload);
873
904
  }
874
- catch (error) {
875
- logger.warn("telemetry callback failed:", error);
905
+ catch {
906
+ logger.warn("telemetry callback failed");
876
907
  }
877
908
  }
878
909
  /**
@@ -899,9 +930,9 @@ export class ServerTTSProvider {
899
930
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(serverConfig, "validate-endpoint", {
900
931
  duration: Date.now() - validationStartedAt,
901
932
  errorType: "TTSEndpointValidationError",
902
- message: `Server TTS API not available at ${serverConfig.apiEndpoint}`,
933
+ message: "Server TTS API is not available",
903
934
  }));
904
- throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
935
+ throw new Error("Server TTS API is not available");
905
936
  }
906
937
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(serverConfig, "validate-endpoint", {
907
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.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "Client-side TTS provider that calls server API for synthesis",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,9 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@pie-players/pie-players-shared": "1.0.0-beta.6",
41
- "@pie-players/pie-tts": "1.0.0-beta.6",
42
- "@pie-players/tts-server-core": "1.0.0-beta.6"
40
+ "@pie-players/pie-players-shared": "1.0.0-beta.8",
41
+ "@pie-players/pie-tts": "1.0.0-beta.8"
43
42
  },
44
43
  "devDependencies": {
45
44
  "typescript": "^5.9.3",