@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.
- package/dist/ServerTTSProvider.js +126 -95
- package/package.json +3 -4
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
405
|
-
logger.warn("telemetry callback failed
|
|
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
|
|
458
|
-
logger.warn("playback-start callback failed
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
579
|
+
message: "TTS synthesis network request failed",
|
|
533
580
|
}));
|
|
534
|
-
|
|
581
|
+
this.assertSynthesisActive(signal, runId);
|
|
582
|
+
throw redactedError("TTS synthesis network request failed", error);
|
|
535
583
|
}
|
|
536
584
|
})();
|
|
537
585
|
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}`;
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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:
|
|
657
|
+
message: "TTS synthesized audio request failed",
|
|
635
658
|
}));
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
875
|
-
logger.warn("telemetry callback failed
|
|
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:
|
|
933
|
+
message: "Server TTS API is not available",
|
|
903
934
|
}));
|
|
904
|
-
throw new Error(
|
|
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.
|
|
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.
|
|
41
|
-
"@pie-players/pie-tts": "1.0.0-beta.
|
|
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",
|