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

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.
@@ -22,9 +22,6 @@ const TOOL_ID = "textToSpeech";
22
22
  const HIGHLIGHT_INTERVAL_MS = 50;
23
23
  // How long the endpoint probe waits before reporting the server unreachable.
24
24
  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
25
  /**
29
26
  * Parse a TTS asset URL (speech marks or audio). Rejects anything that is
30
27
  * not an absolute http(s) URL; relative URLs are resolved against
@@ -366,6 +363,28 @@ const ADAPTERS = {
366
363
  pie: pieAdapter,
367
364
  custom: customAdapter,
368
365
  };
366
+ /** Release a rejected response without waiting for its underlying stream. */
367
+ function cancelResponseBody(response) {
368
+ try {
369
+ void response.body?.cancel().catch(() => { });
370
+ }
371
+ catch {
372
+ // Cleanup must not replace the status-only failure.
373
+ }
374
+ }
375
+ /**
376
+ * A fixed-message error that keeps the original's standard name, such as
377
+ * `NotAllowedError` for blocked autoplay, so hosts can still branch on it. The
378
+ * original message and cause are dropped: they can carry signed URLs.
379
+ */
380
+ function redactedError(message, original) {
381
+ const error = new Error(message);
382
+ const name = original?.name;
383
+ if (typeof name === "string" && /^[A-Z][A-Za-z]*Error$/.test(name)) {
384
+ error.name = name;
385
+ }
386
+ return error;
387
+ }
369
388
  /**
370
389
  * Provider implementation that handles audio playback
371
390
  */
@@ -382,12 +401,12 @@ class ServerTTSProviderImpl {
382
401
  * spoken position instead of replaying the passage from its first word.
383
402
  */
384
403
  highlightCursor = -1;
385
- intentionallyStopped = false;
386
404
  // Set while a synthesis request is in flight.
387
405
  activeSynthesisController = null;
388
406
  // Starts the current audio element; a pause before it sounds leaves the
389
407
  // start to resume.
390
408
  startCurrentAudio = null;
409
+ settleActivePlayback = null;
391
410
  synthesisRunId = 0;
392
411
  telemetryReporter;
393
412
  onPlaybackStart;
@@ -401,13 +420,17 @@ class ServerTTSProviderImpl {
401
420
  try {
402
421
  await this.telemetryReporter?.(eventName, payload);
403
422
  }
404
- catch (error) {
405
- logger.warn("telemetry callback failed:", error);
423
+ catch {
424
+ logger.warn("telemetry callback failed");
425
+ }
426
+ }
427
+ assertSynthesisActive(signal, runId) {
428
+ if (signal.aborted || runId !== this.synthesisRunId) {
429
+ throw new DOMException("TTS request was aborted", "AbortError");
406
430
  }
407
431
  }
408
432
  async speak(text) {
409
433
  this.stop();
410
- this.intentionallyStopped = false;
411
434
  const runId = ++this.synthesisRunId;
412
435
  const synthesisController = new AbortController();
413
436
  this.activeSynthesisController = synthesisController;
@@ -437,6 +460,32 @@ class ServerTTSProviderImpl {
437
460
  return new Promise((resolve, reject) => {
438
461
  const audio = new Audio(audioUrl);
439
462
  this.currentAudio = audio;
463
+ let settled = false;
464
+ const settle = (error) => {
465
+ if (settled)
466
+ return;
467
+ settled = true;
468
+ audio.onplaying = null;
469
+ audio.onended = null;
470
+ audio.onerror = null;
471
+ audio.onpause = null;
472
+ audio.pause();
473
+ audio.src = "";
474
+ URL.revokeObjectURL(audioUrl);
475
+ if (this.currentAudio === audio) {
476
+ this.clearCurrentAudio();
477
+ this.pausedState = false;
478
+ this.startCurrentAudio = null;
479
+ }
480
+ if (this.settleActivePlayback === settle) {
481
+ this.settleActivePlayback = null;
482
+ }
483
+ if (error)
484
+ reject(error);
485
+ else
486
+ resolve();
487
+ };
488
+ this.settleActivePlayback = settle;
440
489
  if (this.config.volume !== undefined) {
441
490
  audio.volume = Math.max(0, Math.min(1, this.config.volume));
442
491
  }
@@ -454,27 +503,18 @@ class ServerTTSProviderImpl {
454
503
  wordBoundaries: this.wordTimings.length > 0,
455
504
  });
456
505
  }
457
- catch (error) {
458
- logger.warn("playback-start callback failed:", error);
506
+ catch {
507
+ logger.warn("playback-start callback failed");
459
508
  }
460
509
  if (this.onWordBoundary && this.wordTimings.length > 0) {
461
510
  this.startWordHighlighting();
462
511
  }
463
512
  };
464
- audio.onended = () => {
465
- URL.revokeObjectURL(audioUrl);
466
- if (!superseded())
467
- this.clearCurrentAudio();
468
- resolve();
469
- };
513
+ audio.onended = () => settle();
470
514
  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"));
515
+ settle(superseded()
516
+ ? undefined
517
+ : new Error("Failed to play audio from server"));
478
518
  };
479
519
  audio.onpause = () => {
480
520
  if (superseded())
@@ -489,7 +529,9 @@ class ServerTTSProviderImpl {
489
529
  if (aborted && this.pausedState && this.currentAudio === audio) {
490
530
  return;
491
531
  }
492
- reject(error);
532
+ settle(superseded()
533
+ ? undefined
534
+ : redactedError("Failed to start audio playback", error));
493
535
  });
494
536
  };
495
537
  this.startCurrentAudio = start;
@@ -504,6 +546,7 @@ class ServerTTSProviderImpl {
504
546
  async synthesizeSpeech(text, signal, runId) {
505
547
  const synthStartedAt = Date.now();
506
548
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "synthesize-speech"));
549
+ this.assertSynthesisActive(signal, runId);
507
550
  const headers = {
508
551
  "Content-Type": "application/json",
509
552
  ...this.config.headers,
@@ -524,76 +567,51 @@ class ServerTTSProviderImpl {
524
567
  });
525
568
  }
526
569
  catch (error) {
527
- if (signal.aborted)
528
- throw error;
570
+ this.assertSynthesisActive(signal, runId);
529
571
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
530
572
  duration: Date.now() - synthStartedAt,
531
573
  errorType: "TTSBackendNetworkError",
532
- message: error instanceof Error ? error.message : String(error),
574
+ message: "TTS synthesis network request failed",
533
575
  }));
534
- throw error;
576
+ this.assertSynthesisActive(signal, runId);
577
+ throw redactedError("TTS synthesis network request failed", error);
535
578
  }
536
579
  })();
537
580
  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}`;
581
+ // A provider can echo request text or signed URLs in its body. Do not
582
+ // read it, or wait for a stalled stream before reporting the status.
583
+ cancelResponseBody(response);
584
+ const errorMessage = `Server returned ${response.status}`;
566
585
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
567
586
  duration: Date.now() - synthStartedAt,
568
587
  statusCode: response.status,
569
588
  errorType: "TTSBackendRequestError",
570
589
  message: errorMessage,
571
590
  }));
591
+ this.assertSynthesisActive(signal, runId);
572
592
  logger.error("synthesize request failed", {
573
593
  status: response.status,
574
- url: synthUrl,
575
594
  backend: this.config.provider || "server",
576
- message: errorMessage,
577
595
  textLength: text.length,
578
596
  });
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
597
  throw new Error(errorMessage);
592
598
  }
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");
599
+ let normalized;
600
+ try {
601
+ normalized = await this.adapter.parseResponse(response, this.config, headers, signal, text);
596
602
  }
603
+ catch {
604
+ this.assertSynthesisActive(signal, runId);
605
+ const message = "TTS synthesis response could not be processed";
606
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "synthesize-speech", {
607
+ duration: Date.now() - synthStartedAt,
608
+ errorType: "TTSBackendResponseError",
609
+ message,
610
+ }));
611
+ this.assertSynthesisActive(signal, runId);
612
+ throw new Error(message);
613
+ }
614
+ this.assertSynthesisActive(signal, runId);
597
615
  let audioBlob;
598
616
  if (normalized.audio.kind === "base64") {
599
617
  audioBlob = this.base64ToBlob(normalized.audio.data, normalized.audio.contentType);
@@ -602,6 +620,7 @@ class ServerTTSProviderImpl {
602
620
  const audioAssetUrl = normalized.audio.url;
603
621
  const assetFetchStartedAt = Date.now();
604
622
  await this.emitTelemetry("pie-tool-backend-call-start", backendTelemetry(this.config, "fetch-synthesized-audio-asset"));
623
+ this.assertSynthesisActive(signal, runId);
605
624
  const assetHeaders = assetFetchHeaders(headers, this.config);
606
625
  const parsedAssetUrl = parseAssetUrl(audioAssetUrl, this.config);
607
626
  if (parsedAssetUrl === null) {
@@ -626,17 +645,18 @@ class ServerTTSProviderImpl {
626
645
  });
627
646
  }
628
647
  catch (error) {
629
- if (signal.aborted)
630
- throw error;
648
+ this.assertSynthesisActive(signal, runId);
631
649
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
632
650
  duration: Date.now() - assetFetchStartedAt,
633
651
  errorType: "TTSAssetNetworkError",
634
- message: error instanceof Error ? error.message : String(error),
652
+ message: "TTS synthesized audio request failed",
635
653
  }));
636
- throw error;
654
+ this.assertSynthesisActive(signal, runId);
655
+ throw redactedError("TTS synthesized audio request failed", error);
637
656
  }
638
657
  })();
639
658
  if (!audioResponse.ok) {
659
+ cancelResponseBody(audioResponse);
640
660
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
641
661
  duration: Date.now() - assetFetchStartedAt,
642
662
  statusCode: audioResponse.status,
@@ -645,16 +665,30 @@ class ServerTTSProviderImpl {
645
665
  }));
646
666
  throw new Error(`Failed to download synthesized audio (${audioResponse.status})`);
647
667
  }
648
- audioBlob = await audioResponse.blob();
668
+ try {
669
+ audioBlob = await audioResponse.blob();
670
+ }
671
+ catch {
672
+ this.assertSynthesisActive(signal, runId);
673
+ const message = "TTS synthesized audio response could not be read";
674
+ await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
675
+ duration: Date.now() - assetFetchStartedAt,
676
+ errorType: "TTSAssetResponseError",
677
+ message,
678
+ }));
679
+ this.assertSynthesisActive(signal, runId);
680
+ throw new Error(message);
681
+ }
649
682
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "fetch-synthesized-audio-asset", {
650
683
  duration: Date.now() - assetFetchStartedAt,
651
684
  }));
652
685
  }
653
- const audioUrl = URL.createObjectURL(audioBlob);
654
686
  const wordTimings = this.parseSpeechMarks(normalized.speechMarks);
655
687
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(this.config, "synthesize-speech", {
656
688
  duration: Date.now() - synthStartedAt,
657
689
  }));
690
+ this.assertSynthesisActive(signal, runId);
691
+ const audioUrl = URL.createObjectURL(audioBlob);
658
692
  return { audioUrl, wordTimings };
659
693
  }
660
694
  /**
@@ -808,16 +842,8 @@ class ServerTTSProviderImpl {
808
842
  this.activeSynthesisController = null;
809
843
  }
810
844
  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
- }
845
+ this.settleActivePlayback?.();
846
+ this.clearCurrentAudio();
821
847
  this.pausedState = false;
822
848
  this.wordTimings = [];
823
849
  this.highlightCursor = -1;
@@ -871,8 +897,8 @@ export class ServerTTSProvider {
871
897
  try {
872
898
  await this.telemetryReporter?.(eventName, payload);
873
899
  }
874
- catch (error) {
875
- logger.warn("telemetry callback failed:", error);
900
+ catch {
901
+ logger.warn("telemetry callback failed");
876
902
  }
877
903
  }
878
904
  /**
@@ -899,9 +925,9 @@ export class ServerTTSProvider {
899
925
  await this.emitTelemetry("pie-tool-backend-call-error", backendTelemetry(serverConfig, "validate-endpoint", {
900
926
  duration: Date.now() - validationStartedAt,
901
927
  errorType: "TTSEndpointValidationError",
902
- message: `Server TTS API not available at ${serverConfig.apiEndpoint}`,
928
+ message: "Server TTS API is not available",
903
929
  }));
904
- throw new Error(`Server TTS API not available at ${serverConfig.apiEndpoint}`);
930
+ throw new Error("Server TTS API is not available");
905
931
  }
906
932
  await this.emitTelemetry("pie-tool-backend-call-success", backendTelemetry(serverConfig, "validate-endpoint", {
907
933
  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.7",
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,9 @@
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.7",
41
+ "@pie-players/pie-tts": "1.0.0-beta.7",
42
+ "@pie-players/tts-server-core": "1.0.0-beta.7"
43
43
  },
44
44
  "devDependencies": {
45
45
  "typescript": "^5.9.3",