@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.0

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.
Files changed (142) hide show
  1. package/README.md +449 -541
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +0 -1
  4. package/dist/attempt/TestSession.js +10 -5
  5. package/dist/components/ItemScope.custom-element.js +1 -1
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
  10. package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  12. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  13. package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
  14. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +6 -0
  16. package/dist/index.d.ts +7 -7
  17. package/dist/index.js +4 -10
  18. package/dist/policy/core/PolicySource.d.ts +1 -1
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
  20. package/dist/policy/core/ToolPolicyEngine.js +73 -16
  21. package/dist/policy/core/compose-decision.d.ts +3 -3
  22. package/dist/policy/core/compose-decision.js +30 -30
  23. package/dist/policy/core/decision-types.d.ts +44 -32
  24. package/dist/policy/core/feature-decision.d.ts +17 -15
  25. package/dist/policy/core/feature-decision.js +11 -13
  26. package/dist/policy/core/pnp-policy-inputs.d.ts +5 -2
  27. package/dist/policy/core/pnp-policy-inputs.js +12 -32
  28. package/dist/policy/core/provenance.d.ts +1 -1
  29. package/dist/policy/core/tool-parameters.d.ts +11 -0
  30. package/dist/policy/core/tool-parameters.js +15 -0
  31. package/dist/policy/engine.d.ts +1 -1
  32. package/dist/policy/sources/PnpPolicySource.d.ts +14 -22
  33. package/dist/policy/sources/PnpPolicySource.js +30 -28
  34. package/dist/runtime/SectionControllerBinding.d.ts +6 -2
  35. package/dist/runtime/SectionControllerBinding.js +21 -15
  36. package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
  37. package/dist/runtime/adapter/subscriber-fanout.js +1 -4
  38. package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
  39. package/dist/runtime/composition-emit-scheduler.js +1 -1
  40. package/dist/runtime/content-region.d.ts +0 -4
  41. package/dist/runtime/content-region.js +2 -2
  42. package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
  43. package/dist/runtime/core/SectionEngineCore.js +0 -4
  44. package/dist/runtime/core/cohort.d.ts +0 -5
  45. package/dist/runtime/core/cohort.js +0 -9
  46. package/dist/runtime/core/engine-output.d.ts +3 -2
  47. package/dist/runtime/core/engine-transition.js +3 -2
  48. package/dist/runtime/registration-events.d.ts +11 -14
  49. package/dist/runtime/runtime-id.d.ts +1 -1
  50. package/dist/runtime/runtime-id.js +1 -1
  51. package/dist/runtime/shell-scope.d.ts +4 -4
  52. package/dist/runtime/shell-scope.js +15 -9
  53. package/dist/runtime/tool-host-contract.d.ts +0 -4
  54. package/dist/runtime/tool-host-contract.js +1 -1
  55. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  56. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  57. package/dist/services/ElementToolStateStore.d.ts +32 -61
  58. package/dist/services/ElementToolStateStore.js +37 -67
  59. package/dist/services/HighlightCoordinator.d.ts +9 -21
  60. package/dist/services/HighlightCoordinator.js +52 -80
  61. package/dist/services/RangeSerializer.d.ts +4 -35
  62. package/dist/services/RangeSerializer.js +4 -48
  63. package/dist/services/SSMLExtractor.d.ts +8 -31
  64. package/dist/services/SSMLExtractor.js +18 -40
  65. package/dist/services/TTSService.d.ts +49 -56
  66. package/dist/services/TTSService.js +308 -283
  67. package/dist/services/ToolCoordinator.d.ts +6 -49
  68. package/dist/services/ToolCoordinator.js +25 -61
  69. package/dist/services/ToolRegistry.d.ts +82 -80
  70. package/dist/services/ToolRegistry.js +91 -67
  71. package/dist/services/ToolkitCoordinator.d.ts +83 -81
  72. package/dist/services/ToolkitCoordinator.js +186 -174
  73. package/dist/services/framework-error.d.ts +30 -7
  74. package/dist/services/framework-error.js +10 -13
  75. package/dist/services/interfaces.d.ts +47 -71
  76. package/dist/services/section-controller-types.d.ts +7 -7
  77. package/dist/services/section-session-assignment.js +6 -8
  78. package/dist/services/spoken-audio-cards.d.ts +2 -4
  79. package/dist/services/spoken-audio-cards.js +2 -4
  80. package/dist/services/tool-config-validation.d.ts +5 -7
  81. package/dist/services/tool-config-validation.js +11 -26
  82. package/dist/services/tool-context.d.ts +7 -23
  83. package/dist/services/tool-context.js +99 -53
  84. package/dist/services/tool-failure.d.ts +30 -0
  85. package/dist/services/tool-failure.js +11 -0
  86. package/dist/services/tool-icons.d.ts +1 -2
  87. package/dist/services/tool-icons.js +2 -2
  88. package/dist/services/tool-instance-id.d.ts +8 -6
  89. package/dist/services/tool-instance-id.js +12 -18
  90. package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
  91. package/dist/services/tool-providers/TTSToolProvider.js +7 -63
  92. package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
  93. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
  94. package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
  95. package/dist/services/tool-request.d.ts +4 -0
  96. package/dist/services/tool-request.js +7 -2
  97. package/dist/services/toolbar-decided-tools.d.ts +1 -0
  98. package/dist/services/toolbar-decided-tools.js +1 -0
  99. package/dist/services/toolbar-items.js +2 -5
  100. package/dist/services/tools-config-normalizer.d.ts +18 -9
  101. package/dist/services/tools-config-normalizer.js +25 -19
  102. package/dist/services/tts/browser-provider.d.ts +2 -5
  103. package/dist/services/tts/browser-provider.js +4 -20
  104. package/dist/services/tts/catalog-span-alignment.js +14 -7
  105. package/dist/services/tts/flat-tree.d.ts +0 -7
  106. package/dist/services/tts/flat-tree.js +2 -2
  107. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  108. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  109. package/dist/services/tts/generated-speech/index.js +1 -1
  110. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  111. package/dist/services/tts/generated-speech/types.js +1 -1
  112. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
  113. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  114. package/dist/services/tts/math-aware-text-processing.js +10 -29
  115. package/dist/services/tts/math-speech.d.ts +1 -1
  116. package/dist/services/tts/math-speech.js +3 -0
  117. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  118. package/dist/services/tts/mathml-sanitization.js +9 -1
  119. package/dist/services/tts/provider-options.d.ts +12 -1
  120. package/dist/services/tts/ssml/spoken-text.js +1 -1
  121. package/dist/services/tts/start-failure.d.ts +0 -7
  122. package/dist/services/tts/start-failure.js +1 -1
  123. package/dist/services/tts/text-segmentation.d.ts +5 -0
  124. package/dist/services/tts/text-segmentation.js +13 -10
  125. package/dist/services/tts-runtime-config.d.ts +32 -12
  126. package/dist/services/tts-runtime-config.js +14 -20
  127. package/dist/tools/content-capability-resolution.d.ts +2 -2
  128. package/dist/tools/registration.d.ts +6 -5
  129. package/dist/tools/registration.js +10 -9
  130. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  131. package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
  132. package/dist/tools/tool-surface-host.js +1 -8
  133. package/package.json +8 -6
  134. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
  135. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
  136. package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
  137. package/dist/services/ContextVariableStore.d.ts +0 -113
  138. package/dist/services/ContextVariableStore.js +0 -206
  139. package/dist/services/tts-control-events.d.ts +0 -13
  140. package/dist/services/tts-control-events.js +0 -7
  141. package/dist/utils/logger.d.ts +0 -6
  142. package/dist/utils/logger.js +0 -10
@@ -1,23 +1,24 @@
1
1
  /**
2
2
  * TTSService
3
3
  *
4
- * Text-to-speech service with pluggable provider architecture.
5
- * Manages playback state, coordinates multiple TTS entry points,
6
- * and integrates with HighlightCoordinator for word highlighting.
4
+ * Owns one read-aloud session: the installed provider, playback state, and the
5
+ * word and sentence highlights painted through HighlightCoordinator.
7
6
  *
8
- * Features:
9
- * - Pluggable TTS providers (browser, AWS Polly, etc.)
10
- * - QTI 3.0 accessibility catalogs (pre-authored spoken content)
11
- * - Unified playback state management
12
- * - Prevents conflicts from simultaneous speech
13
- * - Word highlighting integration
14
- * - Playback controls: play, pause, resume, stop
15
- *
16
- * Part of PIE Assessment Toolkit.
7
+ * Layout:
8
+ * - Provider and host wiring: initialize, setCatalogResolver, the highlight
9
+ * target resolver and math-speech sources.
10
+ * - Content resolution: catalog spoken card, then catalog-composed chunks
11
+ * (script and/or recording with a plain fallback), then generated speech.
12
+ * - Speech plan: structural pauses, seek segments, sentence segments and the
13
+ * normalized-to-DOM position map.
14
+ * - Playback: speakContent → executeSpeakPlayback, the run guard (speakRunId,
15
+ * runOwner), the pause hold and the playback-start barrier.
16
+ * - Highlight painting: boundary handling, the host resolver remap, catalog
17
+ * highlight plans.
18
+ * - Controls and state: pause/resume/stop/seek/rate, setState and listeners.
17
19
  */
18
20
  import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
19
21
  import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
20
- import { HighlightType } from "./HighlightCoordinator.js";
21
22
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
22
23
  import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
23
24
  import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
@@ -32,7 +33,6 @@ import { toTTSStartFailure } from "./tts/start-failure.js";
32
33
  import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
33
34
  import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
34
35
  import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.js";
35
- import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
36
36
  // Debug lines need `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
37
37
  const logger = createPieLogger("tts-service", isTtsDebugEnabled);
38
38
  /**
@@ -46,6 +46,14 @@ export var PlaybackState;
46
46
  PlaybackState["PAUSED"] = "paused";
47
47
  PlaybackState["ERROR"] = "error";
48
48
  })(PlaybackState || (PlaybackState = {}));
49
+ const DEFAULT_STRUCTURAL_PAUSE_PROFILE = {
50
+ baseMs: 280,
51
+ units: { minor: 0.65, section: 1.0, major: 1.35 },
52
+ minMs: 120,
53
+ maxMs: 900,
54
+ };
55
+ const MIN_PLAYBACK_RATE = 0.25;
56
+ const MAX_PLAYBACK_RATE = 4;
49
57
  const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
50
58
  const hostLocalesOf = (options, previous = {}) => {
51
59
  if (!options)
@@ -89,12 +97,53 @@ const sameRenderableHighlightTarget = (left, right) => {
89
97
  return false;
90
98
  };
91
99
  /**
92
- * TTSService
93
- *
100
+ * Where two texts first differ, with 20 characters of context each side;
101
+ * debug tracing only.
102
+ */
103
+ const firstDifference = (spoken, normalized) => {
104
+ for (let i = 0; i < Math.min(spoken.length, normalized.length); i++) {
105
+ if (spoken[i] !== normalized[i]) {
106
+ return {
107
+ index: i,
108
+ spokenChar: spoken[i],
109
+ normalizedChar: normalized[i],
110
+ spokenContext: spoken.substring(Math.max(0, i - 20), i + 20),
111
+ normalizedContext: normalized.substring(Math.max(0, i - 20), i + 20),
112
+ };
113
+ }
114
+ }
115
+ return null;
116
+ };
117
+ /** The authored `data-tts-break-ms` pause, when it is a positive number. */
118
+ const explicitBreakMsOf = (element) => {
119
+ const ms = Number(element.getAttribute("data-tts-break-ms"));
120
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
121
+ };
122
+ const isBlockDisplay = (element) => {
123
+ if (typeof window === "undefined")
124
+ return false;
125
+ const display = window.getComputedStyle(element).display;
126
+ return (display === "block" ||
127
+ display === "list-item" ||
128
+ display.startsWith("table") ||
129
+ display === "flex" ||
130
+ display === "grid");
131
+ };
132
+ /** `[start, end)` of `text` with its surrounding whitespace excluded. */
133
+ const trimSpan = (text, start, end) => {
134
+ const raw = text.substring(start, end);
135
+ return {
136
+ start: start + (raw.match(/^\s*/)?.[0].length || 0),
137
+ end: end - (raw.match(/\s*$/)?.[0].length || 0),
138
+ };
139
+ };
140
+ /**
94
141
  * Instantiable service for text-to-speech functionality.
95
142
  * Each instance manages its own playback state.
96
143
  */
97
144
  export class TTSService {
145
+ // currentProvider is the provider the host installed; provider is the
146
+ // implementation its initialize() returned, which does the speaking.
98
147
  currentProvider = null;
99
148
  provider = null;
100
149
  highlightCoordinator = null;
@@ -115,10 +164,18 @@ export class TTSService {
115
164
  mathSpeechSource = null;
116
165
  currentText = null;
117
166
  currentContentElement = null;
167
+ // Offsets in the normalized visible text of the element being read, mapped
168
+ // to DOM text positions; word boundaries resolve through it.
118
169
  normalizedToDOM = new Map();
119
170
  listeners = new Set();
120
171
  lastError = null;
172
+ // Bumped by every new run and by stop, restart and dispose; an async step
173
+ // that resumes under an older id has been superseded and returns.
121
174
  speakRunId = 0;
175
+ runOwner = null;
176
+ // currentBoundaryOffset is the spoken-text offset the run has reached, which
177
+ // seek and word boundaries count from; activeWordBoundaryOffset is where the
178
+ // spoken text starts in the element's visible text, nonzero for a selection.
122
179
  currentBoundaryOffset = 0;
123
180
  activeWordBoundaryOffset = 0;
124
181
  seekSegments = [];
@@ -142,20 +199,21 @@ export class TTSService {
142
199
  pendingPlaybackRateRequest = null;
143
200
  activePlaybackRate = null;
144
201
  activeHighlightMode = "word";
202
+ // What the plan part being spoken paints in sentence mode.
203
+ wordlessPartHighlight = null;
145
204
  lastRenderedRegionTarget = null;
146
205
  telemetryReporter = null;
147
- // Memoized SRE resolver for the runtime generated-speech path (PIE-623).
206
+ // Memoized SRE resolver for the runtime generated-speech path.
148
207
  // Caches DOM-free spoken text keyed by SRE settings and MathML source across
149
208
  // utterances within this service instance.
150
209
  generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
151
- constructor() { }
152
210
  clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
153
211
  cleanup?.();
154
212
  if (this.playbackStartBarrierCleanup === cleanup) {
155
213
  this.playbackStartBarrierCleanup = null;
156
214
  }
157
215
  }
158
- notifyPlaybackStarted(runId) {
216
+ notifyPlaybackStarted(runId, playback) {
159
217
  if (this.playbackStartDeferredRunId !== runId ||
160
218
  runId !== this.speakRunId) {
161
219
  return;
@@ -170,7 +228,27 @@ export class TTSService {
170
228
  for (const applyHighlight of pendingHighlights) {
171
229
  applyHighlight();
172
230
  }
231
+ if (playback?.wordBoundaries === false)
232
+ this.highlightWordlessPlayback();
173
233
  }
234
+ /**
235
+ * A word-mode part whose playback sends no boundaries, such as a server
236
+ * response without speech marks, highlights as a sentence-mode read does.
237
+ * Catalog chunks and a single speak highlight the same in both modes.
238
+ */
239
+ highlightWordlessPlayback() {
240
+ const paint = this.wordlessPartHighlight;
241
+ if (!paint || this.activeHighlightMode !== "word")
242
+ return;
243
+ this.highlightCoordinator?.clearTTSWord();
244
+ paint();
245
+ }
246
+ /**
247
+ * Defers marking the run playing, and the highlights queued through
248
+ * runWhenPlaybackStarts, until the provider or the recording signals that
249
+ * audio started. Returns the barrier's cleanup, or null when nothing will
250
+ * signal a start, in which case the caller marks the run playing itself.
251
+ */
174
252
  installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
175
253
  this.clearPlaybackStartBarrier();
176
254
  const provider = this.provider;
@@ -178,12 +256,12 @@ export class TTSService {
178
256
  if (!startAwareProvider && !hasMediaStartSignal)
179
257
  return null;
180
258
  const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
181
- const onPlaybackStart = () => {
259
+ const onPlaybackStart = (playback) => {
182
260
  try {
183
- this.notifyPlaybackStarted(runId);
261
+ this.notifyPlaybackStarted(runId, playback);
184
262
  }
185
263
  finally {
186
- previousOnPlaybackStart?.();
264
+ previousOnPlaybackStart?.(playback);
187
265
  }
188
266
  };
189
267
  if (startAwareProvider) {
@@ -259,17 +337,19 @@ export class TTSService {
259
337
  * run, whatever it is still waiting for: the provider, its content or the
260
338
  * provider's audio.
261
339
  */
262
- beginRun() {
340
+ beginRun(owner) {
263
341
  // A recorded clip that starts the new run would otherwise play over the
264
342
  // previous run's synthesis, which only the provider's next speak stops.
265
343
  const supersedesPlayback = this.state === PlaybackState.LOADING ||
266
344
  this.state === PlaybackState.PLAYING ||
267
345
  this.state === PlaybackState.PAUSED;
268
346
  const runId = ++this.speakRunId;
347
+ this.runOwner = owner;
269
348
  this.abandonRun();
270
349
  if (supersedesPlayback)
271
350
  this.provider?.stop();
272
- this.setState(PlaybackState.LOADING);
351
+ // Announced even from loading, so a listener sees the owner change.
352
+ this.setState(PlaybackState.LOADING, true);
273
353
  return runId;
274
354
  }
275
355
  /** The run's audio is sounding; under a pause, it sounds again on resume. */
@@ -295,9 +375,6 @@ export class TTSService {
295
375
  }
296
376
  /**
297
377
  * Initialize TTS service with a provider
298
- *
299
- * @param provider TTS provider instance
300
- * @param config Provider configuration
301
378
  */
302
379
  async initialize(provider, config = {}) {
303
380
  if (this.disposed)
@@ -391,8 +468,6 @@ export class TTSService {
391
468
  *
392
469
  * When set, the speak() method will check for pre-authored spoken
393
470
  * content in catalogs before falling back to generated TTS.
394
- *
395
- * @param resolver AccessibilityCatalogResolver instance
396
471
  */
397
472
  setCatalogResolver(resolver) {
398
473
  this.catalogResolver = resolver;
@@ -529,12 +604,6 @@ export class TTSService {
529
604
  }
530
605
  this.highlightCoordinator.highlightTTSSentence(paintedRanges);
531
606
  }
532
- /**
533
- * Get provider capabilities
534
- */
535
- getCapabilities() {
536
- return this.currentProvider?.getCapabilities() || null;
537
- }
538
607
  providerOptions() {
539
608
  const options = this.ttsConfig.providerOptions;
540
609
  return options && typeof options === "object" ? options : {};
@@ -603,8 +672,8 @@ export class TTSService {
603
672
  });
604
673
  }
605
674
  /**
606
- * Build a character-by-character map from normalized text positions to DOM positions
607
- * This handles the complexity of whitespace normalization
675
+ * Text-collection options for `locale`, with the provider's boundary-spacing
676
+ * mode when it names a known one.
608
677
  */
609
678
  getTextProcessingOptions(locale) {
610
679
  const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
@@ -640,6 +709,10 @@ export class TTSService {
640
709
  }
641
710
  return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
642
711
  }
712
+ /**
713
+ * Rebuilds normalizedToDOM from `element`'s visible text; traced runs also
714
+ * log how it differs from `spokenText`.
715
+ */
643
716
  buildPositionMap(element, spokenText, language) {
644
717
  this.normalizedToDOM.clear();
645
718
  const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
@@ -655,20 +728,7 @@ export class TTSService {
655
728
  normalizedPreview: normalizedDomText.substring(0, 150),
656
729
  differAtIndex: spokenText === normalizedDomText
657
730
  ? null
658
- : (() => {
659
- for (let i = 0; i < Math.min(spokenText.length, normalizedDomText.length); i++) {
660
- if (spokenText[i] !== normalizedDomText[i]) {
661
- return {
662
- index: i,
663
- spokenChar: spokenText[i],
664
- normalizedChar: normalizedDomText[i],
665
- spokenContext: spokenText.substring(Math.max(0, i - 20), i + 20),
666
- normalizedContext: normalizedDomText.substring(Math.max(0, i - 20), i + 20),
667
- };
668
- }
669
- }
670
- return null;
671
- })(),
731
+ : firstDifference(spokenText, normalizedDomText),
672
732
  });
673
733
  logger.debug("Position map built:", {
674
734
  entries: this.normalizedToDOM.size,
@@ -697,20 +757,21 @@ export class TTSService {
697
757
  }
698
758
  return fallback;
699
759
  };
760
+ const defaults = DEFAULT_STRUCTURAL_PAUSE_PROFILE;
700
761
  return {
701
- baseMs: toNumber(custom.baseMs, 280),
762
+ baseMs: toNumber(custom.baseMs, defaults.baseMs),
702
763
  units: {
703
- minor: toNumber(customUnits.minor, 0.65),
704
- section: toNumber(customUnits.section, 1.0),
705
- major: toNumber(customUnits.major, 1.35),
764
+ minor: toNumber(customUnits.minor, defaults.units.minor),
765
+ section: toNumber(customUnits.section, defaults.units.section),
766
+ major: toNumber(customUnits.major, defaults.units.major),
706
767
  },
707
- minMs: toNumber(custom.minMs, 120),
708
- maxMs: toNumber(custom.maxMs, 900),
768
+ minMs: toNumber(custom.minMs, defaults.minMs),
769
+ maxMs: toNumber(custom.maxMs, defaults.maxMs),
709
770
  };
710
771
  }
711
772
  resolvePauseMsFromUnits(units) {
712
773
  const profile = this.getStructuralPauseProfile();
713
- const rate = Math.max(0.25, Math.min(4, Number(this.ttsConfig.rate || 1)));
774
+ const rate = Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, Number(this.ttsConfig.rate || 1)));
714
775
  const raw = (profile.baseMs * Math.max(0, units)) / rate;
715
776
  return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
716
777
  }
@@ -730,12 +791,8 @@ export class TTSService {
730
791
  return false;
731
792
  return /<\s*break\b|<\s*speak\b|<\s*prosody\b|<\s*p\b|<\s*s\b/i.test(text);
732
793
  }
733
- isElementHidden(element) {
734
- return isElementHiddenForTTS(element);
735
- }
736
794
  getBoundaryStrength(element) {
737
- const explicitBreakMs = Number(element.getAttribute("data-tts-break-ms"));
738
- if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
795
+ if (explicitBreakMsOf(element) !== null)
739
796
  return "major";
740
797
  const tagName = element.tagName.toUpperCase();
741
798
  if (tagName.match(/^H[1-6]$/))
@@ -750,26 +807,16 @@ export class TTSService {
750
807
  tagName === "LI") {
751
808
  return "section";
752
809
  }
753
- if (typeof window !== "undefined") {
754
- const display = window.getComputedStyle(element).display;
755
- if (display === "block" ||
756
- display === "list-item" ||
757
- display.startsWith("table") ||
758
- display === "flex" ||
759
- display === "grid") {
760
- return "minor";
761
- }
762
- }
763
810
  return "minor";
764
811
  }
765
812
  getBoundaryAnchor(textNode, root) {
766
813
  let current = flatTreeParentElement(textNode);
767
814
  let best = null;
768
815
  while (current && current !== root) {
769
- if (this.isElementHidden(current))
816
+ if (isElementHiddenForTTS(current))
770
817
  return null;
771
- const explicitBreakMs = Number(current.getAttribute("data-tts-break-ms"));
772
- if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0) {
818
+ const explicitBreakMs = explicitBreakMsOf(current);
819
+ if (explicitBreakMs !== null) {
773
820
  return {
774
821
  anchor: current,
775
822
  units: explicitBreakMs / this.getStructuralPauseProfile().baseMs,
@@ -784,18 +831,8 @@ export class TTSService {
784
831
  best = current;
785
832
  break;
786
833
  }
787
- if (!best) {
788
- if (typeof window !== "undefined") {
789
- const display = window.getComputedStyle(current).display;
790
- if (display === "block" ||
791
- display === "list-item" ||
792
- display.startsWith("table") ||
793
- display === "flex" ||
794
- display === "grid") {
795
- best = current;
796
- }
797
- }
798
- }
834
+ if (!best && isBlockDisplay(current))
835
+ best = current;
799
836
  current = flatTreeParentElement(current);
800
837
  }
801
838
  if (!best)
@@ -812,7 +849,7 @@ export class TTSService {
812
849
  * part of it.
813
850
  */
814
851
  createSpeechPlan(contentElement, normalizedText, textOffset, language) {
815
- const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText, language);
852
+ const boundaries = this.collectSpeechPlanBoundaries(contentElement, language);
816
853
  if (textOffset === 0) {
817
854
  return this.createSpeechPlanSegments(normalizedText, boundaries);
818
855
  }
@@ -822,7 +859,7 @@ export class TTSService {
822
859
  }
823
860
  return this.createSpeechPlanSegments(normalizedText, shifted);
824
861
  }
825
- collectSpeechPlanBoundaries(contentElement, normalizedText, language) {
862
+ collectSpeechPlanBoundaries(contentElement, language) {
826
863
  const boundaries = new Map();
827
864
  // The same locale the position map used, so the boundary offsets index the
828
865
  // text the map built.
@@ -832,7 +869,7 @@ export class TTSService {
832
869
  for (const textNode of flatTreeTextNodes(contentElement)) {
833
870
  const parent = flatTreeParentElement(textNode);
834
871
  if (parent &&
835
- !this.isElementHidden(parent) &&
872
+ !isElementHiddenForTTS(parent) &&
836
873
  !isNodeSuppressedForTTS(textNode)) {
837
874
  const boundary = this.getBoundaryAnchor(textNode, contentElement);
838
875
  const boundaryPoint = nodeStartOffsets.get(textNode);
@@ -863,11 +900,7 @@ export class TTSService {
863
900
  const segments = [];
864
901
  let start = 0;
865
902
  for (const point of points) {
866
- const raw = normalizedText.substring(start, point);
867
- const leading = raw.match(/^\s*/)?.[0].length || 0;
868
- const trailing = raw.match(/\s*$/)?.[0].length || 0;
869
- const segmentStart = start + leading;
870
- const segmentEnd = point - trailing;
903
+ const { start: segmentStart, end: segmentEnd } = trimSpan(normalizedText, start, point);
871
904
  if (segmentEnd > segmentStart) {
872
905
  const text = normalizedText.substring(segmentStart, segmentEnd);
873
906
  segments.push({
@@ -925,11 +958,7 @@ export class TTSService {
925
958
  }
926
959
  let cursor = segmentStart;
927
960
  for (const point of [...splitPoints, segmentEnd]) {
928
- const raw = sourceText.substring(cursor, point);
929
- const leadingWhitespace = raw.match(/^\s*/)?.[0].length || 0;
930
- const trailingWhitespace = raw.match(/\s*$/)?.[0].length || 0;
931
- const startOffset = cursor + leadingWhitespace;
932
- const endOffset = point - trailingWhitespace;
961
+ const { start: startOffset, end: endOffset } = trimSpan(sourceText, cursor, point);
933
962
  if (endOffset > startOffset) {
934
963
  result.push({
935
964
  text: sourceText.substring(startOffset, endOffset),
@@ -953,6 +982,9 @@ export class TTSService {
953
982
  return this.splitSegmentsAtBoundaries(grammaticalSegments, structuralBoundaries, args.contentToSpeak);
954
983
  }
955
984
  createSeekSegmentsFromSpeechChunks(chunks) {
985
+ // One seek segment per chunk, at offsets into a virtual text that joins the
986
+ // chunks with one separator character. A chunk whose speech is SSML is
987
+ // measured by its plain fallback or visible text.
956
988
  let offset = 0;
957
989
  return chunks.map((chunk) => {
958
990
  const speechText = chunk.plainFallback?.speechText ||
@@ -966,23 +998,12 @@ export class TTSService {
966
998
  startOffset: offset,
967
999
  pauseMsAfter: 0,
968
1000
  };
969
- offset += Math.max(1, text.length + 1);
1001
+ offset += text.length + 1;
970
1002
  return segment;
971
1003
  });
972
1004
  }
973
1005
  getCurrentSeekSegmentIndex() {
974
- if (this.seekSegments.length === 0)
975
- return 0;
976
- let index = 0;
977
- for (let i = 0; i < this.seekSegments.length; i++) {
978
- if (this.seekSegments[i].startOffset <= this.currentBoundaryOffset) {
979
- index = i;
980
- }
981
- else {
982
- break;
983
- }
984
- }
985
- return index;
1006
+ return Math.max(0, this.getSegmentIndexForOffset(this.seekSegments, this.currentBoundaryOffset));
986
1007
  }
987
1008
  async speakWithPlan(segments, runId, options) {
988
1009
  if (!this.provider || segments.length === 0)
@@ -1022,12 +1043,21 @@ export class TTSService {
1022
1043
  if (runId !== this.speakRunId)
1023
1044
  return;
1024
1045
  this.currentBoundaryOffset = segment.startOffset;
1046
+ const highlightSegment = () => {
1047
+ this.highlightSentenceSegment(segment.startOffset, segment.text);
1048
+ };
1025
1049
  if (shouldTrackSentenceProgress) {
1026
- this.runWhenPlaybackStarts(runId, () => {
1027
- this.highlightSentenceSegment(segment.startOffset, segment.text);
1028
- });
1050
+ this.runWhenPlaybackStarts(runId, highlightSegment);
1051
+ }
1052
+ this.wordlessPartHighlight = highlightSegment;
1053
+ try {
1054
+ await this.speakWithinLimit(segment.text, runId);
1055
+ }
1056
+ finally {
1057
+ if (this.wordlessPartHighlight === highlightSegment) {
1058
+ this.wordlessPartHighlight = null;
1059
+ }
1029
1060
  }
1030
- await this.speakWithinLimit(segment.text, runId);
1031
1061
  const pauseMs = segment.pauseMsAfter ?? 0;
1032
1062
  if (pauseMs > 0 && runId === this.speakRunId) {
1033
1063
  await this.waitStructuralPause(pauseMs);
@@ -1100,7 +1130,8 @@ export class TTSService {
1100
1130
  const pendingReadiness = this.pendingReadiness();
1101
1131
  // A speak waiting on its provider is already the current run, so a pause
1102
1132
  // or stop issued meanwhile applies to it.
1103
- const waitingRunId = pendingReadiness ? this.beginRun() : null;
1133
+ const owner = options.ownerId ?? null;
1134
+ const waitingRunId = pendingReadiness ? this.beginRun(owner) : null;
1104
1135
  if (pendingReadiness) {
1105
1136
  try {
1106
1137
  await pendingReadiness;
@@ -1128,7 +1159,25 @@ export class TTSService {
1128
1159
  this.setState(PlaybackState.IDLE);
1129
1160
  return;
1130
1161
  }
1131
- await this.speakContent(content.text, content.options, waitingRunId ?? this.beginRun());
1162
+ const runId = waitingRunId ?? this.beginRun(owner);
1163
+ if (options.rate !== undefined) {
1164
+ await this.applyRunRate(options.rate);
1165
+ if (runId !== this.speakRunId)
1166
+ return;
1167
+ }
1168
+ await this.speakContent(content.text, content.options, runId);
1169
+ }
1170
+ /**
1171
+ * Sets the rate a run starts at, after any rate write in flight. The run has
1172
+ * no playback yet, so nothing restarts.
1173
+ */
1174
+ async applyRunRate(rate) {
1175
+ await this.playbackRateWriteQueue;
1176
+ const nextRate = this.normalizePlaybackRate(rate);
1177
+ const currentRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
1178
+ if (nextRate === currentRate)
1179
+ return;
1180
+ await this.updateSettings({ rate: nextRate });
1132
1181
  }
1133
1182
  /**
1134
1183
  * Suppression is read from the live ancestors of what is read, because the
@@ -1143,22 +1192,38 @@ export class TTSService {
1143
1192
  resolveElementTarget(root, options, language) {
1144
1193
  if (this.isSuppressedTarget(root))
1145
1194
  return null;
1146
- // Math-aware, so an element holding only an equation still reads.
1195
+ const contentOptions = {
1196
+ catalogId: options.catalogId,
1197
+ catalogContext: options.catalogContext,
1198
+ language: language.locale,
1199
+ contentLanguage: language.contentLanguage,
1200
+ contentElement: root,
1201
+ };
1202
+ // Math-aware, so an element holding only an equation still reads; a spoken
1203
+ // card reads for content with no text, an image among them.
1147
1204
  const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
1148
- if (!text) {
1205
+ if (!text && !this.holdsSpokenCard(root, contentOptions)) {
1149
1206
  console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
1150
1207
  return null;
1151
1208
  }
1152
- return {
1153
- text,
1154
- options: {
1155
- catalogId: options.catalogId,
1156
- catalogContext: options.catalogContext,
1157
- language: language.locale,
1158
- contentLanguage: language.contentLanguage,
1159
- contentElement: root,
1160
- },
1161
- };
1209
+ return { text, options: contentOptions };
1210
+ }
1211
+ /** Whether the root's card, or a card of a node inside it, would be read. */
1212
+ holdsSpokenCard(root, options) {
1213
+ if (!this.catalogResolver)
1214
+ return false;
1215
+ if (options.catalogId) {
1216
+ const card = this.catalogResolver.getAlternative(options.catalogId, {
1217
+ type: "spoken",
1218
+ language: options.language,
1219
+ useFallback: true,
1220
+ context: options.catalogContext,
1221
+ form: "content",
1222
+ });
1223
+ if (card?.content !== undefined)
1224
+ return true;
1225
+ }
1226
+ return this.collectCatalogSpeechChunks(root, options).some((chunk) => chunk.sourceElement);
1162
1227
  }
1163
1228
  resolveRangeTarget(range, options, language) {
1164
1229
  if (this.isSuppressedTarget(range.commonAncestorContainer))
@@ -1237,27 +1302,35 @@ export class TTSService {
1237
1302
  this.logResolvedSpeechContent({
1238
1303
  contentToSpeak,
1239
1304
  speechSource,
1240
- catalogId: options?.catalogId,
1305
+ catalogId: options.catalogId,
1241
1306
  });
1242
1307
  this.initializeSpeakTracking(highlightText, options);
1243
1308
  const highlightMode = speechMatchesVisibleText
1244
1309
  ? this.resolveHighlightMode()
1245
1310
  : "sentence";
1246
1311
  this.activeHighlightMode = highlightMode;
1247
- this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
1312
+ const wordBoundaryOffset = options.wordBoundaryOffset || 0;
1313
+ this.activeWordBoundaryOffset = wordBoundaryOffset;
1248
1314
  const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
1249
1315
  const shouldUsePlan = !!this.currentContentElement &&
1250
1316
  !usedCatalogSpoken &&
1251
1317
  !hasExplicitBreaks &&
1252
1318
  speechMatchesVisibleText;
1253
1319
  this.playbackChunks = speechChunks?.length ? speechChunks : [];
1254
- this.seekSegments = this.playbackChunks.length
1255
- ? this.createSeekSegmentsFromSpeechChunks(this.playbackChunks)
1256
- : hasExplicitBreaks || !speechMatchesVisibleText
1257
- ? []
1258
- : shouldUsePlan && this.currentContentElement
1259
- ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0, options?.language)
1260
- : this.createSeekSegmentsFromText(highlightText);
1320
+ if (this.playbackChunks.length) {
1321
+ this.seekSegments = this.createSeekSegmentsFromSpeechChunks(this.playbackChunks);
1322
+ }
1323
+ else if (hasExplicitBreaks || !speechMatchesVisibleText) {
1324
+ // Authored SSML breaks, and speech that differs from the visible text,
1325
+ // leave no visible-text offsets to seek to.
1326
+ this.seekSegments = [];
1327
+ }
1328
+ else if (shouldUsePlan && this.currentContentElement) {
1329
+ this.seekSegments = this.createSpeechPlan(this.currentContentElement, normalizedText, wordBoundaryOffset, options.language);
1330
+ }
1331
+ else {
1332
+ this.seekSegments = this.createSeekSegmentsFromText(highlightText);
1333
+ }
1261
1334
  this.sentenceHighlightSegments = hasExplicitBreaks
1262
1335
  ? []
1263
1336
  : this.createSentenceHighlightSegments({
@@ -1276,7 +1349,7 @@ export class TTSService {
1276
1349
  if (speechMatchesVisibleText) {
1277
1350
  this.configureWordBoundaryHighlighting({
1278
1351
  highlightMode,
1279
- wordBoundaryOffset: options?.wordBoundaryOffset || 0,
1352
+ wordBoundaryOffset,
1280
1353
  });
1281
1354
  }
1282
1355
  else {
@@ -1421,6 +1494,9 @@ export class TTSService {
1421
1494
  };
1422
1495
  }
1423
1496
  collectCatalogSpeechChunks(root, options) {
1497
+ const catalogResolver = this.catalogResolver;
1498
+ if (!catalogResolver)
1499
+ return [];
1424
1500
  const chunks = [];
1425
1501
  let textBuffer = "";
1426
1502
  const flushTextBuffer = () => {
@@ -1457,14 +1533,14 @@ export class TTSService {
1457
1533
  // Two lookups rather than one, because `form` is a preference: asking for
1458
1534
  // the payload form happily returns a script card when no recording
1459
1535
  // exists, so the answer has to be checked rather than assumed.
1460
- const audioCard = this.catalogResolver.getAlternative(catalogIdRef, {
1536
+ const audioCard = catalogResolver.getAlternative(catalogIdRef, {
1461
1537
  ...lookup,
1462
1538
  form: "payload",
1463
1539
  });
1464
1540
  const audio = audioCard?.payload
1465
1541
  ? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
1466
1542
  : undefined;
1467
- const scriptCard = this.catalogResolver.getAlternative(catalogIdRef, {
1543
+ const scriptCard = catalogResolver.getAlternative(catalogIdRef, {
1468
1544
  ...lookup,
1469
1545
  form: "content",
1470
1546
  });
@@ -1518,47 +1594,45 @@ export class TTSService {
1518
1594
  const visibleText = collectedVisible.visibleText ||
1519
1595
  normalizeTextForSpeech(flatTextContent(element));
1520
1596
  const regionElement = resolveReadableRegion(element, root);
1521
- // The script chunk, when there is a script. Built exactly as before,
1522
- // and it doubles as the recording's fallback: word-level alignment is
1523
- // only meaningful for the synthesized path, which is the one that runs
1524
- // if the audio cannot play.
1525
- const scriptChunk = catalog.script !== undefined
1526
- ? (() => {
1527
- const script = catalog.script;
1528
- const alignment = createCatalogSpanAlignment({
1597
+ // The script chunk doubles as the recording's fallback: word-level
1598
+ // alignment is meaningful only on the synthesized path, which is the one
1599
+ // that runs if the audio cannot play.
1600
+ let scriptChunk = null;
1601
+ if (catalog.script !== undefined) {
1602
+ const script = catalog.script;
1603
+ const alignment = createCatalogSpanAlignment({
1604
+ speechText: script,
1605
+ visibleText,
1606
+ });
1607
+ const mathElement = getSingleMathElementForAlignment(element, visibleText);
1608
+ const mathAlignment = mathElement
1609
+ ? createMathAwareAlignment({
1610
+ mathElement,
1529
1611
  speechText: script,
1530
- visibleText,
1531
- });
1532
- const mathElement = getSingleMathElementForAlignment(element, visibleText);
1533
- const mathAlignment = mathElement
1534
- ? createMathAwareAlignment({
1535
- mathElement,
1612
+ })
1613
+ : undefined;
1614
+ const mathAlignments = mathAlignment
1615
+ ? undefined
1616
+ : getMathElementsForAlignment(element).map((candidate) => ({
1617
+ element: candidate,
1618
+ alignment: createMathAwareAlignment({
1619
+ mathElement: candidate,
1536
1620
  speechText: script,
1537
- })
1538
- : undefined;
1539
- const mathAlignments = mathAlignment
1540
- ? undefined
1541
- : getMathElementsForAlignment(element).map((candidate) => ({
1542
- element: candidate,
1543
- alignment: createMathAwareAlignment({
1544
- mathElement: candidate,
1545
- speechText: script,
1546
- }),
1547
- }));
1548
- return {
1549
- speechText: script,
1550
- visibleText,
1551
- sourceElement: element,
1552
- regionElement,
1553
- speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
1554
- playbackMode: alignment.playbackMode,
1555
- alignment,
1556
- mathAlignment,
1557
- mathAlignments,
1558
- visibleMap: collectedVisible.map,
1559
- };
1560
- })()
1561
- : null;
1621
+ }),
1622
+ }));
1623
+ scriptChunk = {
1624
+ speechText: script,
1625
+ visibleText,
1626
+ sourceElement: element,
1627
+ regionElement,
1628
+ speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
1629
+ playbackMode: alignment.playbackMode,
1630
+ alignment,
1631
+ mathAlignment,
1632
+ mathAlignments,
1633
+ visibleMap: collectedVisible.map,
1634
+ };
1635
+ }
1562
1636
  if (catalog.audio) {
1563
1637
  chunks.push(scriptChunk
1564
1638
  ? {
@@ -1743,7 +1817,7 @@ export class TTSService {
1743
1817
  this.lastRenderedRegionTarget = null;
1744
1818
  if (chunk.regionRange) {
1745
1819
  const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
1746
- this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1820
+ this.highlightCoordinator.clearTTSWord();
1747
1821
  this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
1748
1822
  return;
1749
1823
  }
@@ -1751,7 +1825,7 @@ export class TTSService {
1751
1825
  return;
1752
1826
  const range = document.createRange();
1753
1827
  range.selectNodeContents(element);
1754
- this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1828
+ this.highlightCoordinator.clearTTSWord();
1755
1829
  this.paintTTSSentenceRanges([range]);
1756
1830
  }
1757
1831
  catch {
@@ -1799,7 +1873,7 @@ export class TTSService {
1799
1873
  // Clear the word layer so the previously spoken word does not stay
1800
1874
  // highlighted. (The "hold last token" behavior returns a non-null
1801
1875
  // target, so it does not reach here.)
1802
- this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1876
+ this.highlightCoordinator.clearTTSWord();
1803
1877
  return;
1804
1878
  }
1805
1879
  if (target.type === "text-range") {
@@ -1847,17 +1921,18 @@ export class TTSService {
1847
1921
  await this.speakCatalogChunkOnce(chunk, runId);
1848
1922
  }
1849
1923
  catch (error) {
1850
- // Speak-time fallback: if an SSML math chunk is rejected by the
1851
- // provider, retry once with its precomputed plain-text variant (same
1852
- // anchors, plain alignment). The fallback chunk carries no further
1853
- // fallback, so this cannot recurse.
1924
+ // A chunk with a plainFallback retries once with it when speaking fails:
1925
+ // a rejected SSML math chunk falls back to its plain-text variant, a
1926
+ // recording that cannot play to its script. The fallback carries no
1927
+ // fallback of its own, so this cannot recurse.
1854
1928
  if (chunk.plainFallback && runId === this.speakRunId) {
1855
1929
  logger.debug("SSML chunk speak failed; retrying plain text", {
1856
1930
  message: error instanceof Error ? error.message : String(error),
1857
1931
  });
1858
- // A recorded-first run can own the start barrier even when its fallback
1859
- // provider has no formal start signal. Drop the failed recording's queued
1860
- // highlight and resume that non-start-aware provider's immediate-start lifecycle.
1932
+ // A run that began with a recording holds the start barrier for the
1933
+ // recording's start signal. A provider with no start signal of its own
1934
+ // would never release it, so drop the recording's queued highlight and
1935
+ // mark the run playing now.
1861
1936
  if (this.playbackStartDeferredRunId === runId &&
1862
1937
  this.provider &&
1863
1938
  !("onPlaybackStart" in this.provider)) {
@@ -2001,6 +2076,9 @@ export class TTSService {
2001
2076
  mathTokenHighlighting,
2002
2077
  })
2003
2078
  : null;
2079
+ const providerOffsetSpace = !pipelineChunk || pipelineChunk.offsetSpace === "unsupported"
2080
+ ? "unknown"
2081
+ : pipelineChunk.offsetSpace;
2004
2082
  this.runWhenPlaybackStarts(runId, () => {
2005
2083
  if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
2006
2084
  this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
@@ -2037,21 +2115,15 @@ export class TTSService {
2037
2115
  const provider = this.provider;
2038
2116
  const previousOnWordBoundary = provider.onWordBoundary;
2039
2117
  const installedHandler = (word, position, length) => {
2040
- if (runId !== this.speakRunId)
2041
- return;
2042
- if (highlightPlan && pipelineChunk) {
2043
- const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
2044
- ? "unknown"
2045
- : pipelineChunk.offsetSpace;
2046
- this.renderHighlightDecision(highlightPlan.resolveBoundary({
2047
- chunkId: pipelineChunk.id,
2048
- word,
2049
- position,
2050
- length,
2051
- providerOffsetSpace,
2052
- }));
2118
+ if (runId !== this.speakRunId || !highlightPlan || !pipelineChunk)
2053
2119
  return;
2054
- }
2120
+ this.renderHighlightDecision(highlightPlan.resolveBoundary({
2121
+ chunkId: pipelineChunk.id,
2122
+ word,
2123
+ position,
2124
+ length,
2125
+ providerOffsetSpace,
2126
+ }));
2055
2127
  };
2056
2128
  provider.onWordBoundary = installedHandler;
2057
2129
  try {
@@ -2080,16 +2152,12 @@ export class TTSService {
2080
2152
  }
2081
2153
  return;
2082
2154
  }
2083
- if (args.shouldUsePlan && this.currentContentElement) {
2084
- const segments = this.seekSegments;
2085
- if (segments.length > 0) {
2086
- await this.speakWithPlan(segments, args.runId, {
2087
- highlightMode: args.highlightMode,
2088
- });
2089
- }
2090
- else {
2091
- await this.speakWithinLimit(args.contentToSpeak, args.runId);
2092
- }
2155
+ if (args.shouldUsePlan &&
2156
+ this.currentContentElement &&
2157
+ this.seekSegments.length > 0) {
2158
+ await this.speakWithPlan(this.seekSegments, args.runId, {
2159
+ highlightMode: args.highlightMode,
2160
+ });
2093
2161
  return;
2094
2162
  }
2095
2163
  await this.speakWithinLimit(args.contentToSpeak, args.runId);
@@ -2111,6 +2179,7 @@ export class TTSService {
2111
2179
  this.playbackChunks = [];
2112
2180
  this.sentenceHighlightSegments = [];
2113
2181
  this.activeSentenceStartOffset = null;
2182
+ this.wordlessPartHighlight = null;
2114
2183
  }
2115
2184
  /**
2116
2185
  * The selected part of `root`'s normalized visible text and where it starts, or
@@ -2171,7 +2240,7 @@ export class TTSService {
2171
2240
  normalizePlaybackRate(rate) {
2172
2241
  if (!Number.isFinite(rate))
2173
2242
  return 1;
2174
- return Math.max(0.25, Math.min(4, rate));
2243
+ return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
2175
2244
  }
2176
2245
  async restartFromSeekIndex(targetIndex) {
2177
2246
  if (!this.provider || this.seekSegments.length === 0)
@@ -2233,8 +2302,11 @@ export class TTSService {
2233
2302
  this.clearHighlightsAndTracking();
2234
2303
  }
2235
2304
  catch (error) {
2236
- if (runId !== this.speakRunId)
2305
+ // A superseded run's failure is the abort that superseded it.
2306
+ if (runId !== this.speakRunId) {
2307
+ logger.debug("Superseded seek restart failed:", error);
2237
2308
  return;
2309
+ }
2238
2310
  this.lastError = error instanceof Error ? error.message : String(error);
2239
2311
  this.setState(PlaybackState.ERROR);
2240
2312
  this.clearHighlightsAndTracking();
@@ -2332,32 +2404,19 @@ export class TTSService {
2332
2404
  await this.seekBy(-step);
2333
2405
  }
2334
2406
  /**
2335
- * Stop playback
2407
+ * Stop playback and release the run owner. A run that ends on its own keeps
2408
+ * its owner, so the owning control can replay it.
2336
2409
  */
2337
2410
  stop() {
2338
2411
  this.speakRunId += 1;
2339
2412
  this.abandonRun();
2340
- if (this.provider) {
2341
- this.provider.onWordBoundary = undefined;
2342
- this.provider.stop();
2343
- }
2344
- this.setState(PlaybackState.IDLE);
2413
+ this.provider?.stop();
2414
+ this.clearHighlightsAndTracking();
2345
2415
  this.currentText = null;
2346
- // Clear highlights
2347
- if (this.highlightCoordinator) {
2348
- this.highlightCoordinator.clearTTS();
2349
- }
2350
- // Clear tracking
2351
- this.lastRenderedRegionTarget = null;
2352
- this.currentContentElement = null;
2353
- this.normalizedToDOM.clear();
2354
- this.currentBoundaryOffset = 0;
2355
- this.activeWordBoundaryOffset = 0;
2356
- this.seekSegments = [];
2357
- this.playbackChunks = [];
2358
- this.sentenceHighlightSegments = [];
2359
- this.activeSentenceStartOffset = null;
2360
- this.activePlaybackRate = null;
2416
+ // Announced even from idle, so a listener sees the owner released.
2417
+ const releasedOwner = this.runOwner !== null;
2418
+ this.runOwner = null;
2419
+ this.setState(PlaybackState.IDLE, releasedOwner);
2361
2420
  }
2362
2421
  /**
2363
2422
  * Stop playback and drop the provider, whose owner destroys it. A later speak
@@ -2389,50 +2448,14 @@ export class TTSService {
2389
2448
  this.catalogResolver = null;
2390
2449
  this.telemetryReporter = null;
2391
2450
  }
2392
- /**
2393
- * Request UI-level TTS controls to hand off/deactivate.
2394
- *
2395
- * This is intentionally separate from playback controls so hosts can orchestrate
2396
- * control handoff explicitly (for example: stop playback, then dismiss controls).
2397
- */
2398
- requestControlHandoff() {
2399
- if (typeof window === "undefined")
2400
- return;
2401
- const detail = { source: "host" };
2402
- window.dispatchEvent(new CustomEvent(PIE_TTS_CONTROL_HANDOFF_EVENT, {
2403
- detail,
2404
- }));
2405
- }
2406
- /**
2407
- * Check if currently playing
2408
- */
2409
- isPlaying() {
2410
- return this.state === PlaybackState.PLAYING;
2411
- }
2412
- /**
2413
- * Check if paused
2414
- */
2415
- isPaused() {
2416
- return this.state === PlaybackState.PAUSED;
2417
- }
2418
2451
  /**
2419
2452
  * Get current state
2420
2453
  */
2421
2454
  getState() {
2422
2455
  return this.state;
2423
2456
  }
2424
- /**
2425
- * Get current text being spoken
2426
- */
2427
- getCurrentText() {
2428
- return this.currentText;
2429
- }
2430
- /**
2431
- * Get the last error message
2432
- * Returns null if no error has occurred
2433
- */
2434
- getLastError() {
2435
- return this.lastError;
2457
+ getRunOwner() {
2458
+ return this.runOwner;
2436
2459
  }
2437
2460
  /**
2438
2461
  * Subscribe to playback state changes. The returned function unsubscribes;
@@ -2446,17 +2469,19 @@ export class TTSService {
2446
2469
  };
2447
2470
  }
2448
2471
  /**
2449
- * Set state and notify listeners
2472
+ * Set state and notify listeners. `announceUnchanged` notifies listeners even
2473
+ * when the state does not change; telemetry fires only on a change.
2450
2474
  */
2451
- setState(newState) {
2452
- if (this.state === newState)
2453
- return;
2475
+ setState(newState, announceUnchanged = false) {
2454
2476
  const previousState = this.state;
2477
+ if (previousState === newState && !announceUnchanged)
2478
+ return;
2455
2479
  this.state = newState;
2456
- // Notify all listeners
2457
2480
  for (const listener of [...this.listeners]) {
2458
2481
  listener(newState);
2459
2482
  }
2483
+ if (previousState === newState)
2484
+ return;
2460
2485
  void this.emitTelemetry("pie-tool-playback-state-changed", {
2461
2486
  toolId: "textToSpeech",
2462
2487
  previousState,