@pie-players/tts-client-server 0.3.68 → 0.3.70
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 +89 -29
- package/package.json +5 -4
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* providers like AWS Polly, Google Cloud TTS, etc.
|
|
6
6
|
* Returns audio with precise word-level timing (speech marks).
|
|
7
7
|
*/
|
|
8
|
+
import { createPieLogger, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared/pie";
|
|
8
9
|
import { normalizeSpeechMarks, resolveSpeedRateBucket, } from "@pie-players/tts-server-core";
|
|
10
|
+
const logger = createPieLogger("server-tts-provider", () => isGlobalDebugEnabled());
|
|
9
11
|
/**
|
|
10
12
|
* Parse a TTS asset URL (speech marks or audio). Rejects anything that is
|
|
11
13
|
* not an absolute http(s) URL; relative URLs are resolved against
|
|
@@ -312,6 +314,12 @@ class ServerTTSProviderImpl {
|
|
|
312
314
|
pausedState = false;
|
|
313
315
|
wordTimings = [];
|
|
314
316
|
highlightInterval = null;
|
|
317
|
+
/**
|
|
318
|
+
* Index of the word timing most recently handed to `onWordBoundary`, or -1
|
|
319
|
+
* before the first. It survives pause/resume so resuming continues from the
|
|
320
|
+
* spoken position instead of replaying the passage from its first word.
|
|
321
|
+
*/
|
|
322
|
+
highlightCursor = -1;
|
|
315
323
|
intentionallyStopped = false;
|
|
316
324
|
activeSynthesisController = null;
|
|
317
325
|
synthesisRunId = 0;
|
|
@@ -328,7 +336,7 @@ class ServerTTSProviderImpl {
|
|
|
328
336
|
await this.telemetryReporter?.(eventName, payload);
|
|
329
337
|
}
|
|
330
338
|
catch (error) {
|
|
331
|
-
|
|
339
|
+
logger.warn("telemetry callback failed:", error);
|
|
332
340
|
}
|
|
333
341
|
}
|
|
334
342
|
async speak(text) {
|
|
@@ -346,6 +354,7 @@ class ServerTTSProviderImpl {
|
|
|
346
354
|
return;
|
|
347
355
|
}
|
|
348
356
|
this.wordTimings = wordTimings;
|
|
357
|
+
this.highlightCursor = -1;
|
|
349
358
|
return new Promise((resolve, reject) => {
|
|
350
359
|
// Create audio element
|
|
351
360
|
const audio = new Audio(audioUrl);
|
|
@@ -364,7 +373,7 @@ class ServerTTSProviderImpl {
|
|
|
364
373
|
this.onPlaybackStart?.();
|
|
365
374
|
}
|
|
366
375
|
catch (error) {
|
|
367
|
-
|
|
376
|
+
logger.warn("playback-start callback failed:", error);
|
|
368
377
|
}
|
|
369
378
|
// Start word highlighting
|
|
370
379
|
if (this.onWordBoundary && this.wordTimings.length > 0) {
|
|
@@ -376,6 +385,7 @@ class ServerTTSProviderImpl {
|
|
|
376
385
|
URL.revokeObjectURL(audioUrl);
|
|
377
386
|
this.currentAudio = null;
|
|
378
387
|
this.wordTimings = [];
|
|
388
|
+
this.highlightCursor = -1;
|
|
379
389
|
resolve();
|
|
380
390
|
};
|
|
381
391
|
audio.onerror = (event) => {
|
|
@@ -383,6 +393,7 @@ class ServerTTSProviderImpl {
|
|
|
383
393
|
URL.revokeObjectURL(audioUrl);
|
|
384
394
|
this.currentAudio = null;
|
|
385
395
|
this.wordTimings = [];
|
|
396
|
+
this.highlightCursor = -1;
|
|
386
397
|
void event;
|
|
387
398
|
// Only reject if this wasn't an intentional stop
|
|
388
399
|
if (!this.intentionallyStopped) {
|
|
@@ -450,14 +461,20 @@ class ServerTTSProviderImpl {
|
|
|
450
461
|
errorData = JSON.parse(rawBody);
|
|
451
462
|
}
|
|
452
463
|
catch (parseError) {
|
|
453
|
-
|
|
464
|
+
logger.warn("non-OK response body was not JSON", {
|
|
454
465
|
status: response.status,
|
|
455
466
|
url: synthUrl,
|
|
456
|
-
|
|
467
|
+
bodyLength: rawBody.length,
|
|
457
468
|
parseError: parseError instanceof Error
|
|
458
469
|
? parseError.message
|
|
459
470
|
: String(parseError),
|
|
460
471
|
});
|
|
472
|
+
// A provider's error body can echo the SSML it rejected, so the body
|
|
473
|
+
// itself stays behind the debug gate; the length above is what says
|
|
474
|
+
// a body arrived and was not JSON.
|
|
475
|
+
logger.debug("non-OK response body", {
|
|
476
|
+
rawBodyPreview: rawBody.slice(0, 500),
|
|
477
|
+
});
|
|
461
478
|
}
|
|
462
479
|
}
|
|
463
480
|
const errorMessage = (typeof errorData.message === "string" && errorData.message) ||
|
|
@@ -474,14 +491,20 @@ class ServerTTSProviderImpl {
|
|
|
474
491
|
errorType: "TTSBackendRequestError",
|
|
475
492
|
message: errorMessage,
|
|
476
493
|
});
|
|
477
|
-
|
|
478
|
-
console.error("[ServerTTSProvider] synthesize request failed", {
|
|
494
|
+
logger.error("synthesize request failed", {
|
|
479
495
|
status: response.status,
|
|
480
496
|
url: synthUrl,
|
|
481
497
|
backend: this.config.provider || "server",
|
|
482
498
|
message: errorMessage,
|
|
483
|
-
responseBody: errorData,
|
|
484
499
|
textLength: text.length,
|
|
500
|
+
});
|
|
501
|
+
// The text under synthesis is item content and the provider's error body
|
|
502
|
+
// can echo it, so both stay behind the debug gate. `errorMessage` above is
|
|
503
|
+
// thrown to the caller regardless, and `textLength` is what correlates a
|
|
504
|
+
// failure with a payload without publishing what the learner is reading.
|
|
505
|
+
const textPreview = text.replace(/\s+/g, " ").trim().slice(0, 120);
|
|
506
|
+
logger.debug("synthesize request failed for text", {
|
|
507
|
+
responseBody: errorData,
|
|
485
508
|
textPreview: textPreview.length < text.length ? `${textPreview}…` : textPreview,
|
|
486
509
|
});
|
|
487
510
|
throw new Error(errorMessage);
|
|
@@ -608,39 +631,75 @@ class ServerTTSProviderImpl {
|
|
|
608
631
|
if (!this.currentAudio ||
|
|
609
632
|
!this.onWordBoundary ||
|
|
610
633
|
this.wordTimings.length === 0) {
|
|
611
|
-
|
|
634
|
+
logger.debug("cannot start word highlighting", {
|
|
612
635
|
hasAudio: !!this.currentAudio,
|
|
613
636
|
hasCallback: !!this.onWordBoundary,
|
|
614
637
|
wordTimingsCount: this.wordTimings.length,
|
|
615
638
|
});
|
|
616
639
|
return;
|
|
617
640
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
641
|
+
logger.debug("starting word highlighting", {
|
|
642
|
+
wordTimingsCount: this.wordTimings.length,
|
|
643
|
+
playbackRate: this.currentAudio.playbackRate,
|
|
644
|
+
});
|
|
645
|
+
// A 50ms interval, chosen over the two alternatives: 50ms is below the
|
|
646
|
+
// perceptual threshold for word-level sync at a fifth of the wake-ups a
|
|
647
|
+
// 60Hz `requestAnimationFrame` loop would spend on the same job, and
|
|
648
|
+
// `timeupdate` fires at browser discretion — commonly 150-250ms — so it
|
|
649
|
+
// would need a timer behind it to interpolate anyway. The interval also
|
|
650
|
+
// degrades better while the tab is hidden: browsers clamp it to roughly
|
|
651
|
+
// 1s where they suspend rAF outright, and because a tick resolves
|
|
652
|
+
// straight to the word that is current, one clamped tick resyncs the
|
|
653
|
+
// highlight rather than replaying a backlog.
|
|
623
654
|
this.highlightInterval = window.setInterval(() => {
|
|
624
655
|
if (!this.currentAudio) {
|
|
625
656
|
this.stopWordHighlighting();
|
|
626
657
|
return;
|
|
627
658
|
}
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const timing = this.wordTimings[i];
|
|
633
|
-
if (currentTime >= timing.time && i > lastWordIndex) {
|
|
634
|
-
// Fire word boundary callback
|
|
635
|
-
if (this.onWordBoundary) {
|
|
636
|
-
console.log("[ServerTTSProvider] Highlighting word at charIndex:", timing.charIndex, "length:", timing.length, "time:", timing.time, "currentTime:", currentTime);
|
|
637
|
-
this.onWordBoundary(timing.word, timing.charIndex, timing.length);
|
|
638
|
-
}
|
|
639
|
-
lastWordIndex = i;
|
|
640
|
-
break;
|
|
641
|
-
}
|
|
659
|
+
const currentTimeMs = this.currentAudio.currentTime * 1000;
|
|
660
|
+
const index = this.resolveCurrentWordIndex(currentTimeMs, this.highlightCursor);
|
|
661
|
+
if (index < 0 || index === this.highlightCursor) {
|
|
662
|
+
return;
|
|
642
663
|
}
|
|
643
|
-
|
|
664
|
+
// Report only the word that is current now. Words a tick crossed over
|
|
665
|
+
// are deliberately skipped rather than fired in order: the callback
|
|
666
|
+
// names the word being spoken and its consumer repaints a single range
|
|
667
|
+
// per call, so replaying the backlog would paint highlights the learner
|
|
668
|
+
// never sees and leave the last paint behind the audio. Firing one word
|
|
669
|
+
// per tick capped highlighting at 20 words/second, which is how the
|
|
670
|
+
// highlight used to drift off the spoken word and never catch up.
|
|
671
|
+
this.highlightCursor = index;
|
|
672
|
+
const timing = this.wordTimings[index];
|
|
673
|
+
logger.debug("word boundary", {
|
|
674
|
+
wordIndex: timing.wordIndex,
|
|
675
|
+
charIndex: timing.charIndex,
|
|
676
|
+
length: timing.length,
|
|
677
|
+
timingMs: timing.time,
|
|
678
|
+
currentTimeMs,
|
|
679
|
+
});
|
|
680
|
+
this.onWordBoundary?.(timing.word, timing.charIndex, timing.length);
|
|
681
|
+
}, 50);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Index of the last timing whose start time has arrived at `currentTimeMs`,
|
|
685
|
+
* or -1 when playback has not reached the first word yet.
|
|
686
|
+
*
|
|
687
|
+
* The scan resumes at `cursor`, so a tick costs the number of words it
|
|
688
|
+
* crossed instead of the length of the passage. A backward jump — a seek, or
|
|
689
|
+
* a replay on the same element — invalidates the cursor, and only then does
|
|
690
|
+
* the scan restart from the beginning.
|
|
691
|
+
*/
|
|
692
|
+
resolveCurrentWordIndex(currentTimeMs, cursor) {
|
|
693
|
+
const timings = this.wordTimings;
|
|
694
|
+
let index = cursor < timings.length ? cursor : -1;
|
|
695
|
+
if (index >= 0 && currentTimeMs < timings[index].time) {
|
|
696
|
+
index = -1;
|
|
697
|
+
}
|
|
698
|
+
while (index + 1 < timings.length &&
|
|
699
|
+
timings[index + 1].time <= currentTimeMs) {
|
|
700
|
+
index++;
|
|
701
|
+
}
|
|
702
|
+
return index;
|
|
644
703
|
}
|
|
645
704
|
/**
|
|
646
705
|
* Stop word highlighting
|
|
@@ -687,6 +746,7 @@ class ServerTTSProviderImpl {
|
|
|
687
746
|
}
|
|
688
747
|
this.pausedState = false;
|
|
689
748
|
this.wordTimings = [];
|
|
749
|
+
this.highlightCursor = -1;
|
|
690
750
|
}
|
|
691
751
|
isPlaying() {
|
|
692
752
|
return this.currentAudio !== null && !this.pausedState;
|
|
@@ -737,7 +797,7 @@ export class ServerTTSProvider {
|
|
|
737
797
|
await this.telemetryReporter?.(eventName, payload);
|
|
738
798
|
}
|
|
739
799
|
catch (error) {
|
|
740
|
-
|
|
800
|
+
logger.warn("telemetry callback failed:", error);
|
|
741
801
|
}
|
|
742
802
|
}
|
|
743
803
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pie-players/tts-client-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.70",
|
|
4
4
|
"description": "Client-side TTS provider that calls server API for synthesis",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,12 +38,13 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@pie-players/pie-
|
|
42
|
-
"@pie-players/tts
|
|
41
|
+
"@pie-players/pie-players-shared": "0.3.70",
|
|
42
|
+
"@pie-players/pie-tts": "0.3.70",
|
|
43
|
+
"@pie-players/tts-server-core": "0.3.70"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"typescript": "^5.9.3",
|
|
46
|
-
"vitest": "^4.1.
|
|
47
|
+
"vitest": "^4.1.11"
|
|
47
48
|
},
|
|
48
49
|
"sideEffects": false,
|
|
49
50
|
"homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/tts-client-server#readme",
|