@pie-players/pie-assessment-toolkit 0.3.74 → 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 (257) hide show
  1. package/README.md +658 -714
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +1 -21
  4. package/dist/attempt/TestSession.js +16 -102
  5. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  6. package/dist/components/ItemScope.custom-element.js +1 -0
  7. package/dist/components/ItemToolBar.custom-element.js +1 -1
  8. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-3zrc4p3d.js +4 -0
  11. package/dist/components/chunks/ItemToolBar-66hcwmfw.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  13. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  14. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-kkj69ptr.js} +1 -1
  15. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  16. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  17. package/dist/components/item-scope-element.d.ts +2 -0
  18. package/dist/components/item-scope-element.js +2 -0
  19. package/dist/context/assessment-toolkit-context.d.ts +33 -6
  20. package/dist/index.d.ts +25 -50
  21. package/dist/index.js +15 -45
  22. package/dist/policy/core/PolicySource.d.ts +2 -2
  23. package/dist/policy/core/ToolPolicyEngine.d.ts +118 -34
  24. package/dist/policy/core/ToolPolicyEngine.js +203 -56
  25. package/dist/policy/core/compose-decision.d.ts +28 -7
  26. package/dist/policy/core/compose-decision.js +95 -74
  27. package/dist/policy/core/decision-types.d.ts +98 -55
  28. package/dist/policy/core/feature-decision.d.ts +28 -19
  29. package/dist/policy/core/feature-decision.js +15 -11
  30. package/dist/policy/core/pnp-policy-inputs.d.ts +29 -35
  31. package/dist/policy/core/pnp-policy-inputs.js +40 -88
  32. package/dist/policy/core/provenance.d.ts +10 -5
  33. package/dist/policy/core/provenance.js +1 -1
  34. package/dist/policy/core/tool-parameters.d.ts +11 -0
  35. package/dist/policy/core/tool-parameters.js +15 -0
  36. package/dist/policy/engine.d.ts +7 -23
  37. package/dist/policy/engine.js +5 -20
  38. package/dist/policy/sources/PnpPolicySource.d.ts +43 -68
  39. package/dist/policy/sources/PnpPolicySource.js +133 -132
  40. package/dist/runtime/SectionControllerBinding.d.ts +283 -0
  41. package/dist/runtime/SectionControllerBinding.js +456 -0
  42. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  43. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  44. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  45. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  46. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  47. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  48. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -3
  49. package/dist/runtime/adapter/subscriber-fanout.js +2 -6
  50. package/dist/runtime/catalog-context.d.ts +19 -0
  51. package/dist/runtime/catalog-context.js +28 -0
  52. package/dist/runtime/composition-emit-scheduler.d.ts +12 -18
  53. package/dist/runtime/composition-emit-scheduler.js +10 -16
  54. package/dist/runtime/content-language.d.ts +32 -0
  55. package/dist/runtime/content-language.js +46 -0
  56. package/dist/runtime/content-region.d.ts +13 -0
  57. package/dist/runtime/content-region.js +29 -0
  58. package/dist/runtime/content-registration.d.ts +13 -0
  59. package/dist/runtime/content-registration.js +25 -0
  60. package/dist/runtime/core/SectionEngineCore.d.ts +2 -4
  61. package/dist/runtime/core/SectionEngineCore.js +2 -6
  62. package/dist/runtime/core/cohort.d.ts +1 -6
  63. package/dist/runtime/core/cohort.js +1 -10
  64. package/dist/runtime/core/engine-input.d.ts +18 -30
  65. package/dist/runtime/core/engine-input.js +11 -12
  66. package/dist/runtime/core/engine-output.d.ts +17 -40
  67. package/dist/runtime/core/engine-output.js +9 -21
  68. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  69. package/dist/runtime/core/engine-readiness.js +8 -41
  70. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  71. package/dist/runtime/core/engine-resolver.js +18 -43
  72. package/dist/runtime/core/engine-state.d.ts +28 -36
  73. package/dist/runtime/core/engine-state.js +18 -21
  74. package/dist/runtime/core/engine-transition.d.ts +14 -19
  75. package/dist/runtime/core/engine-transition.js +74 -53
  76. package/dist/runtime/engine.d.ts +16 -19
  77. package/dist/runtime/engine.js +12 -19
  78. package/dist/runtime/page-warnings.d.ts +13 -0
  79. package/dist/runtime/page-warnings.js +22 -0
  80. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  81. package/dist/runtime/policy-input-forwarding.js +25 -0
  82. package/dist/runtime/registration-events.d.ts +22 -14
  83. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  84. package/dist/runtime/runtime-event-claim.js +15 -0
  85. package/dist/runtime/runtime-id.d.ts +1 -1
  86. package/dist/runtime/runtime-id.js +1 -1
  87. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  88. package/dist/runtime/shell-event-bridge.js +127 -0
  89. package/dist/runtime/shell-registration.d.ts +73 -0
  90. package/dist/runtime/shell-registration.js +95 -0
  91. package/dist/runtime/shell-scope.d.ts +68 -0
  92. package/dist/runtime/shell-scope.js +194 -0
  93. package/dist/runtime/tool-host-contract.d.ts +8 -14
  94. package/dist/runtime/tool-host-contract.js +12 -12
  95. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  96. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  97. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  98. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  99. package/dist/services/ElementToolStateStore.d.ts +42 -67
  100. package/dist/services/ElementToolStateStore.js +65 -71
  101. package/dist/services/HighlightCoordinator.d.ts +28 -40
  102. package/dist/services/HighlightCoordinator.js +459 -381
  103. package/dist/services/RangeSerializer.d.ts +11 -33
  104. package/dist/services/RangeSerializer.js +88 -59
  105. package/dist/services/SSMLExtractor.d.ts +8 -31
  106. package/dist/services/SSMLExtractor.js +18 -40
  107. package/dist/services/TTSService.d.ts +159 -119
  108. package/dist/services/TTSService.js +937 -822
  109. package/dist/services/ToolCoordinator.d.ts +42 -102
  110. package/dist/services/ToolCoordinator.js +178 -256
  111. package/dist/services/ToolRegistry.d.ts +152 -133
  112. package/dist/services/ToolRegistry.js +192 -178
  113. package/dist/services/ToolkitCoordinator.d.ts +233 -237
  114. package/dist/services/ToolkitCoordinator.js +615 -557
  115. package/dist/services/audio-handoff.d.ts +6 -7
  116. package/dist/services/audio-handoff.js +20 -14
  117. package/dist/services/catalog-owner.d.ts +0 -1
  118. package/dist/services/framework-error-bus.d.ts +3 -6
  119. package/dist/services/framework-error-bus.js +3 -6
  120. package/dist/services/framework-error.d.ts +51 -13
  121. package/dist/services/framework-error.js +19 -20
  122. package/dist/services/interfaces.d.ts +189 -184
  123. package/dist/services/section-controller-types.d.ts +7 -7
  124. package/dist/services/section-session-assignment.d.ts +21 -0
  125. package/dist/services/section-session-assignment.js +80 -0
  126. package/dist/services/spoken-audio-cards.d.ts +2 -6
  127. package/dist/services/spoken-audio-cards.js +2 -6
  128. package/dist/services/tool-config-defaults.d.ts +3 -3
  129. package/dist/services/tool-config-defaults.js +3 -3
  130. package/dist/services/tool-config-validation.d.ts +9 -12
  131. package/dist/services/tool-config-validation.js +47 -87
  132. package/dist/services/tool-context.d.ts +7 -31
  133. package/dist/services/tool-context.js +99 -65
  134. package/dist/services/tool-failure.d.ts +30 -0
  135. package/dist/services/tool-failure.js +11 -0
  136. package/dist/services/tool-icons.d.ts +5 -5
  137. package/dist/services/tool-icons.js +6 -5
  138. package/dist/services/tool-instance-id.d.ts +8 -7
  139. package/dist/services/tool-instance-id.js +12 -24
  140. package/dist/services/tool-providers/TTSToolProvider.d.ts +20 -58
  141. package/dist/services/tool-providers/TTSToolProvider.js +32 -85
  142. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -11
  143. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +29 -60
  144. package/dist/services/tool-providers/ToolProviderRegistry.js +128 -144
  145. package/dist/services/tool-request.d.ts +31 -12
  146. package/dist/services/tool-request.js +31 -16
  147. package/dist/services/toolbar-decided-tools.d.ts +16 -0
  148. package/dist/services/toolbar-decided-tools.js +29 -0
  149. package/dist/services/toolbar-items.js +2 -5
  150. package/dist/services/tools-config-normalizer.d.ts +33 -29
  151. package/dist/services/tools-config-normalizer.js +26 -28
  152. package/dist/services/tts/browser-provider.d.ts +39 -6
  153. package/dist/services/tts/browser-provider.js +280 -160
  154. package/dist/services/tts/catalog-span-alignment.js +14 -7
  155. package/dist/services/tts/flat-tree.d.ts +64 -0
  156. package/dist/services/tts/flat-tree.js +269 -0
  157. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  158. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  159. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  160. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  161. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  162. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  163. package/dist/services/tts/generated-speech/index.js +1 -1
  164. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  165. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  166. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  167. package/dist/services/tts/generated-speech/types.js +1 -1
  168. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +22 -16
  169. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  170. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  171. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  172. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  173. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  174. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +42 -47
  177. package/dist/services/tts/math-control-names.d.ts +21 -0
  178. package/dist/services/tts/math-control-names.js +142 -0
  179. package/dist/services/tts/math-speech.d.ts +12 -1
  180. package/dist/services/tts/math-speech.js +54 -18
  181. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  182. package/dist/services/tts/mathml-sanitization.js +9 -1
  183. package/dist/services/tts/provider-options.d.ts +52 -0
  184. package/dist/services/tts/provider-options.js +1 -0
  185. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  186. package/dist/services/tts/ssml/spoken-text.js +29 -0
  187. package/dist/services/tts/start-failure.d.ts +4 -0
  188. package/dist/services/tts/start-failure.js +19 -0
  189. package/dist/services/tts/text-processing.d.ts +9 -0
  190. package/dist/services/tts/text-processing.js +46 -33
  191. package/dist/services/tts/text-segmentation.d.ts +12 -0
  192. package/dist/services/tts/text-segmentation.js +60 -10
  193. package/dist/services/tts-runtime-config.d.ts +54 -26
  194. package/dist/services/tts-runtime-config.js +27 -112
  195. package/dist/tools/content-capability-resolution.d.ts +9 -11
  196. package/dist/tools/content-capability-resolution.js +8 -45
  197. package/dist/tools/registration.d.ts +59 -0
  198. package/dist/tools/registration.js +55 -0
  199. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  200. package/dist/tools/registrations/toolbar-registration-helpers.js +8 -2
  201. package/dist/tools/tool-surface-host.d.ts +6 -10
  202. package/dist/tools/tool-surface-host.js +45 -50
  203. package/dist/tools/tool-tag-map.d.ts +5 -15
  204. package/dist/tools/tool-tag-map.js +6 -20
  205. package/dist/utils/structural-equality.d.ts +11 -0
  206. package/dist/utils/structural-equality.js +47 -0
  207. package/package.json +15 -29
  208. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  210. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  211. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  212. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  213. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  214. package/dist/context/runtime-context-consumer.d.ts +0 -13
  215. package/dist/context/runtime-context-consumer.js +0 -18
  216. package/dist/item-loader.d.ts +0 -44
  217. package/dist/item-loader.js +0 -61
  218. package/dist/policy/core/engine-context.d.ts +0 -29
  219. package/dist/policy/core/engine-context.js +0 -27
  220. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  221. package/dist/policy/core/policy-source-tag.js +0 -28
  222. package/dist/policy/internal.d.ts +0 -25
  223. package/dist/policy/internal.js +0 -25
  224. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  225. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  226. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  227. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  228. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  229. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  230. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  231. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  232. package/dist/runtime/engine-context.d.ts +0 -39
  233. package/dist/runtime/engine-context.js +0 -31
  234. package/dist/runtime/internal.d.ts +0 -43
  235. package/dist/runtime/internal.js +0 -45
  236. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  237. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  238. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  239. package/dist/runtime/stage-emit-gate.js +0 -82
  240. package/dist/services/ContextVariableStore.d.ts +0 -113
  241. package/dist/services/ContextVariableStore.js +0 -206
  242. package/dist/services/I18nService.d.ts +0 -47
  243. package/dist/services/I18nService.js +0 -84
  244. package/dist/services/ThemeProvider.d.ts +0 -95
  245. package/dist/services/ThemeProvider.js +0 -334
  246. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  247. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  248. package/dist/services/pnp-standard-features.d.ts +0 -238
  249. package/dist/services/pnp-standard-features.js +0 -338
  250. package/dist/services/tts-control-events.d.ts +0 -13
  251. package/dist/services/tts-control-events.js +0 -7
  252. package/dist/tools/client.d.ts +0 -10
  253. package/dist/tools/client.js +0 -10
  254. package/dist/tools/internal.d.ts +0 -44
  255. package/dist/tools/internal.js +0 -39
  256. package/dist/utils/logger.d.ts +0 -6
  257. package/dist/utils/logger.js +0 -10
@@ -1,33 +1,40 @@
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
- import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
21
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
20
22
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
21
- import { BrowserTTSProvider } from "./tts/browser-provider.js";
22
- import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
23
+ import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
24
+ import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
25
+ import { composedContains, composedParentElement, flatQuerySelectorAll, flatTextContent, flatTreeChildNodes, flatTreeParentElement, flatTreeTextNodes, rangeHoldsTextPosition, rangeIntersectsComposedNode, } from "./tts/flat-tree.js";
26
+ import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, rangeHoldsSpeakableElement, textInRange, } from "./tts/text-processing.js";
23
27
  import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
24
28
  import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
25
29
  import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
26
30
  import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
27
31
  import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
28
- import { segmentSentences as segmentTextToSentences, } from "./tts/text-segmentation.js";
32
+ import { toTTSStartFailure } from "./tts/start-failure.js";
33
+ import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
29
34
  import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
30
- import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
35
+ import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.js";
36
+ // Debug lines need `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
37
+ const logger = createPieLogger("tts-service", isTtsDebugEnabled);
31
38
  /**
32
39
  * Playback state
33
40
  */
@@ -39,6 +46,29 @@ export var PlaybackState;
39
46
  PlaybackState["PAUSED"] = "paused";
40
47
  PlaybackState["ERROR"] = "error";
41
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;
57
+ const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
58
+ const hostLocalesOf = (options, previous = {}) => {
59
+ if (!options)
60
+ return previous;
61
+ return {
62
+ locale: "locale" in options ? optionalString(options.locale) : previous.locale,
63
+ textNormalization: options.textNormalization && "locale" in options.textNormalization
64
+ ? optionalString(options.textNormalization.locale)
65
+ : previous.textNormalization,
66
+ segmenter: options.segmenter && "locale" in options.segmenter
67
+ ? optionalString(options.segmenter.locale)
68
+ : previous.segmenter,
69
+ };
70
+ };
71
+ const isRange = (target) => "commonAncestorContainer" in target;
42
72
  const sameRange = (left, right) => left === right ||
43
73
  (left.startContainer === right.startContainer &&
44
74
  left.startOffset === right.startOffset &&
@@ -67,92 +97,158 @@ const sameRenderableHighlightTarget = (left, right) => {
67
97
  return false;
68
98
  };
69
99
  /**
70
- * TTSService
71
- *
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
+ /**
72
141
  * Instantiable service for text-to-speech functionality.
73
142
  * Each instance manages its own playback state.
74
143
  */
75
144
  export class TTSService {
145
+ // currentProvider is the provider the host installed; provider is the
146
+ // implementation its initialize() returned, which does the speaking.
76
147
  currentProvider = null;
77
148
  provider = null;
78
149
  highlightCoordinator = null;
79
150
  highlightTargetResolverProvider = null;
80
151
  catalogResolver = null;
81
152
  state = PlaybackState.IDLE;
153
+ // The state a resume returns to: loading when the pause came before the
154
+ // audio started sounding.
155
+ resumeState = PlaybackState.PLAYING;
82
156
  ttsConfig = {};
157
+ // The locales the host configured. A read naming a language sets all three
158
+ // to it; a read naming none restores these.
159
+ hostLocales = {};
160
+ // The locale of the read in progress, which segmentation reads.
161
+ readLocale = DEFAULT_CONTENT_LANGUAGE;
162
+ disposed = false;
163
+ readinessGate = null;
164
+ mathSpeechSource = null;
83
165
  currentText = null;
84
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.
85
169
  normalizedToDOM = new Map();
86
- listeners = new Map();
170
+ listeners = new Set();
87
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.
88
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.
89
179
  currentBoundaryOffset = 0;
90
180
  activeWordBoundaryOffset = 0;
91
181
  seekSegments = [];
92
182
  playbackChunks = [];
93
183
  // The recording currently playing, if any. `cancel` settles the pending play
94
184
  // promise as well as stopping the element, so stop/seek cannot wedge the
95
- // chunk loop on a run that has already been superseded.
185
+ // chunk loop on a run that has already been superseded. `play` starts it, or
186
+ // starts it again after a pause.
96
187
  activeRecordedAudio = null;
97
188
  sentenceHighlightSegments = [];
98
189
  activeSentenceStartOffset = null;
99
190
  playbackStartDeferredRunId = null;
100
191
  pendingPlaybackStartHighlights = [];
101
192
  playbackStartBarrierCleanup = null;
193
+ // A pause between two parts of a run, in a structural gap or while the next
194
+ // part loads, holds the run here until resume.
195
+ pauseHold = null;
196
+ structuralPause = null;
102
197
  playbackRateWriteQueue = Promise.resolve();
103
198
  playbackRateRequestId = 0;
104
199
  pendingPlaybackRateRequest = null;
105
200
  activePlaybackRate = null;
106
201
  activeHighlightMode = "word";
202
+ // What the plan part being spoken paints in sentence mode.
203
+ wordlessPartHighlight = null;
107
204
  lastRenderedRegionTarget = null;
108
205
  telemetryReporter = null;
109
- // Memoized SRE resolver for the runtime generated-speech path (PIE-623).
206
+ // Memoized SRE resolver for the runtime generated-speech path.
110
207
  // Caches DOM-free spoken text keyed by SRE settings and MathML source across
111
208
  // utterances within this service instance.
112
209
  generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
113
- // Verbose alignment/highlight tracing is opt-in: it is expensive (large
114
- // objects, a full text diff per utterance) and noisy in production. Enable
115
- // with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
116
- static debugEnabled = (() => {
117
- try {
118
- if (typeof process !== "undefined" &&
119
- process.env?.PIE_TTS_DEBUG === "1") {
120
- return true;
121
- }
122
- }
123
- catch {
124
- // `process` is not defined in the browser; fall through to the global.
125
- }
126
- return (globalThis.__PIE_TTS_DEBUG__ === true);
127
- })();
128
- constructor() { }
129
- debugLog(message, detail) {
130
- if (!TTSService.debugEnabled)
131
- return;
132
- if (detail === undefined) {
133
- console.debug(message);
134
- }
135
- else {
136
- console.debug(message, detail);
137
- }
138
- }
139
210
  clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
140
211
  cleanup?.();
141
212
  if (this.playbackStartBarrierCleanup === cleanup) {
142
213
  this.playbackStartBarrierCleanup = null;
143
214
  }
144
215
  }
145
- notifyPlaybackStarted(runId) {
216
+ notifyPlaybackStarted(runId, playback) {
146
217
  if (this.playbackStartDeferredRunId !== runId ||
147
218
  runId !== this.speakRunId) {
148
219
  return;
149
220
  }
150
- this.setState(PlaybackState.PLAYING);
221
+ if (this.state === PlaybackState.PAUSED) {
222
+ // The learner paused before this part began: it holds where it started.
223
+ this.activeRecordedAudio?.element.pause();
224
+ this.provider?.pause();
225
+ }
226
+ this.markPlaying();
151
227
  const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
152
228
  for (const applyHighlight of pendingHighlights) {
153
229
  applyHighlight();
154
230
  }
231
+ if (playback?.wordBoundaries === false)
232
+ this.highlightWordlessPlayback();
155
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
+ */
156
252
  installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
157
253
  this.clearPlaybackStartBarrier();
158
254
  const provider = this.provider;
@@ -160,12 +256,12 @@ export class TTSService {
160
256
  if (!startAwareProvider && !hasMediaStartSignal)
161
257
  return null;
162
258
  const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
163
- const onPlaybackStart = () => {
259
+ const onPlaybackStart = (playback) => {
164
260
  try {
165
- this.notifyPlaybackStarted(runId);
261
+ this.notifyPlaybackStarted(runId, playback);
166
262
  }
167
263
  finally {
168
- previousOnPlaybackStart?.();
264
+ previousOnPlaybackStart?.(playback);
169
265
  }
170
266
  };
171
267
  if (startAwareProvider) {
@@ -191,6 +287,84 @@ export class TTSService {
191
287
  }
192
288
  callback();
193
289
  }
290
+ /** Resolves when run `runId` may start its next part: at once unless paused. */
291
+ async waitWhilePaused(runId) {
292
+ while (this.state === PlaybackState.PAUSED && runId === this.speakRunId) {
293
+ if (!this.pauseHold) {
294
+ let release = () => { };
295
+ const promise = new Promise((resolve) => {
296
+ release = resolve;
297
+ });
298
+ this.pauseHold = { promise, release };
299
+ }
300
+ await this.pauseHold.promise;
301
+ }
302
+ }
303
+ releasePauseHold() {
304
+ const hold = this.pauseHold;
305
+ this.pauseHold = null;
306
+ hold?.release();
307
+ }
308
+ waitStructuralPause(pauseMs) {
309
+ this.cancelStructuralPause();
310
+ return new Promise((resolve) => {
311
+ const finish = () => {
312
+ if (this.structuralPause?.finish === finish) {
313
+ clearTimeout(this.structuralPause.timer);
314
+ this.structuralPause = null;
315
+ }
316
+ resolve();
317
+ };
318
+ this.structuralPause = { timer: setTimeout(finish, pauseMs), finish };
319
+ });
320
+ }
321
+ cancelStructuralPause() {
322
+ this.structuralPause?.finish();
323
+ }
324
+ /**
325
+ * Release what a superseded run still holds: its start barrier, a recorded
326
+ * clip still playing, a structural gap and a pause hold. Its loops then see
327
+ * the new run id and return.
328
+ */
329
+ abandonRun() {
330
+ this.clearPlaybackStartBarrier();
331
+ this.cancelRecordedAudio();
332
+ this.cancelStructuralPause();
333
+ this.releasePauseHold();
334
+ }
335
+ /**
336
+ * Start a run and enter loading. From here a pause or stop applies to the
337
+ * run, whatever it is still waiting for: the provider, its content or the
338
+ * provider's audio.
339
+ */
340
+ beginRun(owner) {
341
+ // A recorded clip that starts the new run would otherwise play over the
342
+ // previous run's synthesis, which only the provider's next speak stops.
343
+ const supersedesPlayback = this.state === PlaybackState.LOADING ||
344
+ this.state === PlaybackState.PLAYING ||
345
+ this.state === PlaybackState.PAUSED;
346
+ const runId = ++this.speakRunId;
347
+ this.runOwner = owner;
348
+ this.abandonRun();
349
+ if (supersedesPlayback)
350
+ this.provider?.stop();
351
+ // Announced even from loading, so a listener sees the owner change.
352
+ this.setState(PlaybackState.LOADING, true);
353
+ return runId;
354
+ }
355
+ /** The run's audio is sounding; under a pause, it sounds again on resume. */
356
+ markPlaying() {
357
+ if (this.state === PlaybackState.PAUSED) {
358
+ this.resumeState = PlaybackState.PLAYING;
359
+ return;
360
+ }
361
+ this.setState(PlaybackState.PLAYING);
362
+ }
363
+ assertNotDisposed() {
364
+ if (this.disposed) {
365
+ throw toTTSStartFailure(new Error("TTS service disposed"));
366
+ }
367
+ }
194
368
  async emitTelemetry(eventName, payload) {
195
369
  try {
196
370
  await this.telemetryReporter?.(eventName, payload);
@@ -201,77 +375,41 @@ export class TTSService {
201
375
  }
202
376
  /**
203
377
  * Initialize TTS service with a provider
204
- *
205
- * @param provider TTS provider instance
206
- * @param config Provider configuration
207
378
  */
208
379
  async initialize(provider, config = {}) {
380
+ if (this.disposed)
381
+ throw new Error("TTS service disposed");
209
382
  this.currentProvider = provider;
210
383
  this.ttsConfig = { ...config };
211
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
212
- ? config.providerOptions
213
- : {};
214
- this.telemetryReporter =
215
- typeof providerOptions.__pieTelemetry === "function"
216
- ? providerOptions.__pieTelemetry
217
- : null;
218
- // Initialize provider and get implementation. Browser fallback is only
219
- // allowed here, before the configured provider has successfully become
220
- // the active runtime. Per-call playback/synthesis errors must surface
221
- // instead of silently replacing a user-selected provider and voice.
222
- try {
223
- this.provider = await provider.initialize(config);
224
- }
225
- catch (error) {
226
- if (provider.providerId.toLowerCase() === "browser") {
227
- throw error;
228
- }
229
- if (!this.isBrowserSpeechFallbackAvailable()) {
230
- throw error;
231
- }
232
- const switched = await this.switchProviderToBrowser(error, {
233
- operation: "tts-initialize",
234
- });
235
- if (!switched) {
236
- throw error;
237
- }
238
- }
384
+ this.hostLocales = hostLocalesOf(config.providerOptions);
385
+ const telemetry = this.providerOptions().__pieTelemetry;
386
+ this.telemetryReporter = typeof telemetry === "function" ? telemetry : null;
387
+ // A provider that fails to start throws to the caller, which owns any
388
+ // fallback. Playback and synthesis errors surface the same way.
389
+ this.provider = await provider.initialize(config);
239
390
  }
240
391
  /**
241
- * Update TTS settings dynamically without full reinitialization
242
- *
243
- * This allows changing rate, pitch, and voice on the fly.
244
- * Note: Some providers may require reinitialization for voice changes.
245
- *
246
- * @param settings Partial settings to update (rate, pitch, voice)
392
+ * Update rate, pitch, voice or provider options without reinitializing.
393
+ * `providerOptions` merge over the ones already configured.
247
394
  */
248
395
  async updateSettings(settings) {
249
396
  if (!this.provider) {
250
397
  throw new Error("TTSService not initialized. Call initialize() first.");
251
398
  }
252
- // If the provider implementation has an updateSettings method, use it
253
- const provider = this.provider;
254
- if (typeof provider.updateSettings === "function") {
255
- const mergedSettings = {
256
- ...settings,
257
- ...(settings.providerOptions
258
- ? {
259
- providerOptions: {
260
- ...(this.ttsConfig.providerOptions || {}),
261
- ...settings.providerOptions,
262
- },
263
- }
264
- : {}),
265
- };
266
- await provider.updateSettings(mergedSettings);
267
- this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
268
- }
269
- else {
270
- // Fallback: Reinitialize with merged config
271
- // This requires storing the original config, which we don't have
272
- // So for now, just log a warning
273
- console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
274
- }
399
+ const mergedSettings = {
400
+ ...settings,
401
+ ...(settings.providerOptions
402
+ ? {
403
+ providerOptions: {
404
+ ...this.providerOptions(),
405
+ ...settings.providerOptions,
406
+ },
407
+ }
408
+ : {}),
409
+ };
410
+ await this.provider.updateSettings(mergedSettings);
411
+ this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
412
+ this.hostLocales = hostLocalesOf(settings.providerOptions, this.hostLocales);
275
413
  }
276
414
  /**
277
415
  * Set highlight coordinator for word highlighting
@@ -279,6 +417,29 @@ export class TTSService {
279
417
  setHighlightCoordinator(coordinator) {
280
418
  this.highlightCoordinator = coordinator;
281
419
  }
420
+ /**
421
+ * Starts the service when a caller speaks before it is initialized; a gate
422
+ * that rejects fails the speak with its error. The coordinator installs one
423
+ * that runs its own text-to-speech start.
424
+ */
425
+ setReadinessGate(gate) {
426
+ this.readinessGate = gate;
427
+ }
428
+ /**
429
+ * Where the configured math speech options come from before, and apart from,
430
+ * initialization, so math names do not depend on when speech started.
431
+ */
432
+ setMathSpeechSource(source) {
433
+ this.mathSpeechSource = source;
434
+ }
435
+ /** The start a speak must wait for, or null when it can speak now without yielding. */
436
+ pendingReadiness() {
437
+ if (this.provider || !this.readinessGate)
438
+ return null;
439
+ return this.readinessGate().catch((error) => {
440
+ throw toTTSStartFailure(error);
441
+ });
442
+ }
282
443
  /**
283
444
  * Set a late-bound provider for optional host TTS highlight target remapping.
284
445
  *
@@ -307,37 +468,10 @@ export class TTSService {
307
468
  *
308
469
  * When set, the speak() method will check for pre-authored spoken
309
470
  * content in catalogs before falling back to generated TTS.
310
- *
311
- * @param resolver AccessibilityCatalogResolver instance
312
471
  */
313
472
  setCatalogResolver(resolver) {
314
473
  this.catalogResolver = resolver;
315
474
  }
316
- /**
317
- * Whether a catalog holds spoken content this service could actually speak.
318
- *
319
- * Exists because `data-catalog-idref` is one attribute with several readers:
320
- * a node can be docked to a catalog that carries only a signing card, and the
321
- * nearest docked ancestor of a selection is therefore not necessarily the one
322
- * holding its authored SSML. A caller resolving a selection climbs ancestors
323
- * and asks this before settling on an id — see the TTS tool's
324
- * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
325
- * silently shadows authored speech on an outer one.
326
- */
327
- hasSpokenAlternate(catalogId, language = "en-US") {
328
- if (!catalogId || !this.catalogResolver)
329
- return false;
330
- const resolved = this.catalogResolver.getAlternative(catalogId, {
331
- type: "spoken",
332
- language,
333
- useFallback: true,
334
- // The string form specifically. A node may also carry a recording of the
335
- // same script; that one is not what this reports on, and card order must
336
- // not decide the answer.
337
- form: "content",
338
- });
339
- return resolved?.content !== undefined;
340
- }
341
475
  getHighlightResolverRuntime() {
342
476
  let provided = null;
343
477
  try {
@@ -364,12 +498,12 @@ export class TTSService {
364
498
  return null;
365
499
  return node.nodeType === Node.ELEMENT_NODE
366
500
  ? node
367
- : node.parentElement;
501
+ : composedParentElement(node);
368
502
  }
369
503
  isElementWithinScope(element, scope) {
370
504
  if (!scope)
371
505
  return true;
372
- return element === scope || scope.contains(element);
506
+ return composedContains(scope, element);
373
507
  }
374
508
  isRangeWithinScope(range, scope) {
375
509
  const startElement = this.getNodeElement(range.startContainer);
@@ -444,28 +578,21 @@ export class TTSService {
444
578
  return null;
445
579
  }
446
580
  }
447
- paintTTSWordRange(range) {
448
- if (!this.highlightCoordinator)
581
+ /**
582
+ * Paint the spoken word, each range offered to the host's resolver. A word
583
+ * split across inline elements is one range over its text nodes; across
584
+ * shadow trees, one range per tree.
585
+ */
586
+ paintTTSWord(nativeRanges) {
587
+ if (!this.highlightCoordinator || nativeRanges.length === 0)
449
588
  return;
450
- try {
451
- if (range.startContainer === range.endContainer &&
452
- range.startContainer.nodeType === Node.TEXT_NODE) {
453
- this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
454
- return;
455
- }
456
- }
457
- catch {
458
- // Fall through to generic range painting for lightweight test doubles.
459
- }
460
- this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
589
+ this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
461
590
  }
462
591
  paintTTSSentenceRanges(nativeRanges) {
463
592
  if (!this.highlightCoordinator)
464
593
  return;
465
594
  const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
466
- if (elements.length > 0 &&
467
- ranges.length === 0 &&
468
- this.highlightCoordinator.highlightTTSSentenceElements) {
595
+ if (elements.length > 0 && ranges.length === 0) {
469
596
  this.highlightCoordinator.highlightTTSSentenceElements(elements);
470
597
  return;
471
598
  }
@@ -477,42 +604,123 @@ export class TTSService {
477
604
  }
478
605
  this.highlightCoordinator.highlightTTSSentence(paintedRanges);
479
606
  }
607
+ providerOptions() {
608
+ const options = this.ttsConfig.providerOptions;
609
+ return options && typeof options === "object" ? options : {};
610
+ }
611
+ /** The longest text the provider accepts in one speak, when it declares one. */
612
+ maxTextLength() {
613
+ const limit = this.currentProvider?.getCapabilities?.().maxTextLength;
614
+ return typeof limit === "number" && limit > 0 ? limit : undefined;
615
+ }
480
616
  /**
481
- * Get provider capabilities
617
+ * Speak `text`, in pieces when it exceeds the provider's `maxTextLength`. Word
618
+ * boundaries keep indexing the whole text. An SSML document cannot be cut, so
619
+ * one over the limit fails and the chunk's plain fallback reads instead.
482
620
  */
483
- getCapabilities() {
484
- return this.currentProvider?.getCapabilities() || null;
621
+ async speakWithinLimit(text, runId) {
622
+ const provider = this.provider;
623
+ if (!provider)
624
+ return;
625
+ const limit = this.maxTextLength();
626
+ if (!limit || text.length <= limit) {
627
+ await this.waitWhilePaused(runId);
628
+ if (runId !== this.speakRunId || this.provider !== provider)
629
+ return;
630
+ await provider.speak(text);
631
+ return;
632
+ }
633
+ if (isSsmlDocument(text)) {
634
+ throw new Error(`[tts] SSML of ${text.length} characters exceeds the provider's limit of ${limit}`);
635
+ }
636
+ const onWordBoundary = provider.onWordBoundary;
637
+ try {
638
+ for (const piece of splitTextToLength(text, limit, {
639
+ locale: this.readLocale,
640
+ })) {
641
+ await this.waitWhilePaused(runId);
642
+ if (runId !== this.speakRunId || this.provider !== provider)
643
+ return;
644
+ provider.onWordBoundary = onWordBoundary
645
+ ? (word, position, length) => onWordBoundary(word, position + piece.offset, length)
646
+ : undefined;
647
+ await provider.speak(piece.text);
648
+ }
649
+ }
650
+ finally {
651
+ if (this.provider === provider && runId === this.speakRunId) {
652
+ provider.onWordBoundary = onWordBoundary;
653
+ }
654
+ }
655
+ }
656
+ /** Segments no longer than the provider's `maxTextLength`, offsets kept. */
657
+ segmentsWithinLimit(segments) {
658
+ const limit = this.maxTextLength();
659
+ if (!limit)
660
+ return segments;
661
+ return segments.flatMap((segment) => {
662
+ if (segment.text.length <= limit)
663
+ return [segment];
664
+ const pieces = splitTextToLength(segment.text, limit, {
665
+ locale: this.readLocale,
666
+ });
667
+ return pieces.map((piece, index) => ({
668
+ text: piece.text,
669
+ startOffset: segment.startOffset + piece.offset,
670
+ pauseMsAfter: index === pieces.length - 1 ? segment.pauseMsAfter : undefined,
671
+ }));
672
+ });
485
673
  }
486
674
  /**
487
- * Build a character-by-character map from normalized text positions to DOM positions
488
- * 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.
489
677
  */
490
- getTextProcessingOptions(language) {
491
- const providerOptions = (this.ttsConfig.providerOptions || {});
492
- const textNormalization = (providerOptions.textNormalization ||
493
- {});
494
- const mode = textNormalization.boundarySpacingMode;
678
+ getTextProcessingOptions(locale) {
679
+ const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
495
680
  const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
496
681
  ? mode
497
682
  : undefined;
498
- const locale = language ||
499
- (typeof textNormalization.locale === "string"
500
- ? textNormalization.locale
501
- : undefined);
502
683
  return { locale, boundarySpacingMode };
503
684
  }
685
+ /**
686
+ * The language a read of `node` is in. Its content language is the nearest
687
+ * `lang` between it and its shell, else the language the speak names, else a
688
+ * pinned `lang_id`. Its locale is that content language, else the host's
689
+ * configured locale, else en-US.
690
+ */
691
+ resolveReadLanguage(node, named) {
692
+ const contentLanguage = findContentLanguage(node, { contentLanguage: named }) ??
693
+ optionalString(this.providerOptions().lang_id);
694
+ return {
695
+ contentLanguage,
696
+ locale: contentLanguage ??
697
+ this.hostLocales.locale ??
698
+ this.hostLocales.textNormalization ??
699
+ DEFAULT_CONTENT_LANGUAGE,
700
+ };
701
+ }
702
+ /**
703
+ * The host's SRE options for math speech: from the installed source, else as
704
+ * this service was initialized with.
705
+ */
504
706
  getMathSpeechOptions() {
505
- const providerOptions = (this.ttsConfig.providerOptions || {});
506
- const mathSpeech = providerOptions.mathSpeech;
507
- return normalizeSREMathSpeechOptions(mathSpeech);
707
+ if (this.mathSpeechSource) {
708
+ return normalizeSREMathSpeechOptions(this.mathSpeechSource());
709
+ }
710
+ return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
508
711
  }
712
+ /**
713
+ * Rebuilds normalizedToDOM from `element`'s visible text; traced runs also
714
+ * log how it differs from `spokenText`.
715
+ */
509
716
  buildPositionMap(element, spokenText, language) {
510
717
  this.normalizedToDOM.clear();
511
718
  const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
512
719
  this.normalizedToDOM = map;
513
- if (!TTSService.debugEnabled)
720
+ // The comparison below diffs the whole text, so it runs only when traced.
721
+ if (!isTtsDebugEnabled())
514
722
  return;
515
- this.debugLog("[TTSService] Text comparison:", {
723
+ logger.debug("Text comparison:", {
516
724
  spokenLength: spokenText.length,
517
725
  normalizedDomLength: normalizedDomText.length,
518
726
  match: spokenText === normalizedDomText,
@@ -520,22 +728,9 @@ export class TTSService {
520
728
  normalizedPreview: normalizedDomText.substring(0, 150),
521
729
  differAtIndex: spokenText === normalizedDomText
522
730
  ? null
523
- : (() => {
524
- for (let i = 0; i < Math.min(spokenText.length, normalizedDomText.length); i++) {
525
- if (spokenText[i] !== normalizedDomText[i]) {
526
- return {
527
- index: i,
528
- spokenChar: spokenText[i],
529
- normalizedChar: normalizedDomText[i],
530
- spokenContext: spokenText.substring(Math.max(0, i - 20), i + 20),
531
- normalizedContext: normalizedDomText.substring(Math.max(0, i - 20), i + 20),
532
- };
533
- }
534
- }
535
- return null;
536
- })(),
731
+ : firstDifference(spokenText, normalizedDomText),
537
732
  });
538
- this.debugLog("[TTSService] Position map built:", {
733
+ logger.debug("Position map built:", {
539
734
  entries: this.normalizedToDOM.size,
540
735
  spokenTextLength: spokenText.length,
541
736
  normalizedDomLength: normalizedDomText.length,
@@ -551,9 +746,8 @@ export class TTSService {
551
746
  });
552
747
  }
553
748
  getStructuralPauseProfile() {
554
- const providerOptions = (this.ttsConfig.providerOptions || {});
555
- const custom = (providerOptions.structuralPauses || {});
556
- const customUnits = (custom.units || {});
749
+ const custom = this.providerOptions().structuralPauses || {};
750
+ const customUnits = custom.units || {};
557
751
  const toNumber = (value, fallback) => {
558
752
  if (typeof value === "number" && Number.isFinite(value))
559
753
  return value;
@@ -563,47 +757,42 @@ export class TTSService {
563
757
  }
564
758
  return fallback;
565
759
  };
760
+ const defaults = DEFAULT_STRUCTURAL_PAUSE_PROFILE;
566
761
  return {
567
- baseMs: toNumber(custom.baseMs, 280),
762
+ baseMs: toNumber(custom.baseMs, defaults.baseMs),
568
763
  units: {
569
- minor: toNumber(customUnits.minor, 0.65),
570
- section: toNumber(customUnits.section, 1.0),
571
- 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),
572
767
  },
573
- minMs: toNumber(custom.minMs, 120),
574
- maxMs: toNumber(custom.maxMs, 900),
768
+ minMs: toNumber(custom.minMs, defaults.minMs),
769
+ maxMs: toNumber(custom.maxMs, defaults.maxMs),
575
770
  };
576
771
  }
577
772
  resolvePauseMsFromUnits(units) {
578
773
  const profile = this.getStructuralPauseProfile();
579
- 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)));
580
775
  const raw = (profile.baseMs * Math.max(0, units)) / rate;
581
776
  return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
582
777
  }
583
778
  resolveHighlightMode() {
584
- const providerOptions = (this.ttsConfig.providerOptions || {});
585
- const configuredMode = providerOptions.highlightMode;
779
+ const configuredMode = this.providerOptions().highlightMode;
586
780
  if (configuredMode === "word" || configuredMode === "sentence") {
587
781
  return configuredMode;
588
782
  }
589
- const providerId = (this.currentProvider?.providerId || "").toLowerCase();
590
- if (providerId === "browser") {
591
- return "sentence";
783
+ const capabilities = this.currentProvider?.getCapabilities();
784
+ if (capabilities?.defaultHighlightMode) {
785
+ return capabilities.defaultHighlightMode;
592
786
  }
593
- const supportsWordBoundary = this.currentProvider?.getCapabilities().supportsWordBoundary ?? false;
594
- return supportsWordBoundary ? "word" : "sentence";
787
+ return capabilities?.supportsWordBoundary ? "word" : "sentence";
595
788
  }
596
789
  hasExplicitBreakSemantics(text) {
597
790
  if (!text.includes("<"))
598
791
  return false;
599
792
  return /<\s*break\b|<\s*speak\b|<\s*prosody\b|<\s*p\b|<\s*s\b/i.test(text);
600
793
  }
601
- isElementHidden(element) {
602
- return isElementHiddenForTTS(element);
603
- }
604
794
  getBoundaryStrength(element) {
605
- const explicitBreakMs = Number(element.getAttribute("data-tts-break-ms"));
606
- if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
795
+ if (explicitBreakMsOf(element) !== null)
607
796
  return "major";
608
797
  const tagName = element.tagName.toUpperCase();
609
798
  if (tagName.match(/^H[1-6]$/))
@@ -618,26 +807,16 @@ export class TTSService {
618
807
  tagName === "LI") {
619
808
  return "section";
620
809
  }
621
- if (typeof window !== "undefined") {
622
- const display = window.getComputedStyle(element).display;
623
- if (display === "block" ||
624
- display === "list-item" ||
625
- display.startsWith("table") ||
626
- display === "flex" ||
627
- display === "grid") {
628
- return "minor";
629
- }
630
- }
631
810
  return "minor";
632
811
  }
633
812
  getBoundaryAnchor(textNode, root) {
634
- let current = textNode.parentElement;
813
+ let current = flatTreeParentElement(textNode);
635
814
  let best = null;
636
815
  while (current && current !== root) {
637
- if (this.isElementHidden(current))
816
+ if (isElementHiddenForTTS(current))
638
817
  return null;
639
- const explicitBreakMs = Number(current.getAttribute("data-tts-break-ms"));
640
- if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0) {
818
+ const explicitBreakMs = explicitBreakMsOf(current);
819
+ if (explicitBreakMs !== null) {
641
820
  return {
642
821
  anchor: current,
643
822
  units: explicitBreakMs / this.getStructuralPauseProfile().baseMs,
@@ -652,19 +831,9 @@ export class TTSService {
652
831
  best = current;
653
832
  break;
654
833
  }
655
- if (!best) {
656
- if (typeof window !== "undefined") {
657
- const display = window.getComputedStyle(current).display;
658
- if (display === "block" ||
659
- display === "list-item" ||
660
- display.startsWith("table") ||
661
- display === "flex" ||
662
- display === "grid") {
663
- best = current;
664
- }
665
- }
666
- }
667
- current = current.parentElement;
834
+ if (!best && isBlockDisplay(current))
835
+ best = current;
836
+ current = flatTreeParentElement(current);
668
837
  }
669
838
  if (!best)
670
839
  return null;
@@ -679,8 +848,8 @@ export class TTSService {
679
848
  * boundaries are collected over the whole element, and a selection speaks only
680
849
  * part of it.
681
850
  */
682
- createSpeechPlan(contentElement, normalizedText, textOffset = 0) {
683
- const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText);
851
+ createSpeechPlan(contentElement, normalizedText, textOffset, language) {
852
+ const boundaries = this.collectSpeechPlanBoundaries(contentElement, language);
684
853
  if (textOffset === 0) {
685
854
  return this.createSpeechPlanSegments(normalizedText, boundaries);
686
855
  }
@@ -690,18 +859,17 @@ export class TTSService {
690
859
  }
691
860
  return this.createSpeechPlanSegments(normalizedText, shifted);
692
861
  }
693
- collectSpeechPlanBoundaries(contentElement, normalizedText) {
862
+ collectSpeechPlanBoundaries(contentElement, language) {
694
863
  const boundaries = new Map();
695
- const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions());
864
+ // The same locale the position map used, so the boundary offsets index the
865
+ // text the map built.
866
+ const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions(language));
696
867
  const nodeStartOffsets = this.createNodeStartOffsets(map);
697
- const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
698
- let currentNode = walker.nextNode();
699
868
  let previousBoundaryAnchor = null;
700
- while (currentNode) {
701
- const textNode = currentNode;
702
- const parent = textNode.parentElement;
869
+ for (const textNode of flatTreeTextNodes(contentElement)) {
870
+ const parent = flatTreeParentElement(textNode);
703
871
  if (parent &&
704
- !this.isElementHidden(parent) &&
872
+ !isElementHiddenForTTS(parent) &&
705
873
  !isNodeSuppressedForTTS(textNode)) {
706
874
  const boundary = this.getBoundaryAnchor(textNode, contentElement);
707
875
  const boundaryPoint = nodeStartOffsets.get(textNode);
@@ -713,7 +881,6 @@ export class TTSService {
713
881
  previousBoundaryAnchor = boundary.anchor;
714
882
  }
715
883
  }
716
- currentNode = walker.nextNode();
717
884
  }
718
885
  return boundaries;
719
886
  }
@@ -733,11 +900,7 @@ export class TTSService {
733
900
  const segments = [];
734
901
  let start = 0;
735
902
  for (const point of points) {
736
- const raw = normalizedText.substring(start, point);
737
- const leading = raw.match(/^\s*/)?.[0].length || 0;
738
- const trailing = raw.match(/\s*$/)?.[0].length || 0;
739
- const segmentStart = start + leading;
740
- const segmentEnd = point - trailing;
903
+ const { start: segmentStart, end: segmentEnd } = trimSpan(normalizedText, start, point);
741
904
  if (segmentEnd > segmentStart) {
742
905
  const text = normalizedText.substring(segmentStart, segmentEnd);
743
906
  segments.push({
@@ -761,9 +924,7 @@ export class TTSService {
761
924
  return segments.filter((segment) => segment.text.trim().length > 0);
762
925
  }
763
926
  segmentSentences(text) {
764
- const locale = this.ttsConfig.providerOptions
765
- ?.locale || undefined;
766
- return segmentTextToSentences(text, { locale });
927
+ return segmentTextToSentences(text, { locale: this.readLocale });
767
928
  }
768
929
  createSeekSegmentsFromText(text) {
769
930
  return this.segmentSentences(text)
@@ -797,11 +958,7 @@ export class TTSService {
797
958
  }
798
959
  let cursor = segmentStart;
799
960
  for (const point of [...splitPoints, segmentEnd]) {
800
- const raw = sourceText.substring(cursor, point);
801
- const leadingWhitespace = raw.match(/^\s*/)?.[0].length || 0;
802
- const trailingWhitespace = raw.match(/\s*$/)?.[0].length || 0;
803
- const startOffset = cursor + leadingWhitespace;
804
- const endOffset = point - trailingWhitespace;
961
+ const { start: startOffset, end: endOffset } = trimSpan(sourceText, cursor, point);
805
962
  if (endOffset > startOffset) {
806
963
  result.push({
807
964
  text: sourceText.substring(startOffset, endOffset),
@@ -825,6 +982,9 @@ export class TTSService {
825
982
  return this.splitSegmentsAtBoundaries(grammaticalSegments, structuralBoundaries, args.contentToSpeak);
826
983
  }
827
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.
828
988
  let offset = 0;
829
989
  return chunks.map((chunk) => {
830
990
  const speechText = chunk.plainFallback?.speechText ||
@@ -838,23 +998,12 @@ export class TTSService {
838
998
  startOffset: offset,
839
999
  pauseMsAfter: 0,
840
1000
  };
841
- offset += Math.max(1, text.length + 1);
1001
+ offset += text.length + 1;
842
1002
  return segment;
843
1003
  });
844
1004
  }
845
1005
  getCurrentSeekSegmentIndex() {
846
- if (this.seekSegments.length === 0)
847
- return 0;
848
- let index = 0;
849
- for (let i = 0; i < this.seekSegments.length; i++) {
850
- if (this.seekSegments[i].startOffset <= this.currentBoundaryOffset) {
851
- index = i;
852
- }
853
- else {
854
- break;
855
- }
856
- }
857
- return index;
1006
+ return Math.max(0, this.getSegmentIndexForOffset(this.seekSegments, this.currentBoundaryOffset));
858
1007
  }
859
1008
  async speakWithPlan(segments, runId, options) {
860
1009
  if (!this.provider || segments.length === 0)
@@ -863,6 +1012,10 @@ export class TTSService {
863
1012
  const providerWithPlan = this.provider;
864
1013
  if (!shouldTrackSentenceProgress &&
865
1014
  typeof providerWithPlan.speakSegments === "function") {
1015
+ await this.waitWhilePaused(runId);
1016
+ if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
1017
+ return;
1018
+ }
866
1019
  this.currentBoundaryOffset = 0;
867
1020
  const originalOnWordBoundary = this.provider.onWordBoundary;
868
1021
  this.provider.onWordBoundary = (word, position, length) => {
@@ -872,7 +1025,7 @@ export class TTSService {
872
1025
  }
873
1026
  };
874
1027
  try {
875
- await providerWithPlan.speakSegments(segments);
1028
+ await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
876
1029
  }
877
1030
  finally {
878
1031
  if (runId === this.speakRunId && this.provider === providerWithPlan) {
@@ -886,18 +1039,28 @@ export class TTSService {
886
1039
  wordBoundaryOffset: this.activeWordBoundaryOffset,
887
1040
  });
888
1041
  for (const segment of segments) {
1042
+ await this.waitWhilePaused(runId);
889
1043
  if (runId !== this.speakRunId)
890
1044
  return;
891
1045
  this.currentBoundaryOffset = segment.startOffset;
1046
+ const highlightSegment = () => {
1047
+ this.highlightSentenceSegment(segment.startOffset, segment.text);
1048
+ };
892
1049
  if (shouldTrackSentenceProgress) {
893
- this.runWhenPlaybackStarts(runId, () => {
894
- this.highlightSentenceSegment(segment.startOffset, segment.text);
895
- });
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
+ }
896
1060
  }
897
- await this.provider.speak(segment.text);
898
1061
  const pauseMs = segment.pauseMsAfter ?? 0;
899
- if (pauseMs > 0) {
900
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
1062
+ if (pauseMs > 0 && runId === this.speakRunId) {
1063
+ await this.waitStructuralPause(pauseMs);
901
1064
  }
902
1065
  }
903
1066
  }
@@ -917,10 +1080,11 @@ export class TTSService {
917
1080
  const end = this.normalizedToDOM.get(mapStart + length - 1);
918
1081
  if (!start || !end)
919
1082
  return;
920
- const range = document.createRange();
921
- range.setStart(start.node, start.offset);
922
- range.setEnd(end.node, end.offset + 1);
923
- this.paintTTSSentenceRanges([range]);
1083
+ // One range per tree: a sentence running into a shadow root paints in both.
1084
+ const ranges = createRangesFromVisibleMap(this.normalizedToDOM, mapStart, mapStart + length);
1085
+ if (ranges.length === 0)
1086
+ return;
1087
+ this.paintTTSSentenceRanges(ranges);
924
1088
  this.activeSentenceStartOffset = startOffset;
925
1089
  }
926
1090
  getSegmentIndexForOffset(segments, offset) {
@@ -952,66 +1116,180 @@ export class TTSService {
952
1116
  this.highlightSentenceSegment(segment.startOffset, segment.text);
953
1117
  }
954
1118
  /**
955
- * Find text node and offsets for highlighting a word
1119
+ * Read `target` aloud: a range reads the text it selects, an element its
1120
+ * content. A node with a spoken card that the target holds whole reads its
1121
+ * card, `catalogId` names the card of the content root, and math reads as
1122
+ * math speech.
956
1123
  *
957
- * @param charIndex Character position in normalized/spoken text
958
- * @param length Length of the word
959
- * @returns Text node and local offsets, or null if not found
1124
+ * Content marked not-to-be-spoken is never read, a named card included. When
1125
+ * the target holds nothing speakable, nothing is spoken and playback already
1126
+ * running continues.
960
1127
  */
961
- findHighlightRange(charIndex, length) {
962
- const startPos = this.normalizedToDOM.get(charIndex);
963
- if (!startPos) {
964
- console.warn(`[TTSService] No mapping found for start position ${charIndex}`, {
965
- totalMappings: this.normalizedToDOM.size,
966
- nearbyMappings: Array.from(this.normalizedToDOM.entries())
967
- .filter(([pos]) => Math.abs(pos - charIndex) < 5)
968
- .map(([pos, { offset, node }]) => ({
969
- pos,
970
- offset,
971
- char: node.textContent?.[offset],
972
- })),
973
- });
1128
+ async speak(target, options = {}) {
1129
+ this.assertNotDisposed();
1130
+ const pendingReadiness = this.pendingReadiness();
1131
+ // A speak waiting on its provider is already the current run, so a pause
1132
+ // or stop issued meanwhile applies to it.
1133
+ const owner = options.ownerId ?? null;
1134
+ const waitingRunId = pendingReadiness ? this.beginRun(owner) : null;
1135
+ if (pendingReadiness) {
1136
+ try {
1137
+ await pendingReadiness;
1138
+ }
1139
+ catch (error) {
1140
+ if (waitingRunId === this.speakRunId)
1141
+ this.setState(PlaybackState.IDLE);
1142
+ throw error;
1143
+ }
1144
+ this.assertNotDisposed();
1145
+ if (waitingRunId !== this.speakRunId)
1146
+ return;
1147
+ }
1148
+ if (!this.provider) {
1149
+ if (waitingRunId !== null)
1150
+ this.setState(PlaybackState.IDLE);
1151
+ throw toTTSStartFailure(new Error("TTS service not initialized"));
1152
+ }
1153
+ const language = this.resolveReadLanguage(isRange(target) ? target.startContainer : target, options.language);
1154
+ const content = isRange(target)
1155
+ ? this.resolveRangeTarget(target, options, language)
1156
+ : this.resolveElementTarget(target, options, language);
1157
+ if (!content) {
1158
+ if (waitingRunId !== null)
1159
+ this.setState(PlaybackState.IDLE);
1160
+ return;
1161
+ }
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 });
1181
+ }
1182
+ /**
1183
+ * Suppression is read from the live ancestors of what is read, because the
1184
+ * element carrying it may sit above the content root.
1185
+ */
1186
+ isSuppressedTarget(node) {
1187
+ if (!isNodeSuppressedForTTS(node))
1188
+ return false;
1189
+ console.warn("[tts] the content to read lies inside content marked not-to-be-spoken; nothing was spoken.");
1190
+ return true;
1191
+ }
1192
+ resolveElementTarget(root, options, language) {
1193
+ if (this.isSuppressedTarget(root))
1194
+ return null;
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.
1204
+ const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
1205
+ if (!text && !this.holdsSpokenCard(root, contentOptions)) {
1206
+ console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
974
1207
  return null;
975
1208
  }
976
- // Find the end position (last character of the word)
977
- const endIndex = charIndex + length - 1;
978
- const endPos = this.normalizedToDOM.get(endIndex);
979
- if (!endPos) {
980
- console.warn(`[TTSService] No mapping found for end position ${endIndex}`, {
981
- startChar: charIndex,
982
- length,
983
- totalMappings: this.normalizedToDOM.size,
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",
984
1222
  });
1223
+ if (card?.content !== undefined)
1224
+ return true;
1225
+ }
1226
+ return this.collectCatalogSpeechChunks(root, options).some((chunk) => chunk.sourceElement);
1227
+ }
1228
+ resolveRangeTarget(range, options, language) {
1229
+ if (this.isSuppressedTarget(range.commonAncestorContainer))
1230
+ return null;
1231
+ // Highlighting stays scoped to the content root when one is named, else to
1232
+ // the selection's nearest element.
1233
+ const ancestor = range.commonAncestorContainer;
1234
+ const root = options.contentRoot ||
1235
+ (ancestor.nodeType === Node.ELEMENT_NODE
1236
+ ? ancestor
1237
+ : composedParentElement(ancestor));
1238
+ if (!root)
1239
+ return null;
1240
+ const selected = collectRangeTextForSpeech(range, root);
1241
+ const selectedText = selected.text.trim();
1242
+ if (!selectedText) {
1243
+ if (selected.filtered) {
1244
+ console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1245
+ }
985
1246
  return null;
986
1247
  }
987
- // For simplicity, if the word spans multiple nodes, just highlight in the first node
988
- // (This is a rare edge case and would require creating multiple ranges)
989
- if (startPos.node !== endPos.node) {
990
- console.warn(`[TTSService] Word spans multiple nodes, highlighting in first node only`);
991
- return {
992
- node: startPos.node,
993
- start: startPos.offset,
994
- end: (startPos.node.textContent || "").length,
995
- };
1248
+ // The spoken text and its offset come from the root's visible-text map, which
1249
+ // the highlight position map and the structural speech plan index too. Joined
1250
+ // from raw text nodes instead, a selection loses the space the map inserts
1251
+ // between blocks, and every offset after that boundary drifts.
1252
+ const mapped = this.selectMappedRangeText(range, root, language.locale);
1253
+ let text = selectedText;
1254
+ let offset = mapped?.offset ?? 0;
1255
+ if (mapped) {
1256
+ text = mapped.text;
996
1257
  }
1258
+ else {
1259
+ // Filtered the same way as the speech itself: the offset indexes into the
1260
+ // highlight text, which comes from the exclusion-aware collectors.
1261
+ const beforeRange = document.createRange();
1262
+ beforeRange.selectNodeContents(root);
1263
+ beforeRange.setEnd(range.startContainer, range.startOffset);
1264
+ const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
1265
+ const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
1266
+ offset =
1267
+ normalizedTextBeforeRange.length +
1268
+ (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
1269
+ }
1270
+ logger.debug("selection offset calculation:", {
1271
+ selectedText: text,
1272
+ mapped: !!mapped,
1273
+ offset,
1274
+ rootTag: root.tagName,
1275
+ });
997
1276
  return {
998
- node: startPos.node,
999
- start: startPos.offset,
1000
- end: endPos.offset + 1, // +1 because we want to include the character at endPos
1277
+ text,
1278
+ options: {
1279
+ // The root's card reads only when the selection holds all of the root.
1280
+ catalogId: options.catalogId && rangeHoldsSpeakableElement(range, root, root)
1281
+ ? options.catalogId
1282
+ : undefined,
1283
+ catalogContext: options.catalogContext,
1284
+ language: language.locale,
1285
+ contentLanguage: language.contentLanguage,
1286
+ contentElement: root,
1287
+ contentRange: range,
1288
+ wordBoundaryOffset: offset,
1289
+ },
1001
1290
  };
1002
1291
  }
1003
- /**
1004
- * Speak text with optional catalog support
1005
- *
1006
- * @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
1007
- * @param options Optional catalog ID, language, and content element for highlighting
1008
- */
1009
- async speak(text, options) {
1010
- if (!this.provider) {
1011
- throw new Error("TTS service not initialized");
1012
- }
1013
- const runId = ++this.speakRunId;
1014
- this.clearPlaybackStartBarrier();
1292
+ async speakContent(text, options, runId) {
1015
1293
  let playbackStartBarrier = null;
1016
1294
  try {
1017
1295
  await this.applyLanguageSettings(options);
@@ -1024,26 +1302,35 @@ export class TTSService {
1024
1302
  this.logResolvedSpeechContent({
1025
1303
  contentToSpeak,
1026
1304
  speechSource,
1027
- catalogId: options?.catalogId,
1305
+ catalogId: options.catalogId,
1028
1306
  });
1029
1307
  this.initializeSpeakTracking(highlightText, options);
1030
- const highlightMode = options?.highlightModeOverride ||
1031
- (speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
1308
+ const highlightMode = speechMatchesVisibleText
1309
+ ? this.resolveHighlightMode()
1310
+ : "sentence";
1032
1311
  this.activeHighlightMode = highlightMode;
1033
- this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
1312
+ const wordBoundaryOffset = options.wordBoundaryOffset || 0;
1313
+ this.activeWordBoundaryOffset = wordBoundaryOffset;
1034
1314
  const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
1035
1315
  const shouldUsePlan = !!this.currentContentElement &&
1036
1316
  !usedCatalogSpoken &&
1037
1317
  !hasExplicitBreaks &&
1038
1318
  speechMatchesVisibleText;
1039
1319
  this.playbackChunks = speechChunks?.length ? speechChunks : [];
1040
- this.seekSegments = this.playbackChunks.length
1041
- ? this.createSeekSegmentsFromSpeechChunks(this.playbackChunks)
1042
- : hasExplicitBreaks || !speechMatchesVisibleText
1043
- ? []
1044
- : shouldUsePlan && this.currentContentElement
1045
- ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0)
1046
- : 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
+ }
1047
1334
  this.sentenceHighlightSegments = hasExplicitBreaks
1048
1335
  ? []
1049
1336
  : this.createSentenceHighlightSegments({
@@ -1051,7 +1338,6 @@ export class TTSService {
1051
1338
  shouldUsePlan,
1052
1339
  playbackSegments: this.seekSegments,
1053
1340
  });
1054
- this.setState(PlaybackState.LOADING);
1055
1341
  playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
1056
1342
  this.prepareHighlightsForSpeak({
1057
1343
  contentToSpeak: highlightText,
@@ -1063,15 +1349,14 @@ export class TTSService {
1063
1349
  if (speechMatchesVisibleText) {
1064
1350
  this.configureWordBoundaryHighlighting({
1065
1351
  highlightMode,
1066
- wordBoundaryOffset: options?.wordBoundaryOffset || 0,
1352
+ wordBoundaryOffset,
1067
1353
  });
1068
1354
  }
1069
1355
  else {
1070
1356
  this.clearWordBoundaryHighlighting();
1071
1357
  }
1072
- if (!playbackStartBarrier) {
1073
- this.setState(PlaybackState.PLAYING);
1074
- }
1358
+ if (!playbackStartBarrier)
1359
+ this.markPlaying();
1075
1360
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
1076
1361
  await this.executeSpeakPlayback({
1077
1362
  shouldUsePlan,
@@ -1086,146 +1371,68 @@ export class TTSService {
1086
1371
  this.clearHighlightsAndTracking();
1087
1372
  }
1088
1373
  catch (error) {
1089
- let finalError = error;
1090
- console.error("TTS error:", finalError);
1091
- this.lastError =
1092
- finalError instanceof Error ? finalError.message : String(finalError);
1374
+ // A superseded run's failure is the abort that superseded it.
1093
1375
  if (runId !== this.speakRunId)
1094
1376
  return;
1377
+ console.error("TTS error:", error);
1378
+ this.lastError = error instanceof Error ? error.message : String(error);
1095
1379
  this.setState(PlaybackState.ERROR);
1096
1380
  this.clearHighlightsAndTracking();
1097
- throw finalError;
1381
+ throw error;
1098
1382
  }
1099
1383
  finally {
1100
1384
  this.clearPlaybackStartBarrier(playbackStartBarrier);
1101
1385
  }
1102
1386
  }
1103
- async switchProviderToBrowser(reason, context) {
1104
- if (!this.currentProvider)
1105
- return false;
1106
- const fallbackProvider = new BrowserTTSProvider();
1107
- const previousProviderId = this.currentProvider.providerId;
1108
- // Voice identifiers and provider extensions belong to the provider that
1109
- // rejected initialization. Carry only settings whose meaning is portable
1110
- // when crossing the provider boundary; otherwise a Polly/Google/SC voice
1111
- // such as "Joanna" can incorrectly become an explicit Browser voice.
1112
- const browserConfig = {};
1113
- if (this.ttsConfig.rate !== undefined) {
1114
- browserConfig.rate = this.ttsConfig.rate;
1115
- }
1116
- if (this.ttsConfig.pitch !== undefined) {
1117
- browserConfig.pitch = this.ttsConfig.pitch;
1118
- }
1119
- if (this.ttsConfig.mathTokenHighlighting !== undefined) {
1120
- browserConfig.mathTokenHighlighting =
1121
- this.ttsConfig.mathTokenHighlighting;
1122
- }
1123
- const operation = context?.operation || "tts-initialize";
1124
- const offendingText = context?.contentToSpeak ?? "";
1125
- const offendingPreview = offendingText
1126
- .replace(/\s+/g, " ")
1127
- .trim()
1128
- .slice(0, 120);
1129
- const offendingDiagnostic = offendingText.length > 0
1130
- ? {
1131
- textLength: offendingText.length,
1132
- textPreview: offendingPreview.length < offendingText.length
1133
- ? `${offendingPreview}…`
1134
- : offendingPreview,
1135
- }
1136
- : {};
1137
- try {
1138
- this.provider?.stop();
1139
- this.currentProvider.destroy();
1140
- this.currentProvider = fallbackProvider;
1141
- this.provider = await fallbackProvider.initialize(browserConfig);
1142
- this.ttsConfig = browserConfig;
1143
- await this.emitTelemetry("pie-tool-runtime-fallback", {
1144
- toolId: "textToSpeech",
1145
- operation,
1146
- fromProvider: previousProviderId,
1147
- toProvider: fallbackProvider.providerId,
1148
- reason: reason instanceof Error ? reason.message : String(reason),
1149
- ...offendingDiagnostic,
1150
- });
1151
- console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
1152
- fromProvider: previousProviderId,
1153
- reason,
1154
- ...offendingDiagnostic,
1155
- });
1156
- return true;
1157
- }
1158
- catch (fallbackError) {
1159
- await this.emitTelemetry("pie-tool-runtime-fallback-error", {
1160
- toolId: "textToSpeech",
1161
- operation,
1162
- fromProvider: previousProviderId,
1163
- toProvider: "browser",
1164
- errorType: "TTSRuntimeFallbackError",
1165
- message: fallbackError instanceof Error
1166
- ? fallbackError.message
1167
- : String(fallbackError),
1168
- });
1169
- console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
1170
- return false;
1171
- }
1172
- }
1173
- isBrowserSpeechFallbackAvailable() {
1174
- if (typeof window === "undefined")
1175
- return false;
1176
- if (!("speechSynthesis" in window))
1177
- return false;
1178
- return (typeof globalThis
1179
- .SpeechSynthesisUtterance === "function");
1180
- }
1387
+ /**
1388
+ * Hand the read's language to the provider. A read in a named content language
1389
+ * sets the provider's locales and `contentLanguage` to it, so a browser voice
1390
+ * follows it; a read naming none restores the host's locales and clears
1391
+ * `contentLanguage`, so the browser voice follows the browser's language.
1392
+ */
1181
1393
  async applyLanguageSettings(options) {
1182
- if (!options?.language || !this.provider)
1394
+ if (!this.provider)
1395
+ return;
1396
+ this.readLocale = options.language;
1397
+ const named = options.contentLanguage;
1398
+ const providerOptions = this.providerOptions();
1399
+ const next = {
1400
+ locale: named ?? this.hostLocales.locale,
1401
+ textNormalization: named ?? this.hostLocales.textNormalization,
1402
+ segmenter: named ?? this.hostLocales.segmenter,
1403
+ };
1404
+ if (providerOptions.contentLanguage === named &&
1405
+ optionalString(providerOptions.locale) === next.locale &&
1406
+ optionalString(providerOptions.textNormalization?.locale) ===
1407
+ next.textNormalization &&
1408
+ optionalString(providerOptions.segmenter?.locale) === next.segmenter) {
1183
1409
  return;
1184
- const providerOptions = (this.ttsConfig.providerOptions || {});
1185
- const textNormalization = (providerOptions.textNormalization ||
1186
- {});
1187
- const segmenter = (providerOptions.segmenter || {});
1410
+ }
1411
+ // Every key is set, undefined included: providers merge options shallowly.
1188
1412
  const mergedProviderOptions = {
1189
1413
  ...providerOptions,
1190
- locale: options.language,
1414
+ locale: next.locale,
1191
1415
  textNormalization: {
1192
- ...textNormalization,
1193
- locale: options.language,
1194
- },
1195
- segmenter: {
1196
- ...segmenter,
1197
- locale: options.language,
1416
+ ...providerOptions.textNormalization,
1417
+ locale: next.textNormalization,
1198
1418
  },
1419
+ segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
1420
+ contentLanguage: named,
1199
1421
  };
1200
1422
  this.ttsConfig = {
1201
1423
  ...this.ttsConfig,
1202
1424
  providerOptions: mergedProviderOptions,
1203
1425
  };
1204
- if ("updateSettings" in this.provider &&
1205
- typeof this.provider.updateSettings ===
1206
- "function") {
1207
- await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
1208
- }
1426
+ await this.provider.updateSettings({
1427
+ providerOptions: mergedProviderOptions,
1428
+ });
1209
1429
  }
1210
1430
  async resolveSpeechContent(text, options) {
1211
1431
  const normalizedInputText = normalizeTextForSpeech(text);
1212
- if (options?.ignoreCatalogs) {
1213
- return {
1214
- contentToSpeak: normalizedInputText,
1215
- speechText: normalizedInputText,
1216
- visibleText: normalizedInputText,
1217
- highlightText: normalizedInputText,
1218
- usedCatalogSpoken: false,
1219
- speechSource: "dom-or-input",
1220
- normalizedText: normalizedInputText,
1221
- containsMathMarkup: false,
1222
- speechMatchesVisibleText: true,
1223
- };
1224
- }
1225
- if (options?.catalogId && this.catalogResolver) {
1432
+ if (options.catalogId && this.catalogResolver) {
1226
1433
  const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
1227
1434
  type: "spoken",
1228
- language: options.language || "en-US",
1435
+ language: options.language,
1229
1436
  useFallback: true,
1230
1437
  context: options.catalogContext,
1231
1438
  form: "content",
@@ -1234,11 +1441,9 @@ export class TTSService {
1234
1441
  // instance. Fall through to generated TTS rather than speaking "".
1235
1442
  const spokenText = catalogContent?.content;
1236
1443
  if (catalogContent && spokenText !== undefined) {
1237
- const visibleText = options?.contentElement
1238
- ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
1239
- : normalizedInputText;
1444
+ const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1240
1445
  const normalizedCatalogText = normalizeTextForSpeech(spokenText);
1241
- this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1446
+ logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1242
1447
  return {
1243
1448
  contentToSpeak: spokenText,
1244
1449
  speechText: spokenText,
@@ -1251,24 +1456,12 @@ export class TTSService {
1251
1456
  speechMatchesVisibleText: normalizedCatalogText === visibleText,
1252
1457
  };
1253
1458
  }
1254
- this.debugLog(`[TTSService] No catalog found for "${options.catalogId}", falling back to generated TTS`);
1459
+ logger.debug(`No catalog found for "${options.catalogId}", falling back to generated TTS`);
1255
1460
  }
1256
- const composed = options?.contentElement
1257
- ? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
1258
- : null;
1461
+ const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
1259
1462
  if (composed)
1260
1463
  return composed;
1261
- const generated = options?.contentElement
1262
- ? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
1263
- : {
1264
- contentToSpeak: normalizedInputText,
1265
- speechText: normalizedInputText,
1266
- visibleText: normalizedInputText,
1267
- highlightText: normalizedInputText,
1268
- normalizedText: normalizedInputText,
1269
- containsMathMarkup: false,
1270
- speechMatchesVisibleText: true,
1271
- };
1464
+ const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
1272
1465
  return {
1273
1466
  ...generated,
1274
1467
  usedCatalogSpoken: false,
@@ -1282,7 +1475,11 @@ export class TTSService {
1282
1475
  if (!chunks.some((chunk) => chunk.sourceElement))
1283
1476
  return null;
1284
1477
  const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
1285
- const visibleText = collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1478
+ // A selection's visible text is the text it holds, which the caller passes:
1479
+ // the highlight offsets index it from `wordBoundaryOffset`.
1480
+ const visibleText = options.contentRange
1481
+ ? normalizedInputText
1482
+ : collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1286
1483
  return {
1287
1484
  contentToSpeak: speechText,
1288
1485
  speechText,
@@ -1297,6 +1494,9 @@ export class TTSService {
1297
1494
  };
1298
1495
  }
1299
1496
  collectCatalogSpeechChunks(root, options) {
1497
+ const catalogResolver = this.catalogResolver;
1498
+ if (!catalogResolver)
1499
+ return [];
1300
1500
  const chunks = [];
1301
1501
  let textBuffer = "";
1302
1502
  const flushTextBuffer = () => {
@@ -1311,27 +1511,36 @@ export class TTSService {
1311
1511
  speechMatchesVisibleText: true,
1312
1512
  });
1313
1513
  };
1514
+ const range = options.contentRange;
1314
1515
  const resolveCatalog = (element) => {
1315
1516
  const catalogIdRef = element.getAttribute("data-catalog-idref");
1316
1517
  if (!catalogIdRef)
1317
1518
  return null;
1519
+ // A card reads its whole node, so it stands in for a selection's part of
1520
+ // the node only when the selection holds all of it. A part reads as the
1521
+ // visible text selected.
1522
+ if (range && !rangeHoldsSpeakableElement(range, element, root)) {
1523
+ return null;
1524
+ }
1318
1525
  const lookup = {
1319
1526
  type: "spoken",
1320
- language: options.language || "en-US",
1527
+ // The node's own language first: a `lang` inside the read content
1528
+ // names the language of the part it marks.
1529
+ language: findLangAttribute(element, root) || options.language,
1321
1530
  useFallback: true,
1322
1531
  context: options.catalogContext,
1323
1532
  };
1324
1533
  // Two lookups rather than one, because `form` is a preference: asking for
1325
1534
  // the payload form happily returns a script card when no recording
1326
1535
  // exists, so the answer has to be checked rather than assumed.
1327
- const audioCard = this.catalogResolver.getAlternative(catalogIdRef, {
1536
+ const audioCard = catalogResolver.getAlternative(catalogIdRef, {
1328
1537
  ...lookup,
1329
1538
  form: "payload",
1330
1539
  });
1331
1540
  const audio = audioCard?.payload
1332
1541
  ? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
1333
1542
  : undefined;
1334
- const scriptCard = this.catalogResolver.getAlternative(catalogIdRef, {
1543
+ const scriptCard = catalogResolver.getAlternative(catalogIdRef, {
1335
1544
  ...lookup,
1336
1545
  form: "content",
1337
1546
  });
@@ -1354,7 +1563,7 @@ export class TTSService {
1354
1563
  : null;
1355
1564
  };
1356
1565
  const getMathElementsForAlignment = (element) => {
1357
- const mathElements = Array.from(element.querySelectorAll("math"));
1566
+ const mathElements = flatQuerySelectorAll(element, "math");
1358
1567
  if (element.localName?.toLowerCase() === "math") {
1359
1568
  return [element, ...mathElements];
1360
1569
  }
@@ -1367,7 +1576,12 @@ export class TTSService {
1367
1576
  if (isNodeExcludedFromSpeech(node, root))
1368
1577
  return;
1369
1578
  if (node.nodeType === Node.TEXT_NODE) {
1370
- textBuffer += ` ${node.textContent || ""}`;
1579
+ if (!range) {
1580
+ textBuffer += ` ${node.textContent || ""}`;
1581
+ }
1582
+ else if (rangeIntersectsComposedNode(range, node)) {
1583
+ textBuffer += ` ${textInRange(node, range)}`;
1584
+ }
1371
1585
  return;
1372
1586
  }
1373
1587
  if (node.nodeType !== Node.ELEMENT_NODE)
@@ -1378,49 +1592,47 @@ export class TTSService {
1378
1592
  flushTextBuffer();
1379
1593
  const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1380
1594
  const visibleText = collectedVisible.visibleText ||
1381
- normalizeTextForSpeech(element.textContent || "");
1595
+ normalizeTextForSpeech(flatTextContent(element));
1382
1596
  const regionElement = resolveReadableRegion(element, root);
1383
- // The script chunk, when there is a script. Built exactly as before,
1384
- // and it doubles as the recording's fallback: word-level alignment is
1385
- // only meaningful for the synthesized path, which is the one that runs
1386
- // if the audio cannot play.
1387
- const scriptChunk = catalog.script !== undefined
1388
- ? (() => {
1389
- const script = catalog.script;
1390
- 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,
1391
1611
  speechText: script,
1392
- visibleText,
1393
- });
1394
- const mathElement = getSingleMathElementForAlignment(element, visibleText);
1395
- const mathAlignment = mathElement
1396
- ? createMathAwareAlignment({
1397
- mathElement,
1612
+ })
1613
+ : undefined;
1614
+ const mathAlignments = mathAlignment
1615
+ ? undefined
1616
+ : getMathElementsForAlignment(element).map((candidate) => ({
1617
+ element: candidate,
1618
+ alignment: createMathAwareAlignment({
1619
+ mathElement: candidate,
1398
1620
  speechText: script,
1399
- })
1400
- : undefined;
1401
- const mathAlignments = mathAlignment
1402
- ? undefined
1403
- : getMathElementsForAlignment(element).map((candidate) => ({
1404
- element: candidate,
1405
- alignment: createMathAwareAlignment({
1406
- mathElement: candidate,
1407
- speechText: script,
1408
- }),
1409
- }));
1410
- return {
1411
- speechText: script,
1412
- visibleText,
1413
- sourceElement: element,
1414
- regionElement,
1415
- speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
1416
- playbackMode: alignment.playbackMode,
1417
- alignment,
1418
- mathAlignment,
1419
- mathAlignments,
1420
- visibleMap: collectedVisible.map,
1421
- };
1422
- })()
1423
- : 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
+ }
1424
1636
  if (catalog.audio) {
1425
1637
  chunks.push(scriptChunk
1426
1638
  ? {
@@ -1446,7 +1658,7 @@ export class TTSService {
1446
1658
  chunks.push(scriptChunk);
1447
1659
  return;
1448
1660
  }
1449
- for (const child of Array.from(element.childNodes)) {
1661
+ for (const child of flatTreeChildNodes(element)) {
1450
1662
  visit(child);
1451
1663
  }
1452
1664
  };
@@ -1468,12 +1680,13 @@ export class TTSService {
1468
1680
  const provider = this.currentProvider;
1469
1681
  if (!provider)
1470
1682
  return "plain";
1471
- // Belt-and-suspenders: the browser provider can never voice SSML.
1472
- if (provider.providerId?.toLowerCase() === "browser")
1473
- return "plain";
1474
1683
  return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
1475
1684
  }
1476
- async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language) {
1685
+ /**
1686
+ * With `range`, the selection only: an equation it holds whole reads as math
1687
+ * speech, and a selection without one reads the selected text, `normalizedInputText`.
1688
+ */
1689
+ async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language, range) {
1477
1690
  // Delegate to the generated-speech module: a pure plan assembly + SRE
1478
1691
  // memoization core, plus a DOM adapter that binds live-DOM anchors and
1479
1692
  // emits playback chunks. The aggregate `contentToSpeak` stays plain text
@@ -1481,14 +1694,19 @@ export class TTSService {
1481
1694
  const playbackFormat = this.resolveGeneratedPlaybackFormat();
1482
1695
  const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
1483
1696
  contentRoot: contentElement,
1697
+ range,
1484
1698
  language,
1485
1699
  mathSpeech: this.getMathSpeechOptions(),
1486
1700
  textProcessingOptions: this.getTextProcessingOptions(language),
1487
1701
  produceSsml: playbackFormat === "ssml",
1488
1702
  resolveMathSpeech: this.generatedMathSpeechResolver,
1489
1703
  });
1490
- const visibleText = collectedVisibleText || normalizedInputText;
1491
1704
  if (!containsMathMarkup) {
1705
+ // The input text is the selection's text in the root's visible-text map,
1706
+ // which its highlight offsets index.
1707
+ const visibleText = range
1708
+ ? normalizedInputText
1709
+ : collectedVisibleText || normalizedInputText;
1492
1710
  return {
1493
1711
  contentToSpeak: visibleText,
1494
1712
  speechText: visibleText,
@@ -1499,6 +1717,7 @@ export class TTSService {
1499
1717
  speechMatchesVisibleText: true,
1500
1718
  };
1501
1719
  }
1720
+ const visibleText = collectedVisibleText || normalizedInputText;
1502
1721
  const speechChunks = planToCompositionChunkInputs(plan, {
1503
1722
  format: playbackFormat,
1504
1723
  });
@@ -1519,7 +1738,7 @@ export class TTSService {
1519
1738
  .replace(/\s+/g, " ")
1520
1739
  .trim()
1521
1740
  .slice(0, 200);
1522
- this.debugLog("[TTSService] Speak resolved content", {
1741
+ logger.debug("Speak resolved content", {
1523
1742
  source: args.speechSource,
1524
1743
  catalogId: args.catalogId || null,
1525
1744
  length: args.contentToSpeak.length,
@@ -1528,7 +1747,7 @@ export class TTSService {
1528
1747
  }
1529
1748
  initializeSpeakTracking(contentToSpeak, options) {
1530
1749
  this.currentText = contentToSpeak;
1531
- this.currentContentElement = options?.contentElement || null;
1750
+ this.currentContentElement = options.contentElement;
1532
1751
  this.lastError = null;
1533
1752
  this.currentBoundaryOffset = 0;
1534
1753
  this.playbackChunks = [];
@@ -1537,7 +1756,7 @@ export class TTSService {
1537
1756
  prepareHighlightsForSpeak(args) {
1538
1757
  if (!this.currentContentElement || !this.highlightCoordinator)
1539
1758
  return;
1540
- this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options?.language);
1759
+ this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
1541
1760
  if (args.highlightMode === "sentence" && args.shouldUsePlan) {
1542
1761
  return;
1543
1762
  }
@@ -1545,7 +1764,7 @@ export class TTSService {
1545
1764
  this.runWhenPlaybackStarts(args.runId, () => {
1546
1765
  if (initialSegment) {
1547
1766
  this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
1548
- this.debugLog("[TTSService] Applied initial sentence highlighting");
1767
+ logger.debug("Applied initial sentence highlighting");
1549
1768
  }
1550
1769
  else {
1551
1770
  try {
@@ -1560,34 +1779,29 @@ export class TTSService {
1560
1779
  });
1561
1780
  }
1562
1781
  configureWordBoundaryHighlighting(args) {
1563
- if (!this.provider ||
1564
- args.highlightMode !== "word" ||
1565
- !this.highlightCoordinator ||
1566
- !this.currentContentElement) {
1782
+ if (!this.provider)
1783
+ return;
1784
+ // A sentence-mode read paints no words, so it takes no word boundaries.
1785
+ if (args.highlightMode === "sentence") {
1786
+ this.provider.onWordBoundary = undefined;
1567
1787
  return;
1568
1788
  }
1569
- // Word index uses the same globalIndex as highlightTTSWord (browser boundaries
1570
- // or ServerTTSProvider time-scaled onWordBoundary).
1789
+ if (!this.highlightCoordinator || !this.currentContentElement)
1790
+ return;
1791
+ // Browser boundaries and the server provider's time-scaled boundaries index
1792
+ // the same spoken text.
1571
1793
  this.provider.onWordBoundary = (word, charIndex, length) => {
1572
1794
  const wordLength = length || word.length;
1573
1795
  const spokenIndex = charIndex + this.currentBoundaryOffset;
1574
1796
  const globalIndex = spokenIndex + args.wordBoundaryOffset;
1575
1797
  this.highlightSentenceForOffset(spokenIndex);
1576
- const highlightRange = this.findHighlightRange(globalIndex, wordLength);
1577
- if (highlightRange && this.highlightCoordinator) {
1578
- const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
1579
- this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1580
- const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
1581
- if (nativeRange) {
1582
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1583
- }
1584
- else {
1585
- this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1586
- }
1587
- }
1588
- else {
1589
- console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
1798
+ const ranges = createRangesFromVisibleMap(this.normalizedToDOM, globalIndex, globalIndex + wordLength);
1799
+ if (ranges.length === 0) {
1800
+ logger.debug(`no text at position ${globalIndex}, length ${wordLength}, to highlight`);
1801
+ return;
1590
1802
  }
1803
+ logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
1804
+ this.paintTTSWord(ranges);
1591
1805
  };
1592
1806
  }
1593
1807
  clearWordBoundaryHighlighting() {
@@ -1602,26 +1816,22 @@ export class TTSService {
1602
1816
  try {
1603
1817
  this.lastRenderedRegionTarget = null;
1604
1818
  if (chunk.regionRange) {
1605
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1606
- this.paintTTSSentenceRanges([chunk.regionRange]);
1819
+ const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
1820
+ this.highlightCoordinator.clearTTSWord();
1821
+ this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
1607
1822
  return;
1608
1823
  }
1609
1824
  if (!element)
1610
1825
  return;
1611
1826
  const range = document.createRange();
1612
1827
  range.selectNodeContents(element);
1613
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1828
+ this.highlightCoordinator.clearTTSWord();
1614
1829
  this.paintTTSSentenceRanges([range]);
1615
1830
  }
1616
1831
  catch {
1617
1832
  // Continue playback even when a detached node cannot be highlighted.
1618
1833
  }
1619
1834
  }
1620
- highlightCatalogActiveRange(range) {
1621
- if (!this.highlightCoordinator)
1622
- return;
1623
- this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
1624
- }
1625
1835
  highlightRenderableRegionTarget(target) {
1626
1836
  if (!this.highlightCoordinator ||
1627
1837
  !target ||
@@ -1638,7 +1848,7 @@ export class TTSService {
1638
1848
  range.selectNodeContents(target.element);
1639
1849
  }
1640
1850
  else if (target.type === "range") {
1641
- this.paintTTSSentenceRanges([target.range]);
1851
+ this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
1642
1852
  this.lastRenderedRegionTarget = target;
1643
1853
  return;
1644
1854
  }
@@ -1663,28 +1873,24 @@ export class TTSService {
1663
1873
  // Clear the word layer so the previously spoken word does not stay
1664
1874
  // highlighted. (The "hold last token" behavior returns a non-null
1665
1875
  // target, so it does not reach here.)
1666
- this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1876
+ this.highlightCoordinator.clearTTSWord();
1667
1877
  return;
1668
1878
  }
1669
1879
  if (target.type === "text-range") {
1670
1880
  const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
1671
- if (nativeRange) {
1672
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1673
- }
1674
- else {
1675
- this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1676
- }
1881
+ if (nativeRange)
1882
+ this.paintTTSWord([nativeRange]);
1677
1883
  return;
1678
1884
  }
1679
1885
  if (target.type === "range") {
1680
- this.highlightCatalogActiveRange(target.range);
1886
+ this.paintTTSWord(target.ranges ?? [target.range]);
1681
1887
  return;
1682
1888
  }
1683
1889
  const elementNativeRange = document.createRange();
1684
1890
  elementNativeRange.selectNodeContents(target.element);
1685
1891
  const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
1686
1892
  if (resolvedElementRange.remapped) {
1687
- this.paintTTSWordRange(resolvedElementRange.range);
1893
+ this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
1688
1894
  return;
1689
1895
  }
1690
1896
  // Element targets are atomic: a resolved math token, a whole-expression
@@ -1699,10 +1905,12 @@ export class TTSService {
1699
1905
  const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
1700
1906
  if (target.quality === "semantic-token" &&
1701
1907
  onlyChild?.nodeType === Node.TEXT_NODE) {
1702
- this.highlightCoordinator.highlightTTSWord(onlyChild, 0, onlyChild.textContent?.length || 0);
1908
+ const tokenRange = document.createRange();
1909
+ tokenRange.selectNodeContents(onlyChild);
1910
+ this.highlightCoordinator.highlightTTSWord([tokenRange]);
1703
1911
  return;
1704
1912
  }
1705
- this.highlightCoordinator.highlightTTSWordElement?.(target.element);
1913
+ this.highlightCoordinator.highlightTTSWordElement(target.element);
1706
1914
  }
1707
1915
  renderHighlightDecision(decision) {
1708
1916
  this.highlightRenderableRegionTarget(decision.regionTarget);
@@ -1713,21 +1921,24 @@ export class TTSService {
1713
1921
  await this.speakCatalogChunkOnce(chunk, runId);
1714
1922
  }
1715
1923
  catch (error) {
1716
- // Speak-time fallback: if an SSML math chunk is rejected by the
1717
- // provider, retry once with its precomputed plain-text variant (same
1718
- // anchors, plain alignment). The fallback chunk carries no further
1719
- // 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.
1720
1928
  if (chunk.plainFallback && runId === this.speakRunId) {
1721
- this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
1722
- // A recorded-first run can own the start barrier even when its fallback
1723
- // provider has no formal start signal. Drop the failed recording's queued
1724
- // highlight and resume that non-start-aware provider's immediate-start lifecycle.
1929
+ logger.debug("SSML chunk speak failed; retrying plain text", {
1930
+ message: error instanceof Error ? error.message : String(error),
1931
+ });
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.
1725
1936
  if (this.playbackStartDeferredRunId === runId &&
1726
1937
  this.provider &&
1727
1938
  !("onPlaybackStart" in this.provider)) {
1728
1939
  this.pendingPlaybackStartHighlights = [];
1729
1940
  this.clearPlaybackStartBarrier();
1730
- this.setState(PlaybackState.PLAYING);
1941
+ this.markPlaying();
1731
1942
  }
1732
1943
  await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
1733
1944
  return;
@@ -1787,6 +1998,18 @@ export class TTSService {
1787
1998
  cleanup();
1788
1999
  reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1789
2000
  };
2001
+ // A pause before the clip starts rejects its play with an AbortError;
2002
+ // resume plays it, so that rejection is no failure of the clip.
2003
+ const play = () => {
2004
+ Promise.resolve(element.play())
2005
+ .then(markStarted)
2006
+ .catch((error) => {
2007
+ const aborted = error?.name === "AbortError";
2008
+ if (aborted && this.state === PlaybackState.PAUSED)
2009
+ return;
2010
+ onError();
2011
+ });
2012
+ };
1790
2013
  // Cancellation has to settle this promise, not just stop the element:
1791
2014
  // `stop()` bumps the run id, and a pending play that never resolves would
1792
2015
  // wedge the chunk loop on a run nobody is listening to any more.
@@ -1800,6 +2023,7 @@ export class TTSService {
1800
2023
  element.pause();
1801
2024
  resolve();
1802
2025
  },
2026
+ play,
1803
2027
  };
1804
2028
  element.addEventListener("ended", onEnded);
1805
2029
  element.addEventListener("error", onError);
@@ -1807,7 +2031,7 @@ export class TTSService {
1807
2031
  // Reaching the slice's end is this clip finishing, so the chunk sequence
1808
2032
  // advances rather than the element merely pausing.
1809
2033
  disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
1810
- Promise.resolve(element.play()).then(markStarted).catch(onError);
2034
+ play();
1811
2035
  }).finally(() => {
1812
2036
  if (this.activeRecordedAudio?.element === element) {
1813
2037
  this.activeRecordedAudio = null;
@@ -1852,6 +2076,9 @@ export class TTSService {
1852
2076
  mathTokenHighlighting,
1853
2077
  })
1854
2078
  : null;
2079
+ const providerOffsetSpace = !pipelineChunk || pipelineChunk.offsetSpace === "unsupported"
2080
+ ? "unknown"
2081
+ : pipelineChunk.offsetSpace;
1855
2082
  this.runWhenPlaybackStarts(runId, () => {
1856
2083
  if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
1857
2084
  this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
@@ -1860,7 +2087,9 @@ export class TTSService {
1860
2087
  this.highlightCatalogRegion(chunk);
1861
2088
  }
1862
2089
  });
1863
- const canUseChunkBoundaries = chunk.sourceElement &&
2090
+ // A sentence-mode read takes no word boundaries, aligned chunk or not.
2091
+ const canUseChunkBoundaries = this.resolveHighlightMode() === "word" &&
2092
+ chunk.sourceElement &&
1864
2093
  ((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
1865
2094
  (chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
1866
2095
  (chunk.alignment &&
@@ -1872,7 +2101,7 @@ export class TTSService {
1872
2101
  const previousOnWordBoundary = provider.onWordBoundary;
1873
2102
  provider.onWordBoundary = undefined;
1874
2103
  try {
1875
- await provider.speak(chunk.speechText);
2104
+ await this.speakWithinLimit(chunk.speechText, runId);
1876
2105
  }
1877
2106
  finally {
1878
2107
  if (this.provider === provider &&
@@ -1886,25 +2115,19 @@ export class TTSService {
1886
2115
  const provider = this.provider;
1887
2116
  const previousOnWordBoundary = provider.onWordBoundary;
1888
2117
  const installedHandler = (word, position, length) => {
1889
- if (runId !== this.speakRunId)
1890
- return;
1891
- if (highlightPlan && pipelineChunk) {
1892
- const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
1893
- ? "unknown"
1894
- : pipelineChunk.offsetSpace;
1895
- this.renderHighlightDecision(highlightPlan.resolveBoundary({
1896
- chunkId: pipelineChunk.id,
1897
- word,
1898
- position,
1899
- length,
1900
- providerOffsetSpace,
1901
- }));
2118
+ if (runId !== this.speakRunId || !highlightPlan || !pipelineChunk)
1902
2119
  return;
1903
- }
2120
+ this.renderHighlightDecision(highlightPlan.resolveBoundary({
2121
+ chunkId: pipelineChunk.id,
2122
+ word,
2123
+ position,
2124
+ length,
2125
+ providerOffsetSpace,
2126
+ }));
1904
2127
  };
1905
2128
  provider.onWordBoundary = installedHandler;
1906
2129
  try {
1907
- await provider.speak(chunk.speechText);
2130
+ await this.speakWithinLimit(chunk.speechText, runId);
1908
2131
  }
1909
2132
  finally {
1910
2133
  if (this.provider === provider &&
@@ -1917,6 +2140,7 @@ export class TTSService {
1917
2140
  async executeSpeakPlayback(args) {
1918
2141
  if (args.speechChunks?.length && this.provider) {
1919
2142
  for (let index = 0; index < args.speechChunks.length; index++) {
2143
+ await this.waitWhilePaused(args.runId);
1920
2144
  if (args.runId !== this.speakRunId)
1921
2145
  return;
1922
2146
  const segment = this.seekSegments[index];
@@ -1928,23 +2152,19 @@ export class TTSService {
1928
2152
  }
1929
2153
  return;
1930
2154
  }
1931
- if (args.shouldUsePlan && this.currentContentElement) {
1932
- const segments = this.seekSegments;
1933
- if (segments.length > 0) {
1934
- await this.speakWithPlan(segments, args.runId, {
1935
- highlightMode: args.highlightMode,
1936
- });
1937
- }
1938
- else if (this.provider) {
1939
- await this.provider.speak(args.contentToSpeak);
1940
- }
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
+ });
1941
2161
  return;
1942
2162
  }
1943
- if (this.provider) {
1944
- await this.provider.speak(args.contentToSpeak);
1945
- }
2163
+ await this.speakWithinLimit(args.contentToSpeak, args.runId);
1946
2164
  }
1947
2165
  clearHighlightsAndTracking() {
2166
+ // A finished run leaves no handler for the next read to inherit.
2167
+ this.clearWordBoundaryHighlighting();
1948
2168
  this.pendingPlaybackStartHighlights = [];
1949
2169
  this.activePlaybackRate = null;
1950
2170
  if (this.highlightCoordinator) {
@@ -1959,38 +2179,22 @@ export class TTSService {
1959
2179
  this.playbackChunks = [];
1960
2180
  this.sentenceHighlightSegments = [];
1961
2181
  this.activeSentenceStartOffset = null;
2182
+ this.wordlessPartHighlight = null;
1962
2183
  }
1963
- /**
1964
- * Speak a text range with accurate word highlighting
1965
- *
1966
- * This calculates the offset of the range within its parent element
1967
- * to ensure word highlighting aligns correctly with the selected text.
1968
- *
1969
- * @param range DOM Range to speak
1970
- */
1971
2184
  /**
1972
2185
  * The selected part of `root`'s normalized visible text and where it starts, or
1973
2186
  * null when the map is unavailable or holds no selected character.
1974
2187
  */
1975
- selectMappedRangeText(range, root) {
2188
+ selectMappedRangeText(range, root, language) {
1976
2189
  if (typeof range.comparePoint !== "function")
1977
2190
  return null;
1978
- const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions());
2191
+ const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
1979
2192
  let start = -1;
1980
2193
  let end = -1;
1981
2194
  for (const [index, { node, offset }] of map) {
1982
2195
  if (index >= rootText.length)
1983
2196
  continue;
1984
- let inside = false;
1985
- try {
1986
- inside =
1987
- range.comparePoint(node, offset) === 0 &&
1988
- range.comparePoint(node, Math.min(offset + 1, node.length)) === 0;
1989
- }
1990
- catch {
1991
- inside = false;
1992
- }
1993
- if (!inside)
2197
+ if (!rangeHoldsTextPosition(range, node, offset))
1994
2198
  continue;
1995
2199
  if (start === -1 || index < start)
1996
2200
  start = index;
@@ -2004,103 +2208,39 @@ export class TTSService {
2004
2208
  const text = raw.trim();
2005
2209
  return text ? { text, offset: start + leading } : null;
2006
2210
  }
2007
- async speakRange(range, options) {
2008
- if (!this.provider) {
2009
- throw new Error("TTS service not initialized");
2010
- }
2011
- // Enforced from the live ancestors rather than from `root`, because the
2012
- // element carrying the suppression may sit above whatever root the caller
2013
- // passed or the range happens to resolve to.
2014
- if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
2015
- console.warn("[tts] selection lies inside content marked not-to-be-spoken; nothing was spoken. Selecting a node and pressing read-aloud must not become a way around suppression.");
2016
- return;
2017
- }
2018
- // Use explicit content root when provided; otherwise keep highlighting scoped
2019
- // to the selected range's nearest element ancestor.
2020
- const fromOptions = options?.contentRoot || null;
2021
- let root = fromOptions;
2022
- if (!root) {
2023
- const ancestor = range.commonAncestorContainer;
2024
- root =
2025
- ancestor.nodeType === Node.ELEMENT_NODE
2026
- ? ancestor
2027
- : ancestor.parentElement;
2028
- }
2029
- if (!root)
2030
- return;
2031
- const selected = collectRangeTextForSpeech(range, root);
2032
- const selectedText = selected.text.trim();
2033
- if (!selectedText) {
2034
- if (selected.filtered) {
2035
- console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
2036
- }
2037
- return;
2038
- }
2039
- // The spoken text and its offset come from the root's visible-text map, which
2040
- // the highlight position map and the structural speech plan index too. Joined
2041
- // from raw text nodes instead, a selection loses the space the map inserts
2042
- // between blocks, and every offset after that boundary drifts.
2043
- const mapped = this.selectMappedRangeText(range, root);
2044
- let text = selectedText;
2045
- let offset = mapped?.offset ?? 0;
2046
- if (mapped) {
2047
- text = mapped.text;
2048
- }
2049
- else {
2050
- // Filtered the same way as the speech itself: the offset indexes into the
2051
- // highlight text, which comes from the exclusion-aware collectors.
2052
- const beforeRange = document.createRange();
2053
- beforeRange.selectNodeContents(root);
2054
- beforeRange.setEnd(range.startContainer, range.startOffset);
2055
- const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
2056
- const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
2057
- offset =
2058
- normalizedTextBeforeRange.length +
2059
- (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
2060
- }
2061
- this.debugLog("[TTSService] speakRange offset calculation:", {
2062
- selectedText: text,
2063
- mapped: !!mapped,
2064
- offset,
2065
- rootTag: root.tagName,
2066
- });
2067
- // Speak the text with the root element as context
2068
- await this.speak(text, {
2069
- contentElement: root,
2070
- ignoreCatalogs: true,
2071
- wordBoundaryOffset: offset,
2072
- });
2073
- }
2074
2211
  /**
2075
- * Pause playback
2212
+ * Pause playback. A pause while the read is loading holds it: its audio does
2213
+ * not start until {@link resume}.
2076
2214
  */
2077
2215
  pause() {
2078
- if (!this.provider)
2216
+ if (this.state !== PlaybackState.PLAYING &&
2217
+ this.state !== PlaybackState.LOADING) {
2079
2218
  return;
2080
- if (this.state === PlaybackState.PLAYING) {
2081
- this.activeRecordedAudio?.element.pause();
2082
- this.provider.pause();
2083
- this.setState(PlaybackState.PAUSED);
2084
2219
  }
2220
+ this.resumeState = this.state;
2221
+ this.activeRecordedAudio?.element.pause();
2222
+ this.provider?.pause();
2223
+ // Between two parts, nothing is playing to pause: the run holds before
2224
+ // its next part instead.
2225
+ this.setState(PlaybackState.PAUSED);
2085
2226
  }
2086
2227
  /**
2087
- * Resume playback
2228
+ * Resume playback, back to loading when the pause came before the audio
2229
+ * started.
2088
2230
  */
2089
2231
  resume() {
2090
- if (!this.provider)
2232
+ if (this.state !== PlaybackState.PAUSED)
2091
2233
  return;
2092
- if (this.state === PlaybackState.PAUSED) {
2093
- const recorded = this.activeRecordedAudio?.element;
2094
- if (recorded)
2095
- void Promise.resolve(recorded.play()).catch(() => { });
2096
- this.provider.resume();
2097
- this.setState(PlaybackState.PLAYING);
2098
- }
2234
+ // State first: audio that starts during resume reports into it.
2235
+ this.setState(this.resumeState);
2236
+ this.activeRecordedAudio?.play();
2237
+ this.provider?.resume();
2238
+ this.releasePauseHold();
2099
2239
  }
2100
2240
  normalizePlaybackRate(rate) {
2101
2241
  if (!Number.isFinite(rate))
2102
2242
  return 1;
2103
- return Math.max(0.25, Math.min(4, rate));
2243
+ return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
2104
2244
  }
2105
2245
  async restartFromSeekIndex(targetIndex) {
2106
2246
  if (!this.provider || this.seekSegments.length === 0)
@@ -2109,18 +2249,27 @@ export class TTSService {
2109
2249
  const restartChunks = this.playbackChunks.length
2110
2250
  ? this.playbackChunks.slice(safeTargetIndex)
2111
2251
  : null;
2252
+ // A seek under a pause moves the cursor and stays paused: the restarted
2253
+ // run holds before its first part until resume.
2254
+ const holdPaused = this.state === PlaybackState.PAUSED;
2112
2255
  this.speakRunId += 1;
2113
- this.clearPlaybackStartBarrier();
2114
- this.cancelRecordedAudio();
2256
+ this.abandonRun();
2115
2257
  this.provider.onWordBoundary = undefined;
2116
2258
  this.provider.stop();
2117
2259
  const runId = ++this.speakRunId;
2118
2260
  const restartSegments = this.seekSegments.slice(safeTargetIndex);
2119
2261
  this.highlightCoordinator?.clearTTS();
2120
2262
  this.activeSentenceStartOffset = null;
2121
- this.setState(PlaybackState.LOADING);
2263
+ this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
2264
+ if (!holdPaused)
2265
+ this.setState(PlaybackState.LOADING);
2122
2266
  const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
2123
- if (!playbackStartBarrier) {
2267
+ if (holdPaused) {
2268
+ this.resumeState = playbackStartBarrier
2269
+ ? PlaybackState.LOADING
2270
+ : PlaybackState.PLAYING;
2271
+ }
2272
+ else if (!playbackStartBarrier) {
2124
2273
  this.setState(PlaybackState.PLAYING);
2125
2274
  }
2126
2275
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
@@ -2131,6 +2280,7 @@ export class TTSService {
2131
2280
  });
2132
2281
  if (restartChunks) {
2133
2282
  for (let index = 0; index < restartChunks.length; index++) {
2283
+ await this.waitWhilePaused(runId);
2134
2284
  if (runId !== this.speakRunId)
2135
2285
  return;
2136
2286
  const absoluteIndex = safeTargetIndex + index;
@@ -2152,8 +2302,11 @@ export class TTSService {
2152
2302
  this.clearHighlightsAndTracking();
2153
2303
  }
2154
2304
  catch (error) {
2155
- 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);
2156
2308
  return;
2309
+ }
2157
2310
  this.lastError = error instanceof Error ? error.message : String(error);
2158
2311
  this.setState(PlaybackState.ERROR);
2159
2312
  this.clearHighlightsAndTracking();
@@ -2251,59 +2404,49 @@ export class TTSService {
2251
2404
  await this.seekBy(-step);
2252
2405
  }
2253
2406
  /**
2254
- * 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.
2255
2409
  */
2256
2410
  stop() {
2257
- if (!this.provider)
2258
- return;
2259
2411
  this.speakRunId += 1;
2260
- this.clearPlaybackStartBarrier();
2261
- this.cancelRecordedAudio();
2262
- this.provider.onWordBoundary = undefined;
2263
- this.provider.stop();
2264
- this.setState(PlaybackState.IDLE);
2412
+ this.abandonRun();
2413
+ this.provider?.stop();
2414
+ this.clearHighlightsAndTracking();
2265
2415
  this.currentText = null;
2266
- // Clear highlights
2267
- if (this.highlightCoordinator) {
2268
- this.highlightCoordinator.clearTTS();
2269
- }
2270
- // Clear tracking
2271
- this.lastRenderedRegionTarget = null;
2272
- this.currentContentElement = null;
2273
- this.normalizedToDOM.clear();
2274
- this.currentBoundaryOffset = 0;
2275
- this.activeWordBoundaryOffset = 0;
2276
- this.seekSegments = [];
2277
- this.playbackChunks = [];
2278
- this.sentenceHighlightSegments = [];
2279
- this.activeSentenceStartOffset = null;
2280
- this.activePlaybackRate = null;
2281
- }
2282
- /**
2283
- * Request UI-level TTS controls to hand off/deactivate.
2284
- *
2285
- * This is intentionally separate from playback controls so hosts can orchestrate
2286
- * control handoff explicitly (for example: stop playback, then dismiss controls).
2287
- */
2288
- requestControlHandoff() {
2289
- if (typeof window === "undefined")
2290
- return;
2291
- const detail = { source: "host" };
2292
- window.dispatchEvent(new CustomEvent(PIE_TTS_CONTROL_HANDOFF_EVENT, {
2293
- detail,
2294
- }));
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);
2295
2420
  }
2296
2421
  /**
2297
- * Check if currently playing
2422
+ * Stop playback and drop the provider, whose owner destroys it. A later speak
2423
+ * waits on the readiness gate, as before the first {@link initialize}.
2298
2424
  */
2299
- isPlaying() {
2300
- return this.state === PlaybackState.PLAYING;
2425
+ releaseProvider() {
2426
+ this.stop();
2427
+ this.provider = null;
2428
+ this.currentProvider = null;
2301
2429
  }
2302
2430
  /**
2303
- * Check if paused
2431
+ * Stop, release the provider and drop every listener, hook and pending timer.
2432
+ * A disposed service cannot speak or be initialized again.
2304
2433
  */
2305
- isPaused() {
2306
- return this.state === PlaybackState.PAUSED;
2434
+ dispose() {
2435
+ if (this.disposed)
2436
+ return;
2437
+ this.releaseProvider();
2438
+ this.disposed = true;
2439
+ this.speakRunId += 1;
2440
+ this.abandonRun();
2441
+ this.playbackRateRequestId += 1;
2442
+ this.pendingPlaybackRateRequest = null;
2443
+ this.listeners.clear();
2444
+ this.readinessGate = null;
2445
+ this.mathSpeechSource = null;
2446
+ this.highlightTargetResolverProvider = null;
2447
+ this.highlightCoordinator = null;
2448
+ this.catalogResolver = null;
2449
+ this.telemetryReporter = null;
2307
2450
  }
2308
2451
  /**
2309
2452
  * Get current state
@@ -2311,60 +2454,36 @@ export class TTSService {
2311
2454
  getState() {
2312
2455
  return this.state;
2313
2456
  }
2314
- /**
2315
- * Get current text being spoken
2316
- */
2317
- getCurrentText() {
2318
- return this.currentText;
2319
- }
2320
- /**
2321
- * Get the last error message
2322
- * Returns null if no error has occurred
2323
- */
2324
- getLastError() {
2325
- return this.lastError;
2326
- }
2327
- /**
2328
- * Subscribe to state changes
2329
- *
2330
- * @param id Unique listener ID
2331
- * @param callback Function to call on state change
2332
- */
2333
- onStateChange(id, callback) {
2334
- if (!this.listeners.has(id)) {
2335
- this.listeners.set(id, new Set());
2336
- }
2337
- this.listeners.get(id).add(callback);
2457
+ getRunOwner() {
2458
+ return this.runOwner;
2338
2459
  }
2339
2460
  /**
2340
- * Unsubscribe from state changes
2461
+ * Subscribe to playback state changes. The returned function unsubscribes;
2462
+ * each subscription is its own, so the same callback can be subscribed twice.
2341
2463
  */
2342
- offStateChange(id, callback) {
2343
- const callbacks = this.listeners.get(id);
2344
- if (callbacks) {
2345
- callbacks.delete(callback);
2346
- if (callbacks.size === 0) {
2347
- this.listeners.delete(id);
2348
- }
2349
- }
2464
+ onStateChange(callback) {
2465
+ const listener = (state) => callback(state);
2466
+ this.listeners.add(listener);
2467
+ return () => {
2468
+ this.listeners.delete(listener);
2469
+ };
2350
2470
  }
2351
2471
  /**
2352
- * 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.
2353
2474
  */
2354
- setState(newState) {
2355
- if (this.state === newState)
2356
- return;
2475
+ setState(newState, announceUnchanged = false) {
2357
2476
  const previousState = this.state;
2477
+ if (previousState === newState && !announceUnchanged)
2478
+ return;
2358
2479
  this.state = newState;
2359
- // Notify all listeners
2360
- for (const callbacks of this.listeners.values()) {
2361
- for (const callback of callbacks) {
2362
- callback(newState);
2363
- }
2480
+ for (const listener of [...this.listeners]) {
2481
+ listener(newState);
2364
2482
  }
2483
+ if (previousState === newState)
2484
+ return;
2365
2485
  void this.emitTelemetry("pie-tool-playback-state-changed", {
2366
2486
  toolId: "textToSpeech",
2367
- providerId: "tts",
2368
2487
  previousState,
2369
2488
  state: newState,
2370
2489
  });
@@ -2374,7 +2493,6 @@ export class TTSService {
2374
2493
  : "pie-tool-playback-start";
2375
2494
  void this.emitTelemetry(eventName, {
2376
2495
  toolId: "textToSpeech",
2377
- providerId: "tts",
2378
2496
  previousState,
2379
2497
  state: newState,
2380
2498
  });
@@ -2383,7 +2501,6 @@ export class TTSService {
2383
2501
  if (newState === PlaybackState.PAUSED) {
2384
2502
  void this.emitTelemetry("pie-tool-playback-pause", {
2385
2503
  toolId: "textToSpeech",
2386
- providerId: "tts",
2387
2504
  previousState,
2388
2505
  state: newState,
2389
2506
  });
@@ -2392,7 +2509,6 @@ export class TTSService {
2392
2509
  if (newState === PlaybackState.ERROR) {
2393
2510
  void this.emitTelemetry("pie-tool-playback-error", {
2394
2511
  toolId: "textToSpeech",
2395
- providerId: "tts",
2396
2512
  previousState,
2397
2513
  state: newState,
2398
2514
  message: this.lastError || undefined,
@@ -2405,7 +2521,6 @@ export class TTSService {
2405
2521
  previousState === PlaybackState.LOADING)) {
2406
2522
  void this.emitTelemetry("pie-tool-playback-stop", {
2407
2523
  toolId: "textToSpeech",
2408
- providerId: "tts",
2409
2524
  previousState,
2410
2525
  state: newState,
2411
2526
  });