@pie-players/tts-client-server 0.3.69 → 0.3.71

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.
@@ -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
- console.warn("[ServerTTSProvider] telemetry callback failed:", error);
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
- console.warn("[ServerTTSProvider] playback-start callback failed:", error);
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
- console.warn("[ServerTTSProvider] non-OK response body was not JSON", {
464
+ logger.warn("non-OK response body was not JSON", {
454
465
  status: response.status,
455
466
  url: synthUrl,
456
- rawBodyPreview: rawBody.slice(0, 500),
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
- const textPreview = text.replace(/\s+/g, " ").trim().slice(0, 120);
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
- console.log("[ServerTTSProvider] Cannot start highlighting:", {
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
- console.log("[ServerTTSProvider] Starting word highlighting with", this.wordTimings.length, "word timings");
619
- console.log("[ServerTTSProvider] Playback rate:", this.currentAudio.playbackRate);
620
- console.log("[ServerTTSProvider] First 3 timings:", this.wordTimings.slice(0, 3));
621
- let lastWordIndex = -1;
622
- // Poll every 50ms to check current playback time
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
- // Get current playback time in milliseconds
629
- const currentTime = this.currentAudio.currentTime * 1000;
630
- // Find words that should be highlighted at current time
631
- for (let i = 0; i < this.wordTimings.length; i++) {
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
- }, 50); // 50ms polling = 20 times per second
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
- console.warn("[ServerTTSProvider] telemetry callback failed:", error);
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.69",
3
+ "version": "0.3.71",
4
4
  "description": "Client-side TTS provider that calls server API for synthesis",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,8 +38,9 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@pie-players/pie-tts": "0.3.69",
42
- "@pie-players/tts-server-core": "0.3.69"
41
+ "@pie-players/pie-players-shared": "0.3.71",
42
+ "@pie-players/pie-tts": "0.3.71",
43
+ "@pie-players/tts-server-core": "0.3.71"
43
44
  },
44
45
  "devDependencies": {
45
46
  "typescript": "^5.9.3",