@pie-players/pie-assessment-toolkit 0.3.44 → 0.3.45

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 (191) hide show
  1. package/README.md +54 -69
  2. package/dist/components/ItemToolBar.custom-element.js +479 -324
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +19498 -3590
  4. package/dist/components/SectionToolBar.custom-element.js +761 -507
  5. package/dist/index.d.ts +3 -6
  6. package/dist/index.js +1 -3
  7. package/dist/index.js.map +1 -1
  8. package/dist/policy/core/ToolPolicyEngine.d.ts +1 -2
  9. package/dist/policy/core/ToolPolicyEngine.js +1 -2
  10. package/dist/policy/core/ToolPolicyEngine.js.map +1 -1
  11. package/dist/policy/core/pnp-policy-inputs.d.ts +2 -3
  12. package/dist/policy/core/pnp-policy-inputs.js +2 -3
  13. package/dist/policy/core/pnp-policy-inputs.js.map +1 -1
  14. package/dist/policy/core/provenance.d.ts +7 -34
  15. package/dist/policy/core/provenance.js +4 -21
  16. package/dist/policy/core/provenance.js.map +1 -1
  17. package/dist/policy/sources/PnpPolicySource.d.ts +6 -19
  18. package/dist/policy/sources/PnpPolicySource.js +6 -19
  19. package/dist/policy/sources/PnpPolicySource.js.map +1 -1
  20. package/dist/runtime/SectionRuntimeEngine.d.ts +13 -28
  21. package/dist/runtime/SectionRuntimeEngine.js +13 -27
  22. package/dist/runtime/SectionRuntimeEngine.js.map +1 -1
  23. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +2 -2
  24. package/dist/runtime/adapter/SectionEngineAdapter.js +2 -2
  25. package/dist/runtime/adapter/SectionEngineAdapter.js.map +1 -1
  26. package/dist/runtime/adapter/coordinator-bridge.d.ts +1 -1
  27. package/dist/runtime/adapter/coordinator-bridge.js +1 -1
  28. package/dist/runtime/adapter/coordinator-bridge.js.map +1 -1
  29. package/dist/runtime/adapter/dom-event-bridge.d.ts +1 -1
  30. package/dist/runtime/adapter/dom-event-bridge.js +1 -1
  31. package/dist/runtime/adapter/dom-event-bridge.js.map +1 -1
  32. package/dist/runtime/catalog-registration.d.ts +12 -0
  33. package/dist/runtime/catalog-registration.js +54 -0
  34. package/dist/runtime/catalog-registration.js.map +1 -0
  35. package/dist/runtime/core/engine-output.d.ts +2 -2
  36. package/dist/runtime/core/engine-output.js +2 -2
  37. package/dist/runtime/core/engine-output.js.map +1 -1
  38. package/dist/runtime/core/engine-readiness.d.ts +2 -2
  39. package/dist/runtime/core/engine-readiness.js.map +1 -1
  40. package/dist/runtime/core/engine-resolver.d.ts +13 -41
  41. package/dist/runtime/core/engine-resolver.js +19 -76
  42. package/dist/runtime/core/engine-resolver.js.map +1 -1
  43. package/dist/runtime/core/engine-transition.d.ts +1 -1
  44. package/dist/runtime/core/engine-transition.js +1 -1
  45. package/dist/runtime/core/engine-transition.js.map +1 -1
  46. package/dist/runtime/engine.d.ts +1 -1
  47. package/dist/runtime/engine.js.map +1 -1
  48. package/dist/runtime/section-runtime-engine-host-context.d.ts +22 -15
  49. package/dist/runtime/section-runtime-engine-host-context.js +10 -10
  50. package/dist/runtime/section-runtime-engine-host-context.js.map +1 -1
  51. package/dist/runtime/stage-emit-gate.d.ts +4 -4
  52. package/dist/runtime/stage-emit-gate.js +3 -3
  53. package/dist/runtime/stage-emit-gate.js.map +1 -1
  54. package/dist/services/AccessibilityCatalogResolver.d.ts +23 -0
  55. package/dist/services/AccessibilityCatalogResolver.js +200 -28
  56. package/dist/services/AccessibilityCatalogResolver.js.map +1 -1
  57. package/dist/services/HighlightCoordinator.d.ts +25 -0
  58. package/dist/services/HighlightCoordinator.js +125 -0
  59. package/dist/services/HighlightCoordinator.js.map +1 -1
  60. package/dist/services/SSMLExtractor.d.ts +16 -1
  61. package/dist/services/SSMLExtractor.js +86 -7
  62. package/dist/services/SSMLExtractor.js.map +1 -1
  63. package/dist/services/TTSService.d.ts +30 -4
  64. package/dist/services/TTSService.js +652 -159
  65. package/dist/services/TTSService.js.map +1 -1
  66. package/dist/services/ToolRegistry.d.ts +2 -2
  67. package/dist/services/ToolRegistry.js +1 -1
  68. package/dist/services/ToolRegistry.js.map +1 -1
  69. package/dist/services/ToolkitCoordinator.d.ts +16 -73
  70. package/dist/services/ToolkitCoordinator.js +34 -113
  71. package/dist/services/ToolkitCoordinator.js.map +1 -1
  72. package/dist/services/interfaces.d.ts +20 -2
  73. package/dist/services/interfaces.js.map +1 -1
  74. package/dist/services/tool-config-validation.d.ts +1 -1
  75. package/dist/services/tool-config-validation.js +7 -7
  76. package/dist/services/tool-config-validation.js.map +1 -1
  77. package/dist/services/tool-instance-id.js +1 -1
  78. package/dist/services/tool-instance-id.js.map +1 -1
  79. package/dist/services/tool-providers/TTSToolProvider.js +3 -3
  80. package/dist/services/tool-providers/TTSToolProvider.js.map +1 -1
  81. package/dist/services/tools-config-normalizer.d.ts +13 -2
  82. package/dist/services/tools-config-normalizer.js +3 -8
  83. package/dist/services/tools-config-normalizer.js.map +1 -1
  84. package/dist/services/tts/browser-provider.js +2 -0
  85. package/dist/services/tts/browser-provider.js.map +1 -1
  86. package/dist/services/tts/catalog-span-alignment.d.ts +39 -0
  87. package/dist/services/tts/catalog-span-alignment.js +300 -0
  88. package/dist/services/tts/catalog-span-alignment.js.map +1 -0
  89. package/dist/services/tts/generated-speech/assemble-plan.d.ts +33 -0
  90. package/dist/services/tts/generated-speech/assemble-plan.js +111 -0
  91. package/dist/services/tts/generated-speech/assemble-plan.js.map +1 -0
  92. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +25 -0
  93. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +66 -0
  94. package/dist/services/tts/generated-speech/dom/build-dom-plan.js.map +1 -0
  95. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +19 -0
  96. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.js +71 -0
  97. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.js.map +1 -0
  98. package/dist/services/tts/generated-speech/dom/types.d.ts +37 -0
  99. package/dist/services/tts/generated-speech/dom/types.js +2 -0
  100. package/dist/services/tts/generated-speech/dom/types.js.map +1 -0
  101. package/dist/services/tts/generated-speech/index.d.ts +17 -0
  102. package/dist/services/tts/generated-speech/index.js +17 -0
  103. package/dist/services/tts/generated-speech/index.js.map +1 -0
  104. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +15 -0
  105. package/dist/services/tts/generated-speech/math-speech-cache.js +78 -0
  106. package/dist/services/tts/generated-speech/math-speech-cache.js.map +1 -0
  107. package/dist/services/tts/generated-speech/types.d.ts +104 -0
  108. package/dist/services/tts/generated-speech/types.js +18 -0
  109. package/dist/services/tts/generated-speech/types.js.map +1 -0
  110. package/dist/services/tts/highlight-pipeline/boundary-normalizer.d.ts +2 -0
  111. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +106 -0
  112. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js.map +1 -0
  113. package/dist/services/tts/highlight-pipeline/create-highlight-plan.d.ts +6 -0
  114. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +217 -0
  115. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js.map +1 -0
  116. package/dist/services/tts/highlight-pipeline/highlight-policy.d.ts +2 -0
  117. package/dist/services/tts/highlight-pipeline/highlight-policy.js +32 -0
  118. package/dist/services/tts/highlight-pipeline/highlight-policy.js.map +1 -0
  119. package/dist/services/tts/highlight-pipeline/index.d.ts +9 -0
  120. package/dist/services/tts/highlight-pipeline/index.js +9 -0
  121. package/dist/services/tts/highlight-pipeline/index.js.map +1 -0
  122. package/dist/services/tts/highlight-pipeline/math-semantic-resolver.d.ts +3 -0
  123. package/dist/services/tts/highlight-pipeline/math-semantic-resolver.js +32 -0
  124. package/dist/services/tts/highlight-pipeline/math-semantic-resolver.js.map +1 -0
  125. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.d.ts +29 -0
  126. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +44 -0
  127. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js.map +1 -0
  128. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.d.ts +2 -0
  129. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +20 -0
  130. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js.map +1 -0
  131. package/dist/services/tts/highlight-pipeline/readable-region.d.ts +1 -0
  132. package/dist/services/tts/highlight-pipeline/readable-region.js +19 -0
  133. package/dist/services/tts/highlight-pipeline/readable-region.js.map +1 -0
  134. package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.d.ts +6 -0
  135. package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.js +198 -0
  136. package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.js.map +1 -0
  137. package/dist/services/tts/highlight-pipeline/types.d.ts +72 -0
  138. package/dist/services/tts/highlight-pipeline/types.js +2 -0
  139. package/dist/services/tts/highlight-pipeline/types.js.map +1 -0
  140. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +2 -0
  141. package/dist/services/tts/highlight-pipeline/visible-map-range.js +22 -0
  142. package/dist/services/tts/highlight-pipeline/visible-map-range.js.map +1 -0
  143. package/dist/services/tts/math-alignment/index.d.ts +30 -0
  144. package/dist/services/tts/math-alignment/index.js +66 -0
  145. package/dist/services/tts/math-alignment/index.js.map +1 -0
  146. package/dist/services/tts/math-alignment/mathml-tokenizer.d.ts +2 -0
  147. package/dist/services/tts/math-alignment/mathml-tokenizer.js +317 -0
  148. package/dist/services/tts/math-alignment/mathml-tokenizer.js.map +1 -0
  149. package/dist/services/tts/math-alignment/range-resolver.d.ts +10 -0
  150. package/dist/services/tts/math-alignment/range-resolver.js +13 -0
  151. package/dist/services/tts/math-alignment/range-resolver.js.map +1 -0
  152. package/dist/services/tts/math-alignment/sequence-aligner.d.ts +7 -0
  153. package/dist/services/tts/math-alignment/sequence-aligner.js +119 -0
  154. package/dist/services/tts/math-alignment/sequence-aligner.js.map +1 -0
  155. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +31 -0
  156. package/dist/services/tts/math-alignment/speech-tokenizer.js +143 -0
  157. package/dist/services/tts/math-alignment/speech-tokenizer.js.map +1 -0
  158. package/dist/services/tts/math-alignment/types.d.ts +58 -0
  159. package/dist/services/tts/math-alignment/types.js +2 -0
  160. package/dist/services/tts/math-alignment/types.js.map +1 -0
  161. package/dist/services/tts/math-aware-text-processing.d.ts +18 -0
  162. package/dist/services/tts/math-aware-text-processing.js +348 -0
  163. package/dist/services/tts/math-aware-text-processing.js.map +1 -0
  164. package/dist/services/tts/math-speech.d.ts +44 -0
  165. package/dist/services/tts/math-speech.js +147 -0
  166. package/dist/services/tts/math-speech.js.map +1 -0
  167. package/dist/services/tts/mathml-sanitization.d.ts +1 -0
  168. package/dist/services/tts/mathml-sanitization.js +170 -0
  169. package/dist/services/tts/mathml-sanitization.js.map +1 -0
  170. package/dist/services/tts/ssml/spoken-text.d.ts +33 -0
  171. package/dist/services/tts/ssml/spoken-text.js +159 -0
  172. package/dist/services/tts/ssml/spoken-text.js.map +1 -0
  173. package/dist/services/tts/text-processing.d.ts +3 -0
  174. package/dist/services/tts/text-processing.js +30 -4
  175. package/dist/services/tts/text-processing.js.map +1 -1
  176. package/dist/services/tts-runtime-config.d.ts +17 -1
  177. package/dist/services/tts-runtime-config.js +11 -1
  178. package/dist/services/tts-runtime-config.js.map +1 -1
  179. package/dist/tools/registrations/accessibility-tools.js +0 -1
  180. package/dist/tools/registrations/accessibility-tools.js.map +1 -1
  181. package/dist/tools/registrations/calculator.js +41 -2
  182. package/dist/tools/registrations/calculator.js.map +1 -1
  183. package/dist/tools/tool-tag-map.js +0 -1
  184. package/dist/tools/tool-tag-map.js.map +1 -1
  185. package/package.json +8 -11
  186. package/dist/services/PNPToolResolver.d.ts +0 -280
  187. package/dist/services/PNPToolResolver.js +0 -514
  188. package/dist/services/PNPToolResolver.js.map +0 -1
  189. package/dist/services/pnp-provenance.d.ts +0 -166
  190. package/dist/services/pnp-provenance.js +0 -231
  191. package/dist/services/pnp-provenance.js.map +0 -1
@@ -15,9 +15,16 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
+ import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
18
19
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
19
- import { collectVisibleTextAndMap, extractVisibleText as extractVisibleTextFromDOM, isElementHiddenForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
20
+ import { collectVisibleTextAndMap, isElementHiddenForTTS, isNodeHiddenForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
21
+ import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
22
+ import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
23
+ import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
24
+ import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
25
+ import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
20
26
  import { segmentSentences as segmentTextToSentences, } from "./tts/text-segmentation.js";
27
+ import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
21
28
  import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
22
29
  /**
23
30
  * Playback state
@@ -30,6 +37,30 @@ export var PlaybackState;
30
37
  PlaybackState["PAUSED"] = "paused";
31
38
  PlaybackState["ERROR"] = "error";
32
39
  })(PlaybackState || (PlaybackState = {}));
40
+ const sameRange = (left, right) => left === right ||
41
+ (left.startContainer === right.startContainer &&
42
+ left.startOffset === right.startOffset &&
43
+ left.endContainer === right.endContainer &&
44
+ left.endOffset === right.endOffset);
45
+ const sameRenderableHighlightTarget = (left, right) => {
46
+ if (left === right)
47
+ return true;
48
+ if (!left || !right || left.type !== right.type || left.quality !== right.quality) {
49
+ return false;
50
+ }
51
+ if (left.type === "range" && right.type === "range") {
52
+ return sameRange(left.range, right.range);
53
+ }
54
+ if (left.type === "element" && right.type === "element") {
55
+ return left.element === right.element;
56
+ }
57
+ if (left.type === "text-range" && right.type === "text-range") {
58
+ return (left.node === right.node &&
59
+ left.startOffset === right.startOffset &&
60
+ left.endOffset === right.endOffset);
61
+ }
62
+ return false;
63
+ };
33
64
  /**
34
65
  * TTSService
35
66
  *
@@ -55,8 +86,38 @@ export class TTSService {
55
86
  currentSeekSegmentIndex = 0;
56
87
  activeSentenceStartOffset = null;
57
88
  activeHighlightMode = "word";
89
+ lastRenderedRegionTarget = null;
58
90
  telemetryReporter = null;
91
+ // Memoized SRE resolver for the runtime generated-speech path (PIE-623).
92
+ // Caches DOM-free spoken text keyed by SRE settings and MathML source across
93
+ // utterances within this service instance.
94
+ generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
95
+ // Verbose alignment/highlight tracing is opt-in: it is expensive (large
96
+ // objects, a full text diff per utterance) and noisy in production. Enable
97
+ // with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
98
+ static debugEnabled = (() => {
99
+ try {
100
+ if (typeof process !== "undefined" &&
101
+ process.env?.PIE_TTS_DEBUG === "1") {
102
+ return true;
103
+ }
104
+ }
105
+ catch {
106
+ // `process` is not defined in the browser; fall through to the global.
107
+ }
108
+ return (globalThis.__PIE_TTS_DEBUG__ === true);
109
+ })();
59
110
  constructor() { }
111
+ debugLog(message, detail) {
112
+ if (!TTSService.debugEnabled)
113
+ return;
114
+ if (detail === undefined) {
115
+ console.debug(message);
116
+ }
117
+ else {
118
+ console.debug(message, detail);
119
+ }
120
+ }
60
121
  async emitTelemetry(eventName, payload) {
61
122
  try {
62
123
  await this.telemetryReporter?.(eventName, payload);
@@ -81,8 +142,27 @@ export class TTSService {
81
142
  typeof providerOptions.__pieTelemetry === "function"
82
143
  ? providerOptions.__pieTelemetry
83
144
  : null;
84
- // Initialize provider and get implementation
85
- this.provider = await provider.initialize(config);
145
+ // Initialize provider and get implementation. Browser fallback is only
146
+ // allowed here, before the configured provider has successfully become
147
+ // the active runtime. Per-call playback/synthesis errors must surface
148
+ // instead of silently replacing a user-selected provider and voice.
149
+ try {
150
+ this.provider = await provider.initialize(config);
151
+ }
152
+ catch (error) {
153
+ if (provider.providerId.toLowerCase() === "browser") {
154
+ throw error;
155
+ }
156
+ if (!this.isBrowserSpeechFallbackAvailable()) {
157
+ throw error;
158
+ }
159
+ const switched = await this.switchProviderToBrowser(error, {
160
+ operation: "tts-initialize",
161
+ });
162
+ if (!switched) {
163
+ throw error;
164
+ }
165
+ }
86
166
  }
87
167
  /**
88
168
  * Update TTS settings dynamically without full reinitialization
@@ -97,10 +177,21 @@ export class TTSService {
97
177
  throw new Error("TTSService not initialized. Call initialize() first.");
98
178
  }
99
179
  // If the provider implementation has an updateSettings method, use it
100
- if ("updateSettings" in this.provider &&
101
- typeof this.provider.updateSettings === "function") {
102
- await this.provider.updateSettings(settings);
103
- this.ttsConfig = { ...this.ttsConfig, ...settings };
180
+ const provider = this.provider;
181
+ if (typeof provider.updateSettings === "function") {
182
+ const mergedSettings = {
183
+ ...settings,
184
+ ...(settings.providerOptions
185
+ ? {
186
+ providerOptions: {
187
+ ...(this.ttsConfig.providerOptions || {}),
188
+ ...settings.providerOptions,
189
+ },
190
+ }
191
+ : {}),
192
+ };
193
+ await provider.updateSettings(mergedSettings);
194
+ this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
104
195
  }
105
196
  else {
106
197
  // Fallback: Reinitialize with merged config
@@ -138,7 +229,8 @@ export class TTSService {
138
229
  */
139
230
  getTextProcessingOptions(language) {
140
231
  const providerOptions = (this.ttsConfig.providerOptions || {});
141
- const textNormalization = (providerOptions.textNormalization || {});
232
+ const textNormalization = (providerOptions.textNormalization ||
233
+ {});
142
234
  const mode = textNormalization.boundarySpacingMode;
143
235
  const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
144
236
  ? mode
@@ -149,11 +241,18 @@ export class TTSService {
149
241
  : undefined);
150
242
  return { locale, boundarySpacingMode };
151
243
  }
244
+ getMathSpeechOptions() {
245
+ const providerOptions = (this.ttsConfig.providerOptions || {});
246
+ const mathSpeech = providerOptions.mathSpeech;
247
+ return normalizeSREMathSpeechOptions(mathSpeech);
248
+ }
152
249
  buildPositionMap(element, spokenText, language) {
153
250
  this.normalizedToDOM.clear();
154
251
  const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
155
252
  this.normalizedToDOM = map;
156
- console.log("[TTSService] Text comparison:", {
253
+ if (!TTSService.debugEnabled)
254
+ return;
255
+ this.debugLog("[TTSService] Text comparison:", {
157
256
  spokenLength: spokenText.length,
158
257
  normalizedDomLength: normalizedDomText.length,
159
258
  match: spokenText === normalizedDomText,
@@ -176,7 +275,7 @@ export class TTSService {
176
275
  return null;
177
276
  })(),
178
277
  });
179
- console.log("[TTSService] Position map built:", {
278
+ this.debugLog("[TTSService] Position map built:", {
180
279
  entries: this.normalizedToDOM.size,
181
280
  spokenTextLength: spokenText.length,
182
281
  normalizedDomLength: normalizedDomText.length,
@@ -242,9 +341,6 @@ export class TTSService {
242
341
  isElementHidden(element) {
243
342
  return isElementHiddenForTTS(element);
244
343
  }
245
- extractVisibleText(element, language) {
246
- return extractVisibleTextFromDOM(element, this.getTextProcessingOptions(language));
247
- }
248
344
  getBoundaryStrength(element) {
249
345
  const explicitBreakMs = Number(element.getAttribute("data-tts-break-ms"));
250
346
  if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
@@ -289,7 +385,10 @@ export class TTSService {
289
385
  }
290
386
  const role = (current.getAttribute("role") || "").toLowerCase();
291
387
  const tagName = current.tagName.toUpperCase();
292
- if (tagName.match(/^H[1-6]$/) || role === "heading" || role === "listitem" || tagName === "LI") {
388
+ if (tagName.match(/^H[1-6]$/) ||
389
+ role === "heading" ||
390
+ role === "listitem" ||
391
+ tagName === "LI") {
293
392
  best = current;
294
393
  break;
295
394
  }
@@ -485,7 +584,9 @@ export class TTSService {
485
584
  await providerWithPlan.speakSegments(segments);
486
585
  }
487
586
  finally {
488
- this.provider.onWordBoundary = originalOnWordBoundary;
587
+ if (runId === this.speakRunId && this.provider === providerWithPlan) {
588
+ this.provider.onWordBoundary = originalOnWordBoundary;
589
+ }
489
590
  }
490
591
  return;
491
592
  }
@@ -611,49 +712,64 @@ export class TTSService {
611
712
  throw new Error("TTS service not initialized");
612
713
  }
613
714
  const runId = ++this.speakRunId;
614
- await this.applyLanguageSettings(options);
615
- const { contentToSpeak, normalizedText, usedCatalogSpoken, speechSource } = this.resolveSpeechContent(text, options);
616
- this.logResolvedSpeechContent({
617
- contentToSpeak,
618
- speechSource,
619
- catalogId: options?.catalogId,
620
- });
621
- this.initializeSpeakTracking(contentToSpeak, options);
622
- const highlightMode = options?.highlightModeOverride || this.resolveHighlightMode();
623
- this.activeHighlightMode = highlightMode;
624
- const shouldUsePlan = !!this.currentContentElement &&
625
- !usedCatalogSpoken &&
626
- !this.hasExplicitBreakSemantics(contentToSpeak);
627
- this.seekSegments = this.hasExplicitBreakSemantics(contentToSpeak)
628
- ? []
629
- : shouldUsePlan && this.currentContentElement
630
- ? this.createSpeechPlan(this.currentContentElement, normalizedText)
631
- : this.createSeekSegmentsFromText(contentToSpeak);
632
- this.sentenceHighlightSegments = this.hasExplicitBreakSemantics(contentToSpeak)
633
- ? []
634
- : this.createSentenceHighlightSegments({
715
+ try {
716
+ await this.applyLanguageSettings(options);
717
+ if (runId !== this.speakRunId)
718
+ return;
719
+ const resolvedContent = await this.resolveSpeechContent(text, options);
720
+ if (runId !== this.speakRunId)
721
+ return;
722
+ const { contentToSpeak, speechText, highlightText, normalizedText, usedCatalogSpoken, speechSource, speechMatchesVisibleText, speechChunks, } = resolvedContent;
723
+ this.logResolvedSpeechContent({
635
724
  contentToSpeak,
636
- shouldUsePlan,
637
- playbackSegments: this.seekSegments,
725
+ speechSource,
726
+ catalogId: options?.catalogId,
638
727
  });
639
- this.setState(PlaybackState.LOADING);
640
- this.prepareHighlightsForSpeak({
641
- contentToSpeak,
642
- options,
643
- highlightMode,
644
- shouldUsePlan,
645
- });
646
- try {
647
- this.configureWordBoundaryHighlighting({
728
+ this.initializeSpeakTracking(highlightText, options);
729
+ const highlightMode = options?.highlightModeOverride ||
730
+ (speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
731
+ this.activeHighlightMode = highlightMode;
732
+ const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
733
+ const shouldUsePlan = !!this.currentContentElement &&
734
+ !usedCatalogSpoken &&
735
+ !hasExplicitBreaks &&
736
+ speechMatchesVisibleText;
737
+ this.seekSegments =
738
+ hasExplicitBreaks || !speechMatchesVisibleText
739
+ ? []
740
+ : shouldUsePlan && this.currentContentElement
741
+ ? this.createSpeechPlan(this.currentContentElement, normalizedText)
742
+ : this.createSeekSegmentsFromText(highlightText);
743
+ this.sentenceHighlightSegments = hasExplicitBreaks
744
+ ? []
745
+ : this.createSentenceHighlightSegments({
746
+ contentToSpeak: highlightText,
747
+ shouldUsePlan,
748
+ playbackSegments: this.seekSegments,
749
+ });
750
+ this.setState(PlaybackState.LOADING);
751
+ this.prepareHighlightsForSpeak({
752
+ contentToSpeak: highlightText,
753
+ options,
648
754
  highlightMode,
649
- wordBoundaryOffset: options?.wordBoundaryOffset || 0,
755
+ shouldUsePlan,
650
756
  });
757
+ if (speechMatchesVisibleText) {
758
+ this.configureWordBoundaryHighlighting({
759
+ highlightMode,
760
+ wordBoundaryOffset: options?.wordBoundaryOffset || 0,
761
+ });
762
+ }
763
+ else {
764
+ this.clearWordBoundaryHighlighting();
765
+ }
651
766
  this.setState(PlaybackState.PLAYING);
652
767
  await this.executeSpeakPlayback({
653
768
  shouldUsePlan,
654
769
  runId,
655
770
  highlightMode,
656
- contentToSpeak,
771
+ contentToSpeak: speechText,
772
+ speechChunks,
657
773
  });
658
774
  if (runId !== this.speakRunId)
659
775
  return;
@@ -662,26 +778,6 @@ export class TTSService {
662
778
  }
663
779
  catch (error) {
664
780
  let finalError = error;
665
- if (runId === this.speakRunId) {
666
- try {
667
- const recovered = await this.retryWithBrowserFallback({
668
- error,
669
- runId,
670
- shouldUsePlan,
671
- highlightMode,
672
- contentToSpeak,
673
- wordBoundaryOffset: options?.wordBoundaryOffset || 0,
674
- });
675
- if (recovered) {
676
- this.setState(PlaybackState.IDLE);
677
- this.clearHighlightsAndTracking();
678
- return;
679
- }
680
- }
681
- catch (retryError) {
682
- finalError = retryError;
683
- }
684
- }
685
781
  console.error("TTS error:", finalError);
686
782
  this.lastError =
687
783
  finalError instanceof Error ? finalError.message : String(finalError);
@@ -692,35 +788,26 @@ export class TTSService {
692
788
  throw finalError;
693
789
  }
694
790
  }
695
- shouldAttemptBrowserFallback(error) {
696
- if (!this.provider || !this.currentProvider)
697
- return false;
698
- const providerId = (this.currentProvider.providerId || "").toLowerCase();
699
- if (providerId === "browser")
700
- return false;
701
- const message = (error instanceof Error ? error.message : String(error))
702
- .toLowerCase()
703
- .trim();
704
- // Avoid fallback for expected cancellation/superseded flows.
705
- if (message.includes("superseded by a newer request"))
706
- return false;
707
- if (message.includes("aborterror"))
708
- return false;
709
- if (message.includes("aborted"))
710
- return false;
711
- if (message.includes("cancelled"))
712
- return false;
713
- if (message.includes("canceled"))
714
- return false;
715
- // For non-browser providers, default to browser fallback on runtime errors.
716
- return true;
717
- }
718
- async switchProviderToBrowser(reason) {
791
+ async switchProviderToBrowser(reason, context) {
719
792
  if (!this.currentProvider)
720
793
  return false;
721
794
  const fallbackProvider = new BrowserTTSProvider();
722
795
  const previousProviderId = this.currentProvider.providerId;
723
796
  const browserConfig = { ...this.ttsConfig };
797
+ const operation = context?.operation || "tts-initialize";
798
+ const offendingText = context?.contentToSpeak ?? "";
799
+ const offendingPreview = offendingText
800
+ .replace(/\s+/g, " ")
801
+ .trim()
802
+ .slice(0, 120);
803
+ const offendingDiagnostic = offendingText.length > 0
804
+ ? {
805
+ textLength: offendingText.length,
806
+ textPreview: offendingPreview.length < offendingText.length
807
+ ? `${offendingPreview}…`
808
+ : offendingPreview,
809
+ }
810
+ : {};
724
811
  try {
725
812
  this.provider?.stop();
726
813
  this.currentProvider.destroy();
@@ -729,21 +816,23 @@ export class TTSService {
729
816
  this.ttsConfig = browserConfig;
730
817
  await this.emitTelemetry("pie-tool-runtime-fallback", {
731
818
  toolId: "textToSpeech",
732
- operation: "tts-speak",
819
+ operation,
733
820
  fromProvider: previousProviderId,
734
821
  toProvider: fallbackProvider.providerId,
735
822
  reason: reason instanceof Error ? reason.message : String(reason),
823
+ ...offendingDiagnostic,
736
824
  });
737
- console.warn("[TTSService] Server TTS playback failed; switched to browser fallback", {
825
+ console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
738
826
  fromProvider: previousProviderId,
739
827
  reason,
828
+ ...offendingDiagnostic,
740
829
  });
741
830
  return true;
742
831
  }
743
832
  catch (fallbackError) {
744
833
  await this.emitTelemetry("pie-tool-runtime-fallback-error", {
745
834
  toolId: "textToSpeech",
746
- operation: "tts-speak",
835
+ operation,
747
836
  fromProvider: previousProviderId,
748
837
  toProvider: "browser",
749
838
  errorType: "TTSRuntimeFallbackError",
@@ -760,36 +849,13 @@ export class TTSService {
760
849
  return false;
761
850
  if (!("speechSynthesis" in window))
762
851
  return false;
763
- return typeof globalThis.SpeechSynthesisUtterance === "function";
764
- }
765
- async retryWithBrowserFallback(args) {
766
- if (!this.shouldAttemptBrowserFallback(args.error))
767
- return false;
768
- if (!this.isBrowserSpeechFallbackAvailable())
769
- return false;
770
- const switched = await this.switchProviderToBrowser(args.error);
771
- if (!switched)
772
- return false;
773
- if (args.runId !== this.speakRunId)
774
- return true;
775
- // Rebind highlight callbacks to the newly initialized provider, then retry once.
776
- this.configureWordBoundaryHighlighting({
777
- highlightMode: args.highlightMode,
778
- wordBoundaryOffset: args.wordBoundaryOffset,
779
- });
780
- await this.executeSpeakPlayback({
781
- shouldUsePlan: args.shouldUsePlan,
782
- runId: args.runId,
783
- highlightMode: args.highlightMode,
784
- contentToSpeak: args.contentToSpeak,
785
- });
786
- return true;
852
+ return (typeof globalThis
853
+ .SpeechSynthesisUtterance === "function");
787
854
  }
788
855
  async applyLanguageSettings(options) {
789
856
  if (!options?.language || !this.provider)
790
857
  return;
791
- const providerOptions = (this.ttsConfig.providerOptions ||
792
- {});
858
+ const providerOptions = (this.ttsConfig.providerOptions || {});
793
859
  const textNormalization = (providerOptions.textNormalization ||
794
860
  {});
795
861
  const segmenter = (providerOptions.segmenter || {});
@@ -815,55 +881,260 @@ export class TTSService {
815
881
  await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
816
882
  }
817
883
  }
818
- resolveSpeechContent(text, options) {
884
+ async resolveSpeechContent(text, options) {
819
885
  const normalizedInputText = normalizeTextForSpeech(text);
820
- const normalizedText = options?.contentElement
821
- ? this.extractVisibleText(options.contentElement, options.language) ||
822
- normalizedInputText
823
- : normalizedInputText;
824
- let contentToSpeak = normalizedText;
825
- let usedCatalogSpoken = false;
826
- let speechSource = "dom-or-input";
886
+ if (options?.ignoreCatalogs) {
887
+ return {
888
+ contentToSpeak: normalizedInputText,
889
+ speechText: normalizedInputText,
890
+ visibleText: normalizedInputText,
891
+ highlightText: normalizedInputText,
892
+ usedCatalogSpoken: false,
893
+ speechSource: "dom-or-input",
894
+ normalizedText: normalizedInputText,
895
+ containsMathMarkup: false,
896
+ speechMatchesVisibleText: true,
897
+ };
898
+ }
827
899
  if (options?.catalogId && this.catalogResolver) {
828
900
  const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
829
901
  type: "spoken",
830
902
  language: options.language || "en-US",
831
903
  useFallback: true,
904
+ context: options.catalogContext,
832
905
  });
833
906
  if (catalogContent) {
834
- contentToSpeak = catalogContent.content;
835
- usedCatalogSpoken = true;
836
- speechSource = "catalog-spoken";
837
- console.debug(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
838
- }
839
- else {
840
- console.debug(`[TTSService] No catalog found for "${options.catalogId}", falling back to generated TTS`);
907
+ const visibleText = options?.contentElement
908
+ ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
909
+ : normalizedInputText;
910
+ const normalizedCatalogText = normalizeTextForSpeech(catalogContent.content);
911
+ this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
912
+ return {
913
+ contentToSpeak: catalogContent.content,
914
+ speechText: catalogContent.content,
915
+ visibleText,
916
+ highlightText: visibleText,
917
+ usedCatalogSpoken: true,
918
+ speechSource: "catalog-spoken",
919
+ normalizedText: visibleText,
920
+ containsMathMarkup: false,
921
+ speechMatchesVisibleText: normalizedCatalogText === visibleText,
922
+ };
841
923
  }
924
+ this.debugLog(`[TTSService] No catalog found for "${options.catalogId}", falling back to generated TTS`);
842
925
  }
843
- if (usedCatalogSpoken && options?.contentElement) {
844
- const normalizedCatalogText = normalizeTextForSpeech(contentToSpeak);
845
- const preserveCatalogSpeech = this.hasExplicitBreakSemantics(contentToSpeak);
846
- if (!preserveCatalogSpeech && normalizedCatalogText !== normalizedText) {
847
- console.warn("[TTSService] Catalog spoken text diverges from visible DOM; using normalized visible text to keep highlighting aligned.", {
848
- catalogId: options.catalogId || null,
849
- catalogLength: normalizedCatalogText.length,
850
- visibleLength: normalizedText.length,
926
+ const composed = options?.contentElement
927
+ ? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
928
+ : null;
929
+ if (composed)
930
+ return composed;
931
+ const generated = options?.contentElement
932
+ ? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
933
+ : {
934
+ contentToSpeak: normalizedInputText,
935
+ speechText: normalizedInputText,
936
+ visibleText: normalizedInputText,
937
+ highlightText: normalizedInputText,
938
+ normalizedText: normalizedInputText,
939
+ containsMathMarkup: false,
940
+ speechMatchesVisibleText: true,
941
+ };
942
+ return {
943
+ ...generated,
944
+ usedCatalogSpoken: false,
945
+ speechSource: "dom-or-input",
946
+ };
947
+ }
948
+ resolveCatalogComposedSpeechContent(contentElement, normalizedInputText, options) {
949
+ if (!this.catalogResolver)
950
+ return null;
951
+ const chunks = this.collectCatalogSpeechChunks(contentElement, options);
952
+ if (!chunks.some((chunk) => chunk.sourceElement))
953
+ return null;
954
+ const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
955
+ const visibleText = collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
956
+ return {
957
+ contentToSpeak: speechText,
958
+ speechText,
959
+ visibleText,
960
+ highlightText: visibleText,
961
+ usedCatalogSpoken: true,
962
+ speechSource: "catalog-spoken",
963
+ normalizedText: visibleText,
964
+ containsMathMarkup: false,
965
+ speechMatchesVisibleText: chunks.every((chunk) => chunk.speechMatchesVisibleText),
966
+ speechChunks: chunks,
967
+ };
968
+ }
969
+ collectCatalogSpeechChunks(root, options) {
970
+ const chunks = [];
971
+ let textBuffer = "";
972
+ const flushTextBuffer = () => {
973
+ const visibleText = normalizeTextForSpeech(textBuffer);
974
+ textBuffer = "";
975
+ if (!visibleText)
976
+ return;
977
+ chunks.push({
978
+ speechText: visibleText,
979
+ visibleText,
980
+ sourceElement: null,
981
+ speechMatchesVisibleText: true,
982
+ });
983
+ };
984
+ const resolveCatalog = (element) => {
985
+ const catalogIdRef = element.getAttribute("data-catalog-idref");
986
+ if (!catalogIdRef)
987
+ return null;
988
+ return this.catalogResolver.getAlternative(catalogIdRef, {
989
+ type: "spoken",
990
+ language: options.language || "en-US",
991
+ useFallback: true,
992
+ context: options.catalogContext,
993
+ });
994
+ };
995
+ const getSingleMathElementForAlignment = (element, visibleText) => {
996
+ const mathElements = getMathElementsForAlignment(element);
997
+ if (mathElements.length !== 1)
998
+ return null;
999
+ const mathElement = mathElements[0];
1000
+ const mathVisibleText = collectMathAwareTextAndMap(mathElement, this.getTextProcessingOptions(options.language)).visibleText || normalizeTextForSpeech(mathElement.textContent || "");
1001
+ const compact = (value) => normalizeTextForSpeech(value).replace(/\s+/g, "");
1002
+ return compact(mathVisibleText) === compact(visibleText)
1003
+ ? mathElement
1004
+ : null;
1005
+ };
1006
+ const getMathElementsForAlignment = (element) => {
1007
+ const mathElements = Array.from(element.querySelectorAll("math"));
1008
+ if (element.localName?.toLowerCase() === "math") {
1009
+ return [element, ...mathElements];
1010
+ }
1011
+ return mathElements;
1012
+ };
1013
+ const visit = (node) => {
1014
+ if (isNodeHiddenForTTS(node, root))
1015
+ return;
1016
+ if (node.nodeType === Node.TEXT_NODE) {
1017
+ textBuffer += ` ${node.textContent || ""}`;
1018
+ return;
1019
+ }
1020
+ if (node.nodeType !== Node.ELEMENT_NODE)
1021
+ return;
1022
+ const element = node;
1023
+ const catalog = resolveCatalog(element);
1024
+ if (catalog) {
1025
+ flushTextBuffer();
1026
+ const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1027
+ const visibleText = collectedVisible.visibleText ||
1028
+ normalizeTextForSpeech(element.textContent || "");
1029
+ const alignment = createCatalogSpanAlignment({
1030
+ speechText: catalog.content,
1031
+ visibleText,
1032
+ });
1033
+ const mathElement = getSingleMathElementForAlignment(element, visibleText);
1034
+ const mathAlignment = mathElement
1035
+ ? createMathAwareAlignment({
1036
+ mathElement,
1037
+ speechText: catalog.content,
1038
+ })
1039
+ : undefined;
1040
+ const mathAlignments = mathAlignment
1041
+ ? undefined
1042
+ : getMathElementsForAlignment(element).map((candidate) => ({
1043
+ element: candidate,
1044
+ alignment: createMathAwareAlignment({
1045
+ mathElement: candidate,
1046
+ speechText: catalog.content,
1047
+ }),
1048
+ }));
1049
+ const speechText = normalizeTextForSpeech(catalog.content);
1050
+ chunks.push({
1051
+ speechText: catalog.content,
1052
+ visibleText,
1053
+ sourceElement: element,
1054
+ regionElement: resolveReadableRegion(element, root),
1055
+ speechMatchesVisibleText: speechText === visibleText,
1056
+ playbackMode: alignment.playbackMode,
1057
+ alignment,
1058
+ mathAlignment,
1059
+ mathAlignments,
1060
+ visibleMap: collectedVisible.map,
851
1061
  });
852
- contentToSpeak = normalizedText;
853
- usedCatalogSpoken = false;
854
- speechSource = "dom-or-input";
1062
+ return;
855
1063
  }
1064
+ for (const child of Array.from(element.childNodes)) {
1065
+ visit(child);
1066
+ }
1067
+ };
1068
+ visit(root);
1069
+ flushTextBuffer();
1070
+ return chunks;
1071
+ }
1072
+ /**
1073
+ * Decide the playback format for the generated (no authored catalog) math
1074
+ * path. SSML is only emitted to providers that report `supportsSSML`: the
1075
+ * browser Web Speech API speaks tags literally (capability `false`), and the
1076
+ * server bridge reports `true` only for SSML-reliable backends (Polly,
1077
+ * Google) — the `custom`/SchoolCity transport stays plain.
1078
+ *
1079
+ * A speak-time plain fallback (`SpeechCompositionChunk.plainFallback`) is the
1080
+ * defensive net if a capable provider still rejects a given SSML payload.
1081
+ */
1082
+ resolveGeneratedPlaybackFormat() {
1083
+ const provider = this.currentProvider;
1084
+ if (!provider)
1085
+ return "plain";
1086
+ // Belt-and-suspenders: the browser provider can never voice SSML.
1087
+ if (provider.providerId?.toLowerCase() === "browser")
1088
+ return "plain";
1089
+ return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
1090
+ }
1091
+ async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language) {
1092
+ // Delegate to the generated-speech module: a pure plan assembly + SRE
1093
+ // memoization core, plus a DOM adapter that binds live-DOM anchors and
1094
+ // emits playback chunks. The aggregate `contentToSpeak` stays plain text
1095
+ // (`plan.plainSpeechText`) so seek/structural-pause planning is unaffected.
1096
+ const playbackFormat = this.resolveGeneratedPlaybackFormat();
1097
+ const { plan, containsMathMarkup, visibleText: collectedVisibleText } = await buildGeneratedSpeechFromRoot({
1098
+ contentRoot: contentElement,
1099
+ language,
1100
+ mathSpeech: this.getMathSpeechOptions(),
1101
+ textProcessingOptions: this.getTextProcessingOptions(language),
1102
+ produceSsml: playbackFormat === "ssml",
1103
+ resolveMathSpeech: this.generatedMathSpeechResolver,
1104
+ });
1105
+ const visibleText = collectedVisibleText || normalizedInputText;
1106
+ if (!containsMathMarkup) {
1107
+ return {
1108
+ contentToSpeak: visibleText,
1109
+ speechText: visibleText,
1110
+ visibleText,
1111
+ highlightText: visibleText,
1112
+ normalizedText: visibleText,
1113
+ containsMathMarkup: false,
1114
+ speechMatchesVisibleText: true,
1115
+ };
856
1116
  }
1117
+ const speechChunks = planToCompositionChunkInputs(plan, {
1118
+ format: playbackFormat,
1119
+ });
1120
+ const speechText = plan.plainSpeechText;
857
1121
  return {
858
- contentToSpeak,
859
- usedCatalogSpoken,
860
- speechSource,
861
- normalizedText,
1122
+ contentToSpeak: speechText,
1123
+ speechText,
1124
+ visibleText,
1125
+ highlightText: visibleText,
1126
+ normalizedText: visibleText,
1127
+ containsMathMarkup: true,
1128
+ speechMatchesVisibleText: speechText === visibleText,
1129
+ speechChunks,
862
1130
  };
863
1131
  }
864
1132
  logResolvedSpeechContent(args) {
865
- const preview = args.contentToSpeak.replace(/\s+/g, " ").trim().slice(0, 200);
866
- console.debug("[TTSService] Speak resolved content", {
1133
+ const preview = args.contentToSpeak
1134
+ .replace(/\s+/g, " ")
1135
+ .trim()
1136
+ .slice(0, 200);
1137
+ this.debugLog("[TTSService] Speak resolved content", {
867
1138
  source: args.speechSource,
868
1139
  catalogId: args.catalogId || null,
869
1140
  length: args.contentToSpeak.length,
@@ -888,7 +1159,7 @@ export class TTSService {
888
1159
  const initialSegment = this.sentenceHighlightSegments[0] || this.seekSegments[0];
889
1160
  if (initialSegment) {
890
1161
  this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
891
- console.log("[TTSService] Applied initial sentence highlighting");
1162
+ this.debugLog("[TTSService] Applied initial sentence highlighting");
892
1163
  }
893
1164
  else {
894
1165
  try {
@@ -917,7 +1188,7 @@ export class TTSService {
917
1188
  const highlightRange = this.findHighlightRange(globalIndex, wordLength);
918
1189
  if (highlightRange && this.highlightCoordinator) {
919
1190
  const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
920
- console.log(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1191
+ this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
921
1192
  this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
922
1193
  }
923
1194
  else {
@@ -925,7 +1196,221 @@ export class TTSService {
925
1196
  }
926
1197
  };
927
1198
  }
1199
+ clearWordBoundaryHighlighting() {
1200
+ if (this.provider) {
1201
+ this.provider.onWordBoundary = undefined;
1202
+ }
1203
+ }
1204
+ highlightCatalogRegion(chunk) {
1205
+ const element = chunk.regionElement || chunk.sourceElement;
1206
+ if ((!element && !chunk.regionRange) || !this.highlightCoordinator)
1207
+ return;
1208
+ try {
1209
+ this.lastRenderedRegionTarget = null;
1210
+ if (chunk.regionRange) {
1211
+ this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1212
+ this.highlightCoordinator.highlightTTSSentence([chunk.regionRange]);
1213
+ return;
1214
+ }
1215
+ if (!element)
1216
+ return;
1217
+ const range = document.createRange();
1218
+ range.selectNodeContents(element);
1219
+ this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1220
+ this.highlightCoordinator.highlightTTSSentence([range]);
1221
+ }
1222
+ catch {
1223
+ // Continue playback even when a detached node cannot be highlighted.
1224
+ }
1225
+ }
1226
+ highlightCatalogActiveRange(range) {
1227
+ if (!this.highlightCoordinator)
1228
+ return;
1229
+ if (range.startContainer === range.endContainer &&
1230
+ range.startContainer.nodeType === Node.TEXT_NODE) {
1231
+ this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
1232
+ return;
1233
+ }
1234
+ this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
1235
+ }
1236
+ highlightRenderableRegionTarget(target) {
1237
+ if (!this.highlightCoordinator ||
1238
+ !target ||
1239
+ typeof document === "undefined") {
1240
+ return;
1241
+ }
1242
+ if (sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
1243
+ return;
1244
+ }
1245
+ try {
1246
+ const range = document.createRange();
1247
+ if (target.type === "element") {
1248
+ range.selectNodeContents(target.element);
1249
+ }
1250
+ else if (target.type === "range") {
1251
+ this.highlightCoordinator.highlightTTSSentence([target.range]);
1252
+ this.lastRenderedRegionTarget = target;
1253
+ return;
1254
+ }
1255
+ else {
1256
+ range.setStart(target.node, target.startOffset);
1257
+ range.setEnd(target.node, target.endOffset);
1258
+ }
1259
+ this.highlightCoordinator.highlightTTSSentence([range]);
1260
+ this.lastRenderedRegionTarget = target;
1261
+ }
1262
+ catch {
1263
+ // Continue playback even when a detached node cannot be highlighted.
1264
+ }
1265
+ }
1266
+ highlightRenderableActiveTarget(target) {
1267
+ if (!this.highlightCoordinator || typeof document === "undefined") {
1268
+ return;
1269
+ }
1270
+ if (!target) {
1271
+ // No active word for this boundary — an unresolved prose word, an SSML
1272
+ // break/pause, or a token-mode equation still awaiting its first token.
1273
+ // Clear the word layer so the previously spoken word does not stay
1274
+ // highlighted. (The "hold last token" behavior returns a non-null
1275
+ // target, so it does not reach here.)
1276
+ this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1277
+ return;
1278
+ }
1279
+ if (target.type === "text-range") {
1280
+ this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1281
+ return;
1282
+ }
1283
+ if (target.type === "range") {
1284
+ this.highlightCatalogActiveRange(target.range);
1285
+ return;
1286
+ }
1287
+ // Element targets are atomic: a resolved math token, a whole-expression
1288
+ // fallback, or a replaced element (image/svg). Paint exactly the resolved
1289
+ // element. Native MathML / HTML tokens that expose a single text node keep
1290
+ // the CSS-range underline (it reads more precisely than an element box);
1291
+ // everything else — notably MathJax CHTML tokens (`<mjx-mi><mjx-c/>`),
1292
+ // which have no text node, and expression fallbacks — is marked on the
1293
+ // element itself. Crucially we no longer escalate to the enclosing
1294
+ // `<math>` / `<mjx-container>`: that escalation is why MathJax math only
1295
+ // ever highlighted as a full block while native MathML tracked per-token.
1296
+ const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
1297
+ if (target.quality === "semantic-token" &&
1298
+ onlyChild?.nodeType === Node.TEXT_NODE) {
1299
+ this.highlightCoordinator.highlightTTSWord(onlyChild, 0, onlyChild.textContent?.length || 0);
1300
+ return;
1301
+ }
1302
+ this.highlightCoordinator.highlightTTSWordElement?.(target.element);
1303
+ }
1304
+ renderHighlightDecision(decision) {
1305
+ this.highlightRenderableRegionTarget(decision.regionTarget);
1306
+ this.highlightRenderableActiveTarget(decision.activeTarget);
1307
+ }
1308
+ async speakCatalogChunk(chunk, runId) {
1309
+ try {
1310
+ await this.speakCatalogChunkOnce(chunk, runId);
1311
+ }
1312
+ catch (error) {
1313
+ // Speak-time fallback: if an SSML math chunk is rejected by the
1314
+ // provider, retry once with its precomputed plain-text variant (same
1315
+ // anchors, plain alignment). The fallback chunk carries no further
1316
+ // fallback, so this cannot recurse.
1317
+ if (chunk.plainFallback && runId === this.speakRunId) {
1318
+ this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
1319
+ await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
1320
+ return;
1321
+ }
1322
+ throw error;
1323
+ }
1324
+ }
1325
+ async speakCatalogChunkOnce(chunk, runId) {
1326
+ if (!this.provider)
1327
+ return;
1328
+ this.lastRenderedRegionTarget = null;
1329
+ const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
1330
+ const pipelineChunk = contentRoot
1331
+ ? normalizeSpeechChunks({
1332
+ contentRoot,
1333
+ chunks: [chunk],
1334
+ })[0]
1335
+ : null;
1336
+ // Carried through the config channel (see buildRuntimeTTSConfig); defaults
1337
+ // to per-token math highlighting unless a host explicitly disables it.
1338
+ const mathTokenHighlighting = this.ttsConfig.mathTokenHighlighting !== false;
1339
+ const highlightPlan = pipelineChunk
1340
+ ? createTTSHighlightPlan({
1341
+ chunks: [pipelineChunk],
1342
+ mathTokenHighlighting,
1343
+ })
1344
+ : null;
1345
+ if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
1346
+ this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
1347
+ }
1348
+ else {
1349
+ this.highlightCatalogRegion(chunk);
1350
+ }
1351
+ const canUseChunkBoundaries = chunk.sourceElement &&
1352
+ ((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
1353
+ (chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
1354
+ (chunk.alignment &&
1355
+ chunk.visibleMap &&
1356
+ (chunk.playbackMode === "exact-word" ||
1357
+ chunk.playbackMode === "anchor-span")));
1358
+ if (!canUseChunkBoundaries) {
1359
+ const provider = this.provider;
1360
+ const previousOnWordBoundary = provider.onWordBoundary;
1361
+ provider.onWordBoundary = undefined;
1362
+ try {
1363
+ await provider.speak(chunk.speechText);
1364
+ }
1365
+ finally {
1366
+ if (this.provider === provider &&
1367
+ runId === this.speakRunId &&
1368
+ provider.onWordBoundary === undefined) {
1369
+ provider.onWordBoundary = previousOnWordBoundary;
1370
+ }
1371
+ }
1372
+ return;
1373
+ }
1374
+ const provider = this.provider;
1375
+ const previousOnWordBoundary = provider.onWordBoundary;
1376
+ const installedHandler = (word, position, length) => {
1377
+ if (runId !== this.speakRunId)
1378
+ return;
1379
+ if (highlightPlan && pipelineChunk) {
1380
+ const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
1381
+ ? "unknown"
1382
+ : pipelineChunk.offsetSpace;
1383
+ this.renderHighlightDecision(highlightPlan.resolveBoundary({
1384
+ chunkId: pipelineChunk.id,
1385
+ word,
1386
+ position,
1387
+ length,
1388
+ providerOffsetSpace,
1389
+ }));
1390
+ return;
1391
+ }
1392
+ };
1393
+ provider.onWordBoundary = installedHandler;
1394
+ try {
1395
+ await provider.speak(chunk.speechText);
1396
+ }
1397
+ finally {
1398
+ if (this.provider === provider &&
1399
+ runId === this.speakRunId &&
1400
+ provider.onWordBoundary === installedHandler) {
1401
+ provider.onWordBoundary = previousOnWordBoundary;
1402
+ }
1403
+ }
1404
+ }
928
1405
  async executeSpeakPlayback(args) {
1406
+ if (args.speechChunks?.length && this.provider) {
1407
+ for (const chunk of args.speechChunks) {
1408
+ if (args.runId !== this.speakRunId)
1409
+ return;
1410
+ await this.speakCatalogChunk(chunk, args.runId);
1411
+ }
1412
+ return;
1413
+ }
929
1414
  if (args.shouldUsePlan && this.currentContentElement) {
930
1415
  const segments = this.seekSegments;
931
1416
  if (segments.length > 0) {
@@ -946,6 +1431,7 @@ export class TTSService {
946
1431
  if (this.highlightCoordinator) {
947
1432
  this.highlightCoordinator.clearTTS();
948
1433
  }
1434
+ this.lastRenderedRegionTarget = null;
949
1435
  this.currentContentElement = null;
950
1436
  this.normalizedToDOM.clear();
951
1437
  this.currentBoundaryOffset = 0;
@@ -987,8 +1473,10 @@ export class TTSService {
987
1473
  beforeRange.selectNodeContents(root);
988
1474
  beforeRange.setEnd(range.startContainer, range.startOffset);
989
1475
  const textBeforeRange = beforeRange.toString();
990
- const offset = normalizeTextForSpeech(textBeforeRange).length;
991
- console.log("[TTSService] speakRange offset calculation:", {
1476
+ const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
1477
+ const offset = normalizedTextBeforeRange.length +
1478
+ (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
1479
+ this.debugLog("[TTSService] speakRange offset calculation:", {
992
1480
  selectedText: text,
993
1481
  textBeforeRange: textBeforeRange.substring(0, 100),
994
1482
  offset,
@@ -997,6 +1485,7 @@ export class TTSService {
997
1485
  // Speak the text with the root element as context
998
1486
  await this.speak(text, {
999
1487
  contentElement: root,
1488
+ ignoreCatalogs: true,
1000
1489
  wordBoundaryOffset: offset,
1001
1490
  });
1002
1491
  }
@@ -1025,7 +1514,8 @@ export class TTSService {
1025
1514
  async seekBy(units) {
1026
1515
  if (!this.provider || !this.currentText)
1027
1516
  return;
1028
- if (this.state !== PlaybackState.PLAYING && this.state !== PlaybackState.PAUSED) {
1517
+ if (this.state !== PlaybackState.PLAYING &&
1518
+ this.state !== PlaybackState.PAUSED) {
1029
1519
  return;
1030
1520
  }
1031
1521
  if (this.hasExplicitBreakSemantics(this.currentText))
@@ -1040,6 +1530,7 @@ export class TTSService {
1040
1530
  if (targetIndex === currentIndex)
1041
1531
  return;
1042
1532
  this.speakRunId += 1;
1533
+ this.provider.onWordBoundary = undefined;
1043
1534
  this.provider.stop();
1044
1535
  this.currentSeekSegmentIndex = targetIndex;
1045
1536
  const runId = ++this.speakRunId;
@@ -1078,6 +1569,7 @@ export class TTSService {
1078
1569
  if (!this.provider)
1079
1570
  return;
1080
1571
  this.speakRunId += 1;
1572
+ this.provider.onWordBoundary = undefined;
1081
1573
  this.provider.stop();
1082
1574
  this.setState(PlaybackState.IDLE);
1083
1575
  this.currentText = null;
@@ -1086,6 +1578,7 @@ export class TTSService {
1086
1578
  this.highlightCoordinator.clearTTS();
1087
1579
  }
1088
1580
  // Clear tracking
1581
+ this.lastRenderedRegionTarget = null;
1089
1582
  this.currentContentElement = null;
1090
1583
  this.normalizedToDOM.clear();
1091
1584
  this.currentBoundaryOffset = 0;