@pie-players/tts-client-server 1.0.0-beta.5 → 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.
- package/dist/ServerTTSProvider.js +118 -92
- package/package.json +4 -4
|
@@ -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
|
|
405
|
-
logger.warn("telemetry callback failed
|
|
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
|
|
458
|
-
logger.warn("playback-start callback failed
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
574
|
+
message: "TTS synthesis network request failed",
|
|
533
575
|
}));
|
|
534
|
-
|
|
576
|
+
this.assertSynthesisActive(signal, runId);
|
|
577
|
+
throw redactedError("TTS synthesis network request failed", error);
|
|
535
578
|
}
|
|
536
579
|
})();
|
|
537
580
|
if (!response.ok) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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:
|
|
652
|
+
message: "TTS synthesized audio request failed",
|
|
635
653
|
}));
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
|
875
|
-
logger.warn("telemetry callback failed
|
|
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:
|
|
928
|
+
message: "Server TTS API is not available",
|
|
903
929
|
}));
|
|
904
|
-
throw new Error(
|
|
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.
|
|
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.
|
|
41
|
-
"@pie-players/pie-tts": "1.0.0-beta.
|
|
42
|
-
"@pie-players/tts-server-core": "1.0.0-beta.
|
|
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",
|