@pie-players/pie-assessment-toolkit 0.3.73 → 0.3.75

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 (274) hide show
  1. package/README.md +469 -318
  2. package/dist/attempt/AssessmentSession.d.ts +1 -5
  3. package/dist/attempt/AssessmentSession.js +0 -29
  4. package/dist/attempt/TestSession.d.ts +1 -20
  5. package/dist/attempt/TestSession.js +8 -99
  6. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  7. package/dist/components/ItemScope.custom-element.js +1 -0
  8. package/dist/components/ItemToolBar.custom-element.d.ts +1 -0
  9. package/dist/components/ItemToolBar.custom-element.js +1 -1
  10. package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
  11. package/dist/components/PieAssessmentToolkit.custom-element.js +13 -13
  12. package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
  13. package/dist/components/SectionToolBar.custom-element.js +1 -1
  14. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  15. package/dist/components/chunks/ItemToolBar-97hx60z4.js +2 -0
  16. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  17. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  18. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  19. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  20. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  21. package/dist/components/item-scope-element.d.ts +2 -0
  22. package/dist/components/item-scope-element.js +2 -0
  23. package/dist/context/assessment-toolkit-context.d.ts +32 -6
  24. package/dist/index.d.ts +23 -48
  25. package/dist/index.js +12 -37
  26. package/dist/policy/core/PolicySource.d.ts +1 -1
  27. package/dist/policy/core/ToolPolicyEngine.d.ts +85 -34
  28. package/dist/policy/core/ToolPolicyEngine.js +148 -46
  29. package/dist/policy/core/compose-decision.d.ts +28 -7
  30. package/dist/policy/core/compose-decision.js +75 -54
  31. package/dist/policy/core/decision-types.d.ts +65 -34
  32. package/dist/policy/core/feature-decision.d.ts +16 -9
  33. package/dist/policy/core/feature-decision.js +14 -8
  34. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  35. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  36. package/dist/policy/core/provenance.d.ts +9 -4
  37. package/dist/policy/core/provenance.js +1 -1
  38. package/dist/policy/engine.d.ts +7 -23
  39. package/dist/policy/engine.js +5 -20
  40. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  41. package/dist/policy/sources/PnpPolicySource.js +117 -118
  42. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  43. package/dist/runtime/SectionControllerBinding.js +450 -0
  44. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  45. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  46. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  47. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  48. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  49. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  50. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  51. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  52. package/dist/runtime/catalog-context.d.ts +19 -0
  53. package/dist/runtime/catalog-context.js +28 -0
  54. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  55. package/dist/runtime/composition-emit-scheduler.js +9 -15
  56. package/dist/runtime/content-language.d.ts +32 -0
  57. package/dist/runtime/content-language.js +46 -0
  58. package/dist/runtime/content-region.d.ts +17 -0
  59. package/dist/runtime/content-region.js +29 -0
  60. package/dist/runtime/content-registration.d.ts +13 -0
  61. package/dist/runtime/content-registration.js +25 -0
  62. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  63. package/dist/runtime/core/SectionEngineCore.js +2 -2
  64. package/dist/runtime/core/cohort.d.ts +1 -1
  65. package/dist/runtime/core/cohort.js +1 -1
  66. package/dist/runtime/core/engine-input.d.ts +18 -30
  67. package/dist/runtime/core/engine-input.js +11 -12
  68. package/dist/runtime/core/engine-output.d.ts +16 -40
  69. package/dist/runtime/core/engine-output.js +9 -21
  70. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  71. package/dist/runtime/core/engine-readiness.js +8 -41
  72. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  73. package/dist/runtime/core/engine-resolver.js +18 -43
  74. package/dist/runtime/core/engine-state.d.ts +28 -36
  75. package/dist/runtime/core/engine-state.js +18 -21
  76. package/dist/runtime/core/engine-transition.d.ts +14 -19
  77. package/dist/runtime/core/engine-transition.js +71 -51
  78. package/dist/runtime/engine.d.ts +16 -19
  79. package/dist/runtime/engine.js +12 -19
  80. package/dist/runtime/page-warnings.d.ts +13 -0
  81. package/dist/runtime/page-warnings.js +22 -0
  82. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  83. package/dist/runtime/policy-input-forwarding.js +25 -0
  84. package/dist/runtime/registration-events.d.ts +14 -0
  85. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  86. package/dist/runtime/runtime-event-claim.js +15 -0
  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 +188 -0
  93. package/dist/runtime/tool-host-contract.d.ts +28 -10
  94. package/dist/runtime/tool-host-contract.js +31 -10
  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/ElementToolStateStore.d.ts +14 -10
  98. package/dist/services/ElementToolStateStore.js +37 -13
  99. package/dist/services/HighlightCoordinator.d.ts +22 -28
  100. package/dist/services/HighlightCoordinator.js +431 -327
  101. package/dist/services/RangeSerializer.d.ts +10 -1
  102. package/dist/services/RangeSerializer.js +87 -14
  103. package/dist/services/TTSService.d.ts +130 -69
  104. package/dist/services/TTSService.js +713 -570
  105. package/dist/services/ToolCoordinator.d.ts +43 -66
  106. package/dist/services/ToolCoordinator.js +164 -208
  107. package/dist/services/ToolRegistry.d.ts +78 -54
  108. package/dist/services/ToolRegistry.js +107 -105
  109. package/dist/services/ToolkitCoordinator.d.ts +261 -175
  110. package/dist/services/ToolkitCoordinator.js +667 -433
  111. package/dist/services/audio-handoff.d.ts +6 -7
  112. package/dist/services/audio-handoff.js +20 -14
  113. package/dist/services/catalog-owner.d.ts +11 -1
  114. package/dist/services/catalog-owner.js +25 -0
  115. package/dist/services/create-tools-config.d.ts +2 -2
  116. package/dist/services/framework-error-bus.d.ts +3 -6
  117. package/dist/services/framework-error-bus.js +3 -6
  118. package/dist/services/framework-error.d.ts +30 -8
  119. package/dist/services/framework-error.js +9 -7
  120. package/dist/services/interfaces.d.ts +158 -129
  121. package/dist/services/section-controller-types.d.ts +15 -0
  122. package/dist/services/section-session-assignment.d.ts +21 -0
  123. package/dist/services/section-session-assignment.js +82 -0
  124. package/dist/services/spoken-audio-cards.d.ts +0 -2
  125. package/dist/services/spoken-audio-cards.js +1 -3
  126. package/dist/services/tool-config-defaults.d.ts +3 -3
  127. package/dist/services/tool-config-defaults.js +3 -3
  128. package/dist/services/tool-config-validation.d.ts +14 -4
  129. package/dist/services/tool-config-validation.js +63 -71
  130. package/dist/services/tool-context.d.ts +0 -8
  131. package/dist/services/tool-context.js +29 -25
  132. package/dist/services/tool-icons.d.ts +4 -3
  133. package/dist/services/tool-icons.js +4 -3
  134. package/dist/services/tool-instance-id.d.ts +0 -1
  135. package/dist/services/tool-instance-id.js +0 -6
  136. package/dist/services/tool-providers/TTSToolProvider.d.ts +41 -80
  137. package/dist/services/tool-providers/TTSToolProvider.js +81 -40
  138. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  139. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +32 -28
  140. package/dist/services/tool-providers/ToolProviderRegistry.js +121 -107
  141. package/dist/services/tool-providers/index.d.ts +0 -9
  142. package/dist/services/tool-providers/index.js +2 -4
  143. package/dist/services/tool-request.d.ts +27 -12
  144. package/dist/services/tool-request.js +24 -14
  145. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  146. package/dist/services/toolbar-decided-tools.js +28 -0
  147. package/dist/services/tools-config-normalizer.d.ts +37 -23
  148. package/dist/services/tools-config-normalizer.js +2 -10
  149. package/dist/services/tts/browser-provider.d.ts +38 -2
  150. package/dist/services/tts/browser-provider.js +276 -140
  151. package/dist/services/tts/catalog-span-alignment.d.ts +2 -17
  152. package/dist/services/tts/catalog-span-alignment.js +17 -142
  153. package/dist/services/tts/flat-tree.d.ts +71 -0
  154. package/dist/services/tts/flat-tree.js +269 -0
  155. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  156. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  157. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  158. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  159. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  160. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  161. package/dist/services/tts/generated-speech/types.d.ts +0 -12
  162. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
  163. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +38 -5
  164. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
  165. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  166. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  167. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  168. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  169. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  170. package/dist/services/tts/math-alignment/index.d.ts +0 -5
  171. package/dist/services/tts/math-alignment/index.js +5 -25
  172. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
  173. package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
  174. package/dist/services/tts/math-alignment/types.d.ts +4 -2
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +74 -18
  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 +16 -1
  180. package/dist/services/tts/math-speech.js +55 -19
  181. package/dist/services/tts/provider-options.d.ts +41 -0
  182. package/dist/services/tts/provider-options.js +1 -0
  183. package/dist/services/tts/sre-engine.d.ts +7 -0
  184. package/dist/services/tts/sre-engine.js +11 -0
  185. package/dist/services/tts/sre-locales.d.ts +14 -0
  186. package/dist/services/tts/sre-locales.js +57 -0
  187. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  188. package/dist/services/tts/ssml/spoken-text.js +29 -0
  189. package/dist/services/tts/start-failure.d.ts +11 -0
  190. package/dist/services/tts/start-failure.js +19 -0
  191. package/dist/services/tts/text-processing.d.ts +9 -1
  192. package/dist/services/tts/text-processing.js +46 -34
  193. package/dist/services/tts/text-segmentation.d.ts +7 -0
  194. package/dist/services/tts/text-segmentation.js +47 -0
  195. package/dist/services/tts-runtime-config.d.ts +44 -18
  196. package/dist/services/tts-runtime-config.js +22 -93
  197. package/dist/tools/content-capability-resolution.d.ts +7 -9
  198. package/dist/tools/content-capability-resolution.js +8 -45
  199. package/dist/tools/registration.d.ts +58 -0
  200. package/dist/tools/registration.js +54 -0
  201. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  202. package/dist/tools/tool-surface-host.d.ts +6 -10
  203. package/dist/tools/tool-surface-host.js +44 -42
  204. package/dist/tools/tool-tag-map.d.ts +5 -15
  205. package/dist/tools/tool-tag-map.js +6 -20
  206. package/dist/utils/structural-equality.d.ts +11 -0
  207. package/dist/utils/structural-equality.js +47 -0
  208. package/package.json +24 -59
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  210. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  211. package/dist/components/chunks/ItemToolBar-byq80z42.js +0 -40
  212. package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
  213. package/dist/context/runtime-context-consumer.d.ts +0 -13
  214. package/dist/context/runtime-context-consumer.js +0 -51
  215. package/dist/item-loader.d.ts +0 -44
  216. package/dist/item-loader.js +0 -61
  217. package/dist/policy/core/engine-context.d.ts +0 -29
  218. package/dist/policy/core/engine-context.js +0 -27
  219. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  220. package/dist/policy/core/policy-source-tag.js +0 -28
  221. package/dist/policy/internal.d.ts +0 -25
  222. package/dist/policy/internal.js +0 -25
  223. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  224. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  225. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  226. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  227. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  228. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  229. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  230. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  231. package/dist/runtime/engine-context.d.ts +0 -39
  232. package/dist/runtime/engine-context.js +0 -31
  233. package/dist/runtime/internal.d.ts +0 -43
  234. package/dist/runtime/internal.js +0 -45
  235. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -80
  236. package/dist/runtime/section-runtime-engine-host-context.js +0 -95
  237. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  238. package/dist/runtime/stage-emit-gate.js +0 -82
  239. package/dist/services/I18nService.d.ts +0 -47
  240. package/dist/services/I18nService.js +0 -84
  241. package/dist/services/ThemeProvider.d.ts +0 -95
  242. package/dist/services/ThemeProvider.js +0 -334
  243. package/dist/services/ToolConfigResolver.d.ts +0 -161
  244. package/dist/services/ToolConfigResolver.js +0 -229
  245. package/dist/services/catalog-media.d.ts +0 -56
  246. package/dist/services/catalog-media.js +0 -190
  247. package/dist/services/createDefaultToolRegistry.d.ts +0 -53
  248. package/dist/services/createDefaultToolRegistry.js +0 -45
  249. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  250. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  251. package/dist/services/pnp-standard-features.d.ts +0 -238
  252. package/dist/services/pnp-standard-features.js +0 -338
  253. package/dist/services/tool-providers/CortexToolProvider.d.ts +0 -13
  254. package/dist/services/tool-providers/CortexToolProvider.js +0 -32
  255. package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
  256. package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
  257. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
  258. package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
  259. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
  260. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
  261. package/dist/services/tts/visual-line-ranges.d.ts +0 -18
  262. package/dist/services/tts/visual-line-ranges.js +0 -226
  263. package/dist/tools/client.d.ts +0 -12
  264. package/dist/tools/client.js +0 -14
  265. package/dist/tools/index.d.ts +0 -8
  266. package/dist/tools/index.js +0 -10
  267. package/dist/tools/internal.d.ts +0 -43
  268. package/dist/tools/internal.js +0 -41
  269. package/dist/tools/response-discovery.d.ts +0 -61
  270. package/dist/tools/response-discovery.js +0 -113
  271. package/dist/tools/types.d.ts +0 -337
  272. package/dist/tools/types.js +0 -12
  273. package/dist/tools/variant-resolver.d.ts +0 -41
  274. package/dist/tools/variant-resolver.js +0 -207
@@ -15,19 +15,26 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
- import { applyMediaFragment, enforceMediaFragment } from "./catalog-media.js";
19
- import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
18
+ import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
19
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
20
+ import { HighlightType } from "./HighlightCoordinator.js";
20
21
  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";
22
+ import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
23
+ import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
24
+ import { composedContains, composedParentElement, flatQuerySelectorAll, flatTextContent, flatTreeChildNodes, flatTreeParentElement, flatTreeTextNodes, rangeHoldsTextPosition, rangeIntersectsComposedNode, } from "./tts/flat-tree.js";
25
+ import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, rangeHoldsSpeakableElement, textInRange, } from "./tts/text-processing.js";
23
26
  import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
24
27
  import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
25
28
  import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
26
29
  import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
27
30
  import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
28
- import { segmentSentences as segmentTextToSentences, } from "./tts/text-segmentation.js";
31
+ import { toTTSStartFailure } from "./tts/start-failure.js";
32
+ import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
29
33
  import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
34
+ import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.js";
30
35
  import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.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,21 @@ export var PlaybackState;
39
46
  PlaybackState["PAUSED"] = "paused";
40
47
  PlaybackState["ERROR"] = "error";
41
48
  })(PlaybackState || (PlaybackState = {}));
49
+ const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
50
+ const hostLocalesOf = (options, previous = {}) => {
51
+ if (!options)
52
+ return previous;
53
+ return {
54
+ locale: "locale" in options ? optionalString(options.locale) : previous.locale,
55
+ textNormalization: options.textNormalization && "locale" in options.textNormalization
56
+ ? optionalString(options.textNormalization.locale)
57
+ : previous.textNormalization,
58
+ segmenter: options.segmenter && "locale" in options.segmenter
59
+ ? optionalString(options.segmenter.locale)
60
+ : previous.segmenter,
61
+ };
62
+ };
63
+ const isRange = (target) => "commonAncestorContainer" in target;
42
64
  const sameRange = (left, right) => left === right ||
43
65
  (left.startContainer === right.startContainer &&
44
66
  left.startOffset === right.startOffset &&
@@ -79,11 +101,22 @@ export class TTSService {
79
101
  highlightTargetResolverProvider = null;
80
102
  catalogResolver = null;
81
103
  state = PlaybackState.IDLE;
104
+ // The state a resume returns to: loading when the pause came before the
105
+ // audio started sounding.
106
+ resumeState = PlaybackState.PLAYING;
82
107
  ttsConfig = {};
108
+ // The locales the host configured. A read naming a language sets all three
109
+ // to it; a read naming none restores these.
110
+ hostLocales = {};
111
+ // The locale of the read in progress, which segmentation reads.
112
+ readLocale = DEFAULT_CONTENT_LANGUAGE;
113
+ disposed = false;
114
+ readinessGate = null;
115
+ mathSpeechSource = null;
83
116
  currentText = null;
84
117
  currentContentElement = null;
85
118
  normalizedToDOM = new Map();
86
- listeners = new Map();
119
+ listeners = new Set();
87
120
  lastError = null;
88
121
  speakRunId = 0;
89
122
  currentBoundaryOffset = 0;
@@ -92,14 +125,18 @@ export class TTSService {
92
125
  playbackChunks = [];
93
126
  // The recording currently playing, if any. `cancel` settles the pending play
94
127
  // promise as well as stopping the element, so stop/seek cannot wedge the
95
- // chunk loop on a run that has already been superseded.
128
+ // chunk loop on a run that has already been superseded. `play` starts it, or
129
+ // starts it again after a pause.
96
130
  activeRecordedAudio = null;
97
131
  sentenceHighlightSegments = [];
98
- currentSeekSegmentIndex = 0;
99
132
  activeSentenceStartOffset = null;
100
133
  playbackStartDeferredRunId = null;
101
134
  pendingPlaybackStartHighlights = [];
102
135
  playbackStartBarrierCleanup = null;
136
+ // A pause between two parts of a run, in a structural gap or while the next
137
+ // part loads, holds the run here until resume.
138
+ pauseHold = null;
139
+ structuralPause = null;
103
140
  playbackRateWriteQueue = Promise.resolve();
104
141
  playbackRateRequestId = 0;
105
142
  pendingPlaybackRateRequest = null;
@@ -111,32 +148,7 @@ export class TTSService {
111
148
  // Caches DOM-free spoken text keyed by SRE settings and MathML source across
112
149
  // utterances within this service instance.
113
150
  generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
114
- // Verbose alignment/highlight tracing is opt-in: it is expensive (large
115
- // objects, a full text diff per utterance) and noisy in production. Enable
116
- // with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
117
- static debugEnabled = (() => {
118
- try {
119
- if (typeof process !== "undefined" &&
120
- process.env?.PIE_TTS_DEBUG === "1") {
121
- return true;
122
- }
123
- }
124
- catch {
125
- // `process` is not defined in the browser; fall through to the global.
126
- }
127
- return (globalThis.__PIE_TTS_DEBUG__ === true);
128
- })();
129
151
  constructor() { }
130
- debugLog(message, detail) {
131
- if (!TTSService.debugEnabled)
132
- return;
133
- if (detail === undefined) {
134
- console.debug(message);
135
- }
136
- else {
137
- console.debug(message, detail);
138
- }
139
- }
140
152
  clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
141
153
  cleanup?.();
142
154
  if (this.playbackStartBarrierCleanup === cleanup) {
@@ -148,7 +160,12 @@ export class TTSService {
148
160
  runId !== this.speakRunId) {
149
161
  return;
150
162
  }
151
- this.setState(PlaybackState.PLAYING);
163
+ if (this.state === PlaybackState.PAUSED) {
164
+ // The learner paused before this part began: it holds where it started.
165
+ this.activeRecordedAudio?.element.pause();
166
+ this.provider?.pause();
167
+ }
168
+ this.markPlaying();
152
169
  const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
153
170
  for (const applyHighlight of pendingHighlights) {
154
171
  applyHighlight();
@@ -192,6 +209,82 @@ export class TTSService {
192
209
  }
193
210
  callback();
194
211
  }
212
+ /** Resolves when run `runId` may start its next part: at once unless paused. */
213
+ async waitWhilePaused(runId) {
214
+ while (this.state === PlaybackState.PAUSED && runId === this.speakRunId) {
215
+ if (!this.pauseHold) {
216
+ let release = () => { };
217
+ const promise = new Promise((resolve) => {
218
+ release = resolve;
219
+ });
220
+ this.pauseHold = { promise, release };
221
+ }
222
+ await this.pauseHold.promise;
223
+ }
224
+ }
225
+ releasePauseHold() {
226
+ const hold = this.pauseHold;
227
+ this.pauseHold = null;
228
+ hold?.release();
229
+ }
230
+ waitStructuralPause(pauseMs) {
231
+ this.cancelStructuralPause();
232
+ return new Promise((resolve) => {
233
+ const finish = () => {
234
+ if (this.structuralPause?.finish === finish) {
235
+ clearTimeout(this.structuralPause.timer);
236
+ this.structuralPause = null;
237
+ }
238
+ resolve();
239
+ };
240
+ this.structuralPause = { timer: setTimeout(finish, pauseMs), finish };
241
+ });
242
+ }
243
+ cancelStructuralPause() {
244
+ this.structuralPause?.finish();
245
+ }
246
+ /**
247
+ * Release what a superseded run still holds: its start barrier, a recorded
248
+ * clip still playing, a structural gap and a pause hold. Its loops then see
249
+ * the new run id and return.
250
+ */
251
+ abandonRun() {
252
+ this.clearPlaybackStartBarrier();
253
+ this.cancelRecordedAudio();
254
+ this.cancelStructuralPause();
255
+ this.releasePauseHold();
256
+ }
257
+ /**
258
+ * Start a run and enter loading. From here a pause or stop applies to the
259
+ * run, whatever it is still waiting for: the provider, its content or the
260
+ * provider's audio.
261
+ */
262
+ beginRun() {
263
+ // A recorded clip that starts the new run would otherwise play over the
264
+ // previous run's synthesis, which only the provider's next speak stops.
265
+ const supersedesPlayback = this.state === PlaybackState.LOADING ||
266
+ this.state === PlaybackState.PLAYING ||
267
+ this.state === PlaybackState.PAUSED;
268
+ const runId = ++this.speakRunId;
269
+ this.abandonRun();
270
+ if (supersedesPlayback)
271
+ this.provider?.stop();
272
+ this.setState(PlaybackState.LOADING);
273
+ return runId;
274
+ }
275
+ /** The run's audio is sounding; under a pause, it sounds again on resume. */
276
+ markPlaying() {
277
+ if (this.state === PlaybackState.PAUSED) {
278
+ this.resumeState = PlaybackState.PLAYING;
279
+ return;
280
+ }
281
+ this.setState(PlaybackState.PLAYING);
282
+ }
283
+ assertNotDisposed() {
284
+ if (this.disposed) {
285
+ throw toTTSStartFailure(new Error("TTS service disposed"));
286
+ }
287
+ }
195
288
  async emitTelemetry(eventName, payload) {
196
289
  try {
197
290
  await this.telemetryReporter?.(eventName, payload);
@@ -207,72 +300,39 @@ export class TTSService {
207
300
  * @param config Provider configuration
208
301
  */
209
302
  async initialize(provider, config = {}) {
303
+ if (this.disposed)
304
+ throw new Error("TTS service disposed");
210
305
  this.currentProvider = provider;
211
306
  this.ttsConfig = { ...config };
212
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
213
- ? config.providerOptions
214
- : {};
215
- this.telemetryReporter =
216
- typeof providerOptions.__pieTelemetry === "function"
217
- ? providerOptions.__pieTelemetry
218
- : null;
219
- // Initialize provider and get implementation. Browser fallback is only
220
- // allowed here, before the configured provider has successfully become
221
- // the active runtime. Per-call playback/synthesis errors must surface
222
- // instead of silently replacing a user-selected provider and voice.
223
- try {
224
- this.provider = await provider.initialize(config);
225
- }
226
- catch (error) {
227
- if (provider.providerId.toLowerCase() === "browser") {
228
- throw error;
229
- }
230
- if (!this.isBrowserSpeechFallbackAvailable()) {
231
- throw error;
232
- }
233
- const switched = await this.switchProviderToBrowser(error, {
234
- operation: "tts-initialize",
235
- });
236
- if (!switched) {
237
- throw error;
238
- }
239
- }
307
+ this.hostLocales = hostLocalesOf(config.providerOptions);
308
+ const telemetry = this.providerOptions().__pieTelemetry;
309
+ this.telemetryReporter = typeof telemetry === "function" ? telemetry : null;
310
+ // A provider that fails to start throws to the caller, which owns any
311
+ // fallback. Playback and synthesis errors surface the same way.
312
+ this.provider = await provider.initialize(config);
240
313
  }
241
314
  /**
242
- * Update TTS settings dynamically without full reinitialization
243
- *
244
- * This allows changing rate, pitch, and voice on the fly.
245
- * Note: Some providers may require reinitialization for voice changes.
246
- *
247
- * @param settings Partial settings to update (rate, pitch, voice)
315
+ * Update rate, pitch, voice or provider options without reinitializing.
316
+ * `providerOptions` merge over the ones already configured.
248
317
  */
249
318
  async updateSettings(settings) {
250
319
  if (!this.provider) {
251
320
  throw new Error("TTSService not initialized. Call initialize() first.");
252
321
  }
253
- // If the provider implementation has an updateSettings method, use it
254
- const provider = this.provider;
255
- if (typeof provider.updateSettings === "function") {
256
- const mergedSettings = {
257
- ...settings,
258
- ...(settings.providerOptions
259
- ? {
260
- providerOptions: {
261
- ...(this.ttsConfig.providerOptions || {}),
262
- ...settings.providerOptions,
263
- },
264
- }
265
- : {}),
266
- };
267
- await provider.updateSettings(mergedSettings);
268
- this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
269
- }
270
- else {
271
- // Fallback: Reinitialize with merged config
272
- // This requires storing the original config, which we don't have
273
- // So for now, just log a warning
274
- console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
275
- }
322
+ const mergedSettings = {
323
+ ...settings,
324
+ ...(settings.providerOptions
325
+ ? {
326
+ providerOptions: {
327
+ ...this.providerOptions(),
328
+ ...settings.providerOptions,
329
+ },
330
+ }
331
+ : {}),
332
+ };
333
+ await this.provider.updateSettings(mergedSettings);
334
+ this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
335
+ this.hostLocales = hostLocalesOf(settings.providerOptions, this.hostLocales);
276
336
  }
277
337
  /**
278
338
  * Set highlight coordinator for word highlighting
@@ -280,6 +340,29 @@ export class TTSService {
280
340
  setHighlightCoordinator(coordinator) {
281
341
  this.highlightCoordinator = coordinator;
282
342
  }
343
+ /**
344
+ * Starts the service when a caller speaks before it is initialized; a gate
345
+ * that rejects fails the speak with its error. The coordinator installs one
346
+ * that runs its own text-to-speech start.
347
+ */
348
+ setReadinessGate(gate) {
349
+ this.readinessGate = gate;
350
+ }
351
+ /**
352
+ * Where the configured math speech options come from before, and apart from,
353
+ * initialization, so math names do not depend on when speech started.
354
+ */
355
+ setMathSpeechSource(source) {
356
+ this.mathSpeechSource = source;
357
+ }
358
+ /** The start a speak must wait for, or null when it can speak now without yielding. */
359
+ pendingReadiness() {
360
+ if (this.provider || !this.readinessGate)
361
+ return null;
362
+ return this.readinessGate().catch((error) => {
363
+ throw toTTSStartFailure(error);
364
+ });
365
+ }
283
366
  /**
284
367
  * Set a late-bound provider for optional host TTS highlight target remapping.
285
368
  *
@@ -314,31 +397,6 @@ export class TTSService {
314
397
  setCatalogResolver(resolver) {
315
398
  this.catalogResolver = resolver;
316
399
  }
317
- /**
318
- * Whether a catalog holds spoken content this service could actually speak.
319
- *
320
- * Exists because `data-catalog-idref` is one attribute with several readers:
321
- * a node can be docked to a catalog that carries only a signing card, and the
322
- * nearest docked ancestor of a selection is therefore not necessarily the one
323
- * holding its authored SSML. A caller resolving a selection climbs ancestors
324
- * and asks this before settling on an id — see the TTS tool's
325
- * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
326
- * silently shadows authored speech on an outer one.
327
- */
328
- hasSpokenAlternate(catalogId, language = "en-US") {
329
- if (!catalogId || !this.catalogResolver)
330
- return false;
331
- const resolved = this.catalogResolver.getAlternative(catalogId, {
332
- type: "spoken",
333
- language,
334
- useFallback: true,
335
- // The string form specifically. A node may also carry a recording of the
336
- // same script; that one is not what this reports on, and card order must
337
- // not decide the answer.
338
- form: "content",
339
- });
340
- return resolved?.content !== undefined;
341
- }
342
400
  getHighlightResolverRuntime() {
343
401
  let provided = null;
344
402
  try {
@@ -365,12 +423,12 @@ export class TTSService {
365
423
  return null;
366
424
  return node.nodeType === Node.ELEMENT_NODE
367
425
  ? node
368
- : node.parentElement;
426
+ : composedParentElement(node);
369
427
  }
370
428
  isElementWithinScope(element, scope) {
371
429
  if (!scope)
372
430
  return true;
373
- return element === scope || scope.contains(element);
431
+ return composedContains(scope, element);
374
432
  }
375
433
  isRangeWithinScope(range, scope) {
376
434
  const startElement = this.getNodeElement(range.startContainer);
@@ -445,28 +503,21 @@ export class TTSService {
445
503
  return null;
446
504
  }
447
505
  }
448
- paintTTSWordRange(range) {
449
- if (!this.highlightCoordinator)
506
+ /**
507
+ * Paint the spoken word, each range offered to the host's resolver. A word
508
+ * split across inline elements is one range over its text nodes; across
509
+ * shadow trees, one range per tree.
510
+ */
511
+ paintTTSWord(nativeRanges) {
512
+ if (!this.highlightCoordinator || nativeRanges.length === 0)
450
513
  return;
451
- try {
452
- if (range.startContainer === range.endContainer &&
453
- range.startContainer.nodeType === Node.TEXT_NODE) {
454
- this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
455
- return;
456
- }
457
- }
458
- catch {
459
- // Fall through to generic range painting for lightweight test doubles.
460
- }
461
- this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
514
+ this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
462
515
  }
463
516
  paintTTSSentenceRanges(nativeRanges) {
464
517
  if (!this.highlightCoordinator)
465
518
  return;
466
519
  const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
467
- if (elements.length > 0 &&
468
- ranges.length === 0 &&
469
- this.highlightCoordinator.highlightTTSSentenceElements) {
520
+ if (elements.length > 0 && ranges.length === 0) {
470
521
  this.highlightCoordinator.highlightTTSSentenceElements(elements);
471
522
  return;
472
523
  }
@@ -484,36 +535,119 @@ export class TTSService {
484
535
  getCapabilities() {
485
536
  return this.currentProvider?.getCapabilities() || null;
486
537
  }
538
+ providerOptions() {
539
+ const options = this.ttsConfig.providerOptions;
540
+ return options && typeof options === "object" ? options : {};
541
+ }
542
+ /** The longest text the provider accepts in one speak, when it declares one. */
543
+ maxTextLength() {
544
+ const limit = this.currentProvider?.getCapabilities?.().maxTextLength;
545
+ return typeof limit === "number" && limit > 0 ? limit : undefined;
546
+ }
547
+ /**
548
+ * Speak `text`, in pieces when it exceeds the provider's `maxTextLength`. Word
549
+ * boundaries keep indexing the whole text. An SSML document cannot be cut, so
550
+ * one over the limit fails and the chunk's plain fallback reads instead.
551
+ */
552
+ async speakWithinLimit(text, runId) {
553
+ const provider = this.provider;
554
+ if (!provider)
555
+ return;
556
+ const limit = this.maxTextLength();
557
+ if (!limit || text.length <= limit) {
558
+ await this.waitWhilePaused(runId);
559
+ if (runId !== this.speakRunId || this.provider !== provider)
560
+ return;
561
+ await provider.speak(text);
562
+ return;
563
+ }
564
+ if (isSsmlDocument(text)) {
565
+ throw new Error(`[tts] SSML of ${text.length} characters exceeds the provider's limit of ${limit}`);
566
+ }
567
+ const onWordBoundary = provider.onWordBoundary;
568
+ try {
569
+ for (const piece of splitTextToLength(text, limit, {
570
+ locale: this.readLocale,
571
+ })) {
572
+ await this.waitWhilePaused(runId);
573
+ if (runId !== this.speakRunId || this.provider !== provider)
574
+ return;
575
+ provider.onWordBoundary = onWordBoundary
576
+ ? (word, position, length) => onWordBoundary(word, position + piece.offset, length)
577
+ : undefined;
578
+ await provider.speak(piece.text);
579
+ }
580
+ }
581
+ finally {
582
+ if (this.provider === provider && runId === this.speakRunId) {
583
+ provider.onWordBoundary = onWordBoundary;
584
+ }
585
+ }
586
+ }
587
+ /** Segments no longer than the provider's `maxTextLength`, offsets kept. */
588
+ segmentsWithinLimit(segments) {
589
+ const limit = this.maxTextLength();
590
+ if (!limit)
591
+ return segments;
592
+ return segments.flatMap((segment) => {
593
+ if (segment.text.length <= limit)
594
+ return [segment];
595
+ const pieces = splitTextToLength(segment.text, limit, {
596
+ locale: this.readLocale,
597
+ });
598
+ return pieces.map((piece, index) => ({
599
+ text: piece.text,
600
+ startOffset: segment.startOffset + piece.offset,
601
+ pauseMsAfter: index === pieces.length - 1 ? segment.pauseMsAfter : undefined,
602
+ }));
603
+ });
604
+ }
487
605
  /**
488
606
  * Build a character-by-character map from normalized text positions to DOM positions
489
607
  * This handles the complexity of whitespace normalization
490
608
  */
491
- getTextProcessingOptions(language) {
492
- const providerOptions = (this.ttsConfig.providerOptions || {});
493
- const textNormalization = (providerOptions.textNormalization ||
494
- {});
495
- const mode = textNormalization.boundarySpacingMode;
609
+ getTextProcessingOptions(locale) {
610
+ const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
496
611
  const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
497
612
  ? mode
498
613
  : undefined;
499
- const locale = language ||
500
- (typeof textNormalization.locale === "string"
501
- ? textNormalization.locale
502
- : undefined);
503
614
  return { locale, boundarySpacingMode };
504
615
  }
616
+ /**
617
+ * The language a read of `node` is in. Its content language is the nearest
618
+ * `lang` between it and its shell, else the language the speak names, else a
619
+ * pinned `lang_id`. Its locale is that content language, else the host's
620
+ * configured locale, else en-US.
621
+ */
622
+ resolveReadLanguage(node, named) {
623
+ const contentLanguage = findContentLanguage(node, { contentLanguage: named }) ??
624
+ optionalString(this.providerOptions().lang_id);
625
+ return {
626
+ contentLanguage,
627
+ locale: contentLanguage ??
628
+ this.hostLocales.locale ??
629
+ this.hostLocales.textNormalization ??
630
+ DEFAULT_CONTENT_LANGUAGE,
631
+ };
632
+ }
633
+ /**
634
+ * The host's SRE options for math speech: from the installed source, else as
635
+ * this service was initialized with.
636
+ */
505
637
  getMathSpeechOptions() {
506
- const providerOptions = (this.ttsConfig.providerOptions || {});
507
- const mathSpeech = providerOptions.mathSpeech;
508
- return normalizeSREMathSpeechOptions(mathSpeech);
638
+ if (this.mathSpeechSource) {
639
+ return normalizeSREMathSpeechOptions(this.mathSpeechSource());
640
+ }
641
+ return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
509
642
  }
510
643
  buildPositionMap(element, spokenText, language) {
511
644
  this.normalizedToDOM.clear();
512
645
  const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
513
646
  this.normalizedToDOM = map;
514
- if (!TTSService.debugEnabled)
647
+ // The comparison below diffs the whole text, so it runs only when traced.
648
+ if (!isTtsDebugEnabled())
515
649
  return;
516
- this.debugLog("[TTSService] Text comparison:", {
650
+ logger.debug("Text comparison:", {
517
651
  spokenLength: spokenText.length,
518
652
  normalizedDomLength: normalizedDomText.length,
519
653
  match: spokenText === normalizedDomText,
@@ -536,7 +670,7 @@ export class TTSService {
536
670
  return null;
537
671
  })(),
538
672
  });
539
- this.debugLog("[TTSService] Position map built:", {
673
+ logger.debug("Position map built:", {
540
674
  entries: this.normalizedToDOM.size,
541
675
  spokenTextLength: spokenText.length,
542
676
  normalizedDomLength: normalizedDomText.length,
@@ -552,9 +686,8 @@ export class TTSService {
552
686
  });
553
687
  }
554
688
  getStructuralPauseProfile() {
555
- const providerOptions = (this.ttsConfig.providerOptions || {});
556
- const custom = (providerOptions.structuralPauses || {});
557
- const customUnits = (custom.units || {});
689
+ const custom = this.providerOptions().structuralPauses || {};
690
+ const customUnits = custom.units || {};
558
691
  const toNumber = (value, fallback) => {
559
692
  if (typeof value === "number" && Number.isFinite(value))
560
693
  return value;
@@ -582,17 +715,15 @@ export class TTSService {
582
715
  return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
583
716
  }
584
717
  resolveHighlightMode() {
585
- const providerOptions = (this.ttsConfig.providerOptions || {});
586
- const configuredMode = providerOptions.highlightMode;
718
+ const configuredMode = this.providerOptions().highlightMode;
587
719
  if (configuredMode === "word" || configuredMode === "sentence") {
588
720
  return configuredMode;
589
721
  }
590
- const providerId = (this.currentProvider?.providerId || "").toLowerCase();
591
- if (providerId === "browser") {
592
- return "sentence";
722
+ const capabilities = this.currentProvider?.getCapabilities();
723
+ if (capabilities?.defaultHighlightMode) {
724
+ return capabilities.defaultHighlightMode;
593
725
  }
594
- const supportsWordBoundary = this.currentProvider?.getCapabilities().supportsWordBoundary ?? false;
595
- return supportsWordBoundary ? "word" : "sentence";
726
+ return capabilities?.supportsWordBoundary ? "word" : "sentence";
596
727
  }
597
728
  hasExplicitBreakSemantics(text) {
598
729
  if (!text.includes("<"))
@@ -632,7 +763,7 @@ export class TTSService {
632
763
  return "minor";
633
764
  }
634
765
  getBoundaryAnchor(textNode, root) {
635
- let current = textNode.parentElement;
766
+ let current = flatTreeParentElement(textNode);
636
767
  let best = null;
637
768
  while (current && current !== root) {
638
769
  if (this.isElementHidden(current))
@@ -665,7 +796,7 @@ export class TTSService {
665
796
  }
666
797
  }
667
798
  }
668
- current = current.parentElement;
799
+ current = flatTreeParentElement(current);
669
800
  }
670
801
  if (!best)
671
802
  return null;
@@ -675,20 +806,31 @@ export class TTSService {
675
806
  units: this.getStructuralPauseProfile().units[strength],
676
807
  };
677
808
  }
678
- createSpeechPlan(contentElement, normalizedText) {
679
- const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText);
680
- return this.createSpeechPlanSegments(normalizedText, boundaries);
809
+ /**
810
+ * `textOffset` is where `normalizedText` starts in the element's visible text:
811
+ * boundaries are collected over the whole element, and a selection speaks only
812
+ * part of it.
813
+ */
814
+ createSpeechPlan(contentElement, normalizedText, textOffset, language) {
815
+ const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText, language);
816
+ if (textOffset === 0) {
817
+ return this.createSpeechPlanSegments(normalizedText, boundaries);
818
+ }
819
+ const shifted = new Map();
820
+ for (const [point, units] of boundaries) {
821
+ shifted.set(point - textOffset, units);
822
+ }
823
+ return this.createSpeechPlanSegments(normalizedText, shifted);
681
824
  }
682
- collectSpeechPlanBoundaries(contentElement, normalizedText) {
825
+ collectSpeechPlanBoundaries(contentElement, normalizedText, language) {
683
826
  const boundaries = new Map();
684
- const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions());
827
+ // The same locale the position map used, so the boundary offsets index the
828
+ // text the map built.
829
+ const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions(language));
685
830
  const nodeStartOffsets = this.createNodeStartOffsets(map);
686
- const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
687
- let currentNode = walker.nextNode();
688
831
  let previousBoundaryAnchor = null;
689
- while (currentNode) {
690
- const textNode = currentNode;
691
- const parent = textNode.parentElement;
832
+ for (const textNode of flatTreeTextNodes(contentElement)) {
833
+ const parent = flatTreeParentElement(textNode);
692
834
  if (parent &&
693
835
  !this.isElementHidden(parent) &&
694
836
  !isNodeSuppressedForTTS(textNode)) {
@@ -702,7 +844,6 @@ export class TTSService {
702
844
  previousBoundaryAnchor = boundary.anchor;
703
845
  }
704
846
  }
705
- currentNode = walker.nextNode();
706
847
  }
707
848
  return boundaries;
708
849
  }
@@ -750,9 +891,7 @@ export class TTSService {
750
891
  return segments.filter((segment) => segment.text.trim().length > 0);
751
892
  }
752
893
  segmentSentences(text) {
753
- const locale = this.ttsConfig.providerOptions
754
- ?.locale || undefined;
755
- return segmentTextToSentences(text, { locale });
894
+ return segmentTextToSentences(text, { locale: this.readLocale });
756
895
  }
757
896
  createSeekSegmentsFromText(text) {
758
897
  return this.segmentSentences(text)
@@ -852,17 +991,20 @@ export class TTSService {
852
991
  const providerWithPlan = this.provider;
853
992
  if (!shouldTrackSentenceProgress &&
854
993
  typeof providerWithPlan.speakSegments === "function") {
994
+ await this.waitWhilePaused(runId);
995
+ if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
996
+ return;
997
+ }
855
998
  this.currentBoundaryOffset = 0;
856
999
  const originalOnWordBoundary = this.provider.onWordBoundary;
857
1000
  this.provider.onWordBoundary = (word, position, length) => {
858
1001
  originalOnWordBoundary?.(word, position, length);
859
1002
  if (Number.isFinite(position)) {
860
1003
  this.currentBoundaryOffset = position;
861
- this.currentSeekSegmentIndex = this.getCurrentSeekSegmentIndex();
862
1004
  }
863
1005
  };
864
1006
  try {
865
- await providerWithPlan.speakSegments(segments);
1007
+ await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
866
1008
  }
867
1009
  finally {
868
1010
  if (runId === this.speakRunId && this.provider === providerWithPlan) {
@@ -876,39 +1018,43 @@ export class TTSService {
876
1018
  wordBoundaryOffset: this.activeWordBoundaryOffset,
877
1019
  });
878
1020
  for (const segment of segments) {
1021
+ await this.waitWhilePaused(runId);
879
1022
  if (runId !== this.speakRunId)
880
1023
  return;
881
- const seekIndex = this.seekSegments.findIndex((candidate) => candidate.startOffset === segment.startOffset);
882
- if (seekIndex >= 0) {
883
- this.currentSeekSegmentIndex = seekIndex;
884
- }
885
1024
  this.currentBoundaryOffset = segment.startOffset;
886
1025
  if (shouldTrackSentenceProgress) {
887
1026
  this.runWhenPlaybackStarts(runId, () => {
888
1027
  this.highlightSentenceSegment(segment.startOffset, segment.text);
889
1028
  });
890
1029
  }
891
- await this.provider.speak(segment.text);
1030
+ await this.speakWithinLimit(segment.text, runId);
892
1031
  const pauseMs = segment.pauseMsAfter ?? 0;
893
- if (pauseMs > 0) {
894
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
1032
+ if (pauseMs > 0 && runId === this.speakRunId) {
1033
+ await this.waitStructuralPause(pauseMs);
895
1034
  }
896
1035
  }
897
1036
  }
1037
+ /**
1038
+ * `startOffset` indexes the spoken text; the position map indexes the whole
1039
+ * content element, which for a selection starts `activeWordBoundaryOffset`
1040
+ * characters earlier.
1041
+ */
898
1042
  highlightSentenceSegment(startOffset, text) {
899
1043
  if (!this.highlightCoordinator || typeof document === "undefined")
900
1044
  return;
901
1045
  const length = text.trimEnd().length;
902
1046
  if (length <= 0)
903
1047
  return;
904
- const start = this.normalizedToDOM.get(startOffset);
905
- const end = this.normalizedToDOM.get(startOffset + length - 1);
1048
+ const mapStart = startOffset + this.activeWordBoundaryOffset;
1049
+ const start = this.normalizedToDOM.get(mapStart);
1050
+ const end = this.normalizedToDOM.get(mapStart + length - 1);
906
1051
  if (!start || !end)
907
1052
  return;
908
- const range = document.createRange();
909
- range.setStart(start.node, start.offset);
910
- range.setEnd(end.node, end.offset + 1);
911
- this.paintTTSSentenceRanges([range]);
1053
+ // One range per tree: a sentence running into a shadow root paints in both.
1054
+ const ranges = createRangesFromVisibleMap(this.normalizedToDOM, mapStart, mapStart + length);
1055
+ if (ranges.length === 0)
1056
+ return;
1057
+ this.paintTTSSentenceRanges(ranges);
912
1058
  this.activeSentenceStartOffset = startOffset;
913
1059
  }
914
1060
  getSegmentIndexForOffset(segments, offset) {
@@ -940,66 +1086,145 @@ export class TTSService {
940
1086
  this.highlightSentenceSegment(segment.startOffset, segment.text);
941
1087
  }
942
1088
  /**
943
- * Find text node and offsets for highlighting a word
1089
+ * Read `target` aloud: a range reads the text it selects, an element its
1090
+ * content. A node with a spoken card that the target holds whole reads its
1091
+ * card, `catalogId` names the card of the content root, and math reads as
1092
+ * math speech.
944
1093
  *
945
- * @param charIndex Character position in normalized/spoken text
946
- * @param length Length of the word
947
- * @returns Text node and local offsets, or null if not found
1094
+ * Content marked not-to-be-spoken is never read, a named card included. When
1095
+ * the target holds nothing speakable, nothing is spoken and playback already
1096
+ * running continues.
948
1097
  */
949
- findHighlightRange(charIndex, length) {
950
- const startPos = this.normalizedToDOM.get(charIndex);
951
- if (!startPos) {
952
- console.warn(`[TTSService] No mapping found for start position ${charIndex}`, {
953
- totalMappings: this.normalizedToDOM.size,
954
- nearbyMappings: Array.from(this.normalizedToDOM.entries())
955
- .filter(([pos]) => Math.abs(pos - charIndex) < 5)
956
- .map(([pos, { offset, node }]) => ({
957
- pos,
958
- offset,
959
- char: node.textContent?.[offset],
960
- })),
961
- });
1098
+ async speak(target, options = {}) {
1099
+ this.assertNotDisposed();
1100
+ const pendingReadiness = this.pendingReadiness();
1101
+ // A speak waiting on its provider is already the current run, so a pause
1102
+ // or stop issued meanwhile applies to it.
1103
+ const waitingRunId = pendingReadiness ? this.beginRun() : null;
1104
+ if (pendingReadiness) {
1105
+ try {
1106
+ await pendingReadiness;
1107
+ }
1108
+ catch (error) {
1109
+ if (waitingRunId === this.speakRunId)
1110
+ this.setState(PlaybackState.IDLE);
1111
+ throw error;
1112
+ }
1113
+ this.assertNotDisposed();
1114
+ if (waitingRunId !== this.speakRunId)
1115
+ return;
1116
+ }
1117
+ if (!this.provider) {
1118
+ if (waitingRunId !== null)
1119
+ this.setState(PlaybackState.IDLE);
1120
+ throw toTTSStartFailure(new Error("TTS service not initialized"));
1121
+ }
1122
+ const language = this.resolveReadLanguage(isRange(target) ? target.startContainer : target, options.language);
1123
+ const content = isRange(target)
1124
+ ? this.resolveRangeTarget(target, options, language)
1125
+ : this.resolveElementTarget(target, options, language);
1126
+ if (!content) {
1127
+ if (waitingRunId !== null)
1128
+ this.setState(PlaybackState.IDLE);
1129
+ return;
1130
+ }
1131
+ await this.speakContent(content.text, content.options, waitingRunId ?? this.beginRun());
1132
+ }
1133
+ /**
1134
+ * Suppression is read from the live ancestors of what is read, because the
1135
+ * element carrying it may sit above the content root.
1136
+ */
1137
+ isSuppressedTarget(node) {
1138
+ if (!isNodeSuppressedForTTS(node))
1139
+ return false;
1140
+ console.warn("[tts] the content to read lies inside content marked not-to-be-spoken; nothing was spoken.");
1141
+ return true;
1142
+ }
1143
+ resolveElementTarget(root, options, language) {
1144
+ if (this.isSuppressedTarget(root))
1145
+ return null;
1146
+ // Math-aware, so an element holding only an equation still reads.
1147
+ const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
1148
+ if (!text) {
1149
+ console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
962
1150
  return null;
963
1151
  }
964
- // Find the end position (last character of the word)
965
- const endIndex = charIndex + length - 1;
966
- const endPos = this.normalizedToDOM.get(endIndex);
967
- if (!endPos) {
968
- console.warn(`[TTSService] No mapping found for end position ${endIndex}`, {
969
- startChar: charIndex,
970
- length,
971
- totalMappings: this.normalizedToDOM.size,
972
- });
1152
+ return {
1153
+ text,
1154
+ options: {
1155
+ catalogId: options.catalogId,
1156
+ catalogContext: options.catalogContext,
1157
+ language: language.locale,
1158
+ contentLanguage: language.contentLanguage,
1159
+ contentElement: root,
1160
+ },
1161
+ };
1162
+ }
1163
+ resolveRangeTarget(range, options, language) {
1164
+ if (this.isSuppressedTarget(range.commonAncestorContainer))
1165
+ return null;
1166
+ // Highlighting stays scoped to the content root when one is named, else to
1167
+ // the selection's nearest element.
1168
+ const ancestor = range.commonAncestorContainer;
1169
+ const root = options.contentRoot ||
1170
+ (ancestor.nodeType === Node.ELEMENT_NODE
1171
+ ? ancestor
1172
+ : composedParentElement(ancestor));
1173
+ if (!root)
1174
+ return null;
1175
+ const selected = collectRangeTextForSpeech(range, root);
1176
+ const selectedText = selected.text.trim();
1177
+ if (!selectedText) {
1178
+ if (selected.filtered) {
1179
+ console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1180
+ }
973
1181
  return null;
974
1182
  }
975
- // For simplicity, if the word spans multiple nodes, just highlight in the first node
976
- // (This is a rare edge case and would require creating multiple ranges)
977
- if (startPos.node !== endPos.node) {
978
- console.warn(`[TTSService] Word spans multiple nodes, highlighting in first node only`);
979
- return {
980
- node: startPos.node,
981
- start: startPos.offset,
982
- end: (startPos.node.textContent || "").length,
983
- };
1183
+ // The spoken text and its offset come from the root's visible-text map, which
1184
+ // the highlight position map and the structural speech plan index too. Joined
1185
+ // from raw text nodes instead, a selection loses the space the map inserts
1186
+ // between blocks, and every offset after that boundary drifts.
1187
+ const mapped = this.selectMappedRangeText(range, root, language.locale);
1188
+ let text = selectedText;
1189
+ let offset = mapped?.offset ?? 0;
1190
+ if (mapped) {
1191
+ text = mapped.text;
984
1192
  }
1193
+ else {
1194
+ // Filtered the same way as the speech itself: the offset indexes into the
1195
+ // highlight text, which comes from the exclusion-aware collectors.
1196
+ const beforeRange = document.createRange();
1197
+ beforeRange.selectNodeContents(root);
1198
+ beforeRange.setEnd(range.startContainer, range.startOffset);
1199
+ const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
1200
+ const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
1201
+ offset =
1202
+ normalizedTextBeforeRange.length +
1203
+ (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
1204
+ }
1205
+ logger.debug("selection offset calculation:", {
1206
+ selectedText: text,
1207
+ mapped: !!mapped,
1208
+ offset,
1209
+ rootTag: root.tagName,
1210
+ });
985
1211
  return {
986
- node: startPos.node,
987
- start: startPos.offset,
988
- end: endPos.offset + 1, // +1 because we want to include the character at endPos
1212
+ text,
1213
+ options: {
1214
+ // The root's card reads only when the selection holds all of the root.
1215
+ catalogId: options.catalogId && rangeHoldsSpeakableElement(range, root, root)
1216
+ ? options.catalogId
1217
+ : undefined,
1218
+ catalogContext: options.catalogContext,
1219
+ language: language.locale,
1220
+ contentLanguage: language.contentLanguage,
1221
+ contentElement: root,
1222
+ contentRange: range,
1223
+ wordBoundaryOffset: offset,
1224
+ },
989
1225
  };
990
1226
  }
991
- /**
992
- * Speak text with optional catalog support
993
- *
994
- * @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
995
- * @param options Optional catalog ID, language, and content element for highlighting
996
- */
997
- async speak(text, options) {
998
- if (!this.provider) {
999
- throw new Error("TTS service not initialized");
1000
- }
1001
- const runId = ++this.speakRunId;
1002
- this.clearPlaybackStartBarrier();
1227
+ async speakContent(text, options, runId) {
1003
1228
  let playbackStartBarrier = null;
1004
1229
  try {
1005
1230
  await this.applyLanguageSettings(options);
@@ -1015,8 +1240,9 @@ export class TTSService {
1015
1240
  catalogId: options?.catalogId,
1016
1241
  });
1017
1242
  this.initializeSpeakTracking(highlightText, options);
1018
- const highlightMode = options?.highlightModeOverride ||
1019
- (speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
1243
+ const highlightMode = speechMatchesVisibleText
1244
+ ? this.resolveHighlightMode()
1245
+ : "sentence";
1020
1246
  this.activeHighlightMode = highlightMode;
1021
1247
  this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
1022
1248
  const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
@@ -1030,7 +1256,7 @@ export class TTSService {
1030
1256
  : hasExplicitBreaks || !speechMatchesVisibleText
1031
1257
  ? []
1032
1258
  : shouldUsePlan && this.currentContentElement
1033
- ? this.createSpeechPlan(this.currentContentElement, normalizedText)
1259
+ ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0, options?.language)
1034
1260
  : this.createSeekSegmentsFromText(highlightText);
1035
1261
  this.sentenceHighlightSegments = hasExplicitBreaks
1036
1262
  ? []
@@ -1039,7 +1265,6 @@ export class TTSService {
1039
1265
  shouldUsePlan,
1040
1266
  playbackSegments: this.seekSegments,
1041
1267
  });
1042
- this.setState(PlaybackState.LOADING);
1043
1268
  playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
1044
1269
  this.prepareHighlightsForSpeak({
1045
1270
  contentToSpeak: highlightText,
@@ -1057,9 +1282,8 @@ export class TTSService {
1057
1282
  else {
1058
1283
  this.clearWordBoundaryHighlighting();
1059
1284
  }
1060
- if (!playbackStartBarrier) {
1061
- this.setState(PlaybackState.PLAYING);
1062
- }
1285
+ if (!playbackStartBarrier)
1286
+ this.markPlaying();
1063
1287
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
1064
1288
  await this.executeSpeakPlayback({
1065
1289
  shouldUsePlan,
@@ -1074,146 +1298,68 @@ export class TTSService {
1074
1298
  this.clearHighlightsAndTracking();
1075
1299
  }
1076
1300
  catch (error) {
1077
- let finalError = error;
1078
- console.error("TTS error:", finalError);
1079
- this.lastError =
1080
- finalError instanceof Error ? finalError.message : String(finalError);
1301
+ // A superseded run's failure is the abort that superseded it.
1081
1302
  if (runId !== this.speakRunId)
1082
1303
  return;
1304
+ console.error("TTS error:", error);
1305
+ this.lastError = error instanceof Error ? error.message : String(error);
1083
1306
  this.setState(PlaybackState.ERROR);
1084
1307
  this.clearHighlightsAndTracking();
1085
- throw finalError;
1308
+ throw error;
1086
1309
  }
1087
1310
  finally {
1088
1311
  this.clearPlaybackStartBarrier(playbackStartBarrier);
1089
1312
  }
1090
1313
  }
1091
- async switchProviderToBrowser(reason, context) {
1092
- if (!this.currentProvider)
1093
- return false;
1094
- const fallbackProvider = new BrowserTTSProvider();
1095
- const previousProviderId = this.currentProvider.providerId;
1096
- // Voice identifiers and provider extensions belong to the provider that
1097
- // rejected initialization. Carry only settings whose meaning is portable
1098
- // when crossing the provider boundary; otherwise a Polly/Google/SC voice
1099
- // such as "Joanna" can incorrectly become an explicit Browser voice.
1100
- const browserConfig = {};
1101
- if (this.ttsConfig.rate !== undefined) {
1102
- browserConfig.rate = this.ttsConfig.rate;
1103
- }
1104
- if (this.ttsConfig.pitch !== undefined) {
1105
- browserConfig.pitch = this.ttsConfig.pitch;
1106
- }
1107
- if (this.ttsConfig.mathTokenHighlighting !== undefined) {
1108
- browserConfig.mathTokenHighlighting =
1109
- this.ttsConfig.mathTokenHighlighting;
1110
- }
1111
- const operation = context?.operation || "tts-initialize";
1112
- const offendingText = context?.contentToSpeak ?? "";
1113
- const offendingPreview = offendingText
1114
- .replace(/\s+/g, " ")
1115
- .trim()
1116
- .slice(0, 120);
1117
- const offendingDiagnostic = offendingText.length > 0
1118
- ? {
1119
- textLength: offendingText.length,
1120
- textPreview: offendingPreview.length < offendingText.length
1121
- ? `${offendingPreview}…`
1122
- : offendingPreview,
1123
- }
1124
- : {};
1125
- try {
1126
- this.provider?.stop();
1127
- this.currentProvider.destroy();
1128
- this.currentProvider = fallbackProvider;
1129
- this.provider = await fallbackProvider.initialize(browserConfig);
1130
- this.ttsConfig = browserConfig;
1131
- await this.emitTelemetry("pie-tool-runtime-fallback", {
1132
- toolId: "textToSpeech",
1133
- operation,
1134
- fromProvider: previousProviderId,
1135
- toProvider: fallbackProvider.providerId,
1136
- reason: reason instanceof Error ? reason.message : String(reason),
1137
- ...offendingDiagnostic,
1138
- });
1139
- console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
1140
- fromProvider: previousProviderId,
1141
- reason,
1142
- ...offendingDiagnostic,
1143
- });
1144
- return true;
1145
- }
1146
- catch (fallbackError) {
1147
- await this.emitTelemetry("pie-tool-runtime-fallback-error", {
1148
- toolId: "textToSpeech",
1149
- operation,
1150
- fromProvider: previousProviderId,
1151
- toProvider: "browser",
1152
- errorType: "TTSRuntimeFallbackError",
1153
- message: fallbackError instanceof Error
1154
- ? fallbackError.message
1155
- : String(fallbackError),
1156
- });
1157
- console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
1158
- return false;
1159
- }
1160
- }
1161
- isBrowserSpeechFallbackAvailable() {
1162
- if (typeof window === "undefined")
1163
- return false;
1164
- if (!("speechSynthesis" in window))
1165
- return false;
1166
- return (typeof globalThis
1167
- .SpeechSynthesisUtterance === "function");
1168
- }
1314
+ /**
1315
+ * Hand the read's language to the provider. A read in a named content language
1316
+ * sets the provider's locales and `contentLanguage` to it, so a browser voice
1317
+ * follows it; a read naming none restores the host's locales and clears
1318
+ * `contentLanguage`, so the browser voice follows the browser's language.
1319
+ */
1169
1320
  async applyLanguageSettings(options) {
1170
- if (!options?.language || !this.provider)
1321
+ if (!this.provider)
1322
+ return;
1323
+ this.readLocale = options.language;
1324
+ const named = options.contentLanguage;
1325
+ const providerOptions = this.providerOptions();
1326
+ const next = {
1327
+ locale: named ?? this.hostLocales.locale,
1328
+ textNormalization: named ?? this.hostLocales.textNormalization,
1329
+ segmenter: named ?? this.hostLocales.segmenter,
1330
+ };
1331
+ if (providerOptions.contentLanguage === named &&
1332
+ optionalString(providerOptions.locale) === next.locale &&
1333
+ optionalString(providerOptions.textNormalization?.locale) ===
1334
+ next.textNormalization &&
1335
+ optionalString(providerOptions.segmenter?.locale) === next.segmenter) {
1171
1336
  return;
1172
- const providerOptions = (this.ttsConfig.providerOptions || {});
1173
- const textNormalization = (providerOptions.textNormalization ||
1174
- {});
1175
- const segmenter = (providerOptions.segmenter || {});
1337
+ }
1338
+ // Every key is set, undefined included: providers merge options shallowly.
1176
1339
  const mergedProviderOptions = {
1177
1340
  ...providerOptions,
1178
- locale: options.language,
1341
+ locale: next.locale,
1179
1342
  textNormalization: {
1180
- ...textNormalization,
1181
- locale: options.language,
1182
- },
1183
- segmenter: {
1184
- ...segmenter,
1185
- locale: options.language,
1343
+ ...providerOptions.textNormalization,
1344
+ locale: next.textNormalization,
1186
1345
  },
1346
+ segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
1347
+ contentLanguage: named,
1187
1348
  };
1188
1349
  this.ttsConfig = {
1189
1350
  ...this.ttsConfig,
1190
1351
  providerOptions: mergedProviderOptions,
1191
1352
  };
1192
- if ("updateSettings" in this.provider &&
1193
- typeof this.provider.updateSettings ===
1194
- "function") {
1195
- await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
1196
- }
1353
+ await this.provider.updateSettings({
1354
+ providerOptions: mergedProviderOptions,
1355
+ });
1197
1356
  }
1198
1357
  async resolveSpeechContent(text, options) {
1199
1358
  const normalizedInputText = normalizeTextForSpeech(text);
1200
- if (options?.ignoreCatalogs) {
1201
- return {
1202
- contentToSpeak: normalizedInputText,
1203
- speechText: normalizedInputText,
1204
- visibleText: normalizedInputText,
1205
- highlightText: normalizedInputText,
1206
- usedCatalogSpoken: false,
1207
- speechSource: "dom-or-input",
1208
- normalizedText: normalizedInputText,
1209
- containsMathMarkup: false,
1210
- speechMatchesVisibleText: true,
1211
- };
1212
- }
1213
- if (options?.catalogId && this.catalogResolver) {
1359
+ if (options.catalogId && this.catalogResolver) {
1214
1360
  const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
1215
1361
  type: "spoken",
1216
- language: options.language || "en-US",
1362
+ language: options.language,
1217
1363
  useFallback: true,
1218
1364
  context: options.catalogContext,
1219
1365
  form: "content",
@@ -1222,11 +1368,9 @@ export class TTSService {
1222
1368
  // instance. Fall through to generated TTS rather than speaking "".
1223
1369
  const spokenText = catalogContent?.content;
1224
1370
  if (catalogContent && spokenText !== undefined) {
1225
- const visibleText = options?.contentElement
1226
- ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
1227
- : normalizedInputText;
1371
+ const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1228
1372
  const normalizedCatalogText = normalizeTextForSpeech(spokenText);
1229
- this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1373
+ logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1230
1374
  return {
1231
1375
  contentToSpeak: spokenText,
1232
1376
  speechText: spokenText,
@@ -1239,24 +1383,12 @@ export class TTSService {
1239
1383
  speechMatchesVisibleText: normalizedCatalogText === visibleText,
1240
1384
  };
1241
1385
  }
1242
- this.debugLog(`[TTSService] No catalog found for "${options.catalogId}", falling back to generated TTS`);
1386
+ logger.debug(`No catalog found for "${options.catalogId}", falling back to generated TTS`);
1243
1387
  }
1244
- const composed = options?.contentElement
1245
- ? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
1246
- : null;
1388
+ const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
1247
1389
  if (composed)
1248
1390
  return composed;
1249
- const generated = options?.contentElement
1250
- ? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
1251
- : {
1252
- contentToSpeak: normalizedInputText,
1253
- speechText: normalizedInputText,
1254
- visibleText: normalizedInputText,
1255
- highlightText: normalizedInputText,
1256
- normalizedText: normalizedInputText,
1257
- containsMathMarkup: false,
1258
- speechMatchesVisibleText: true,
1259
- };
1391
+ const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
1260
1392
  return {
1261
1393
  ...generated,
1262
1394
  usedCatalogSpoken: false,
@@ -1270,7 +1402,11 @@ export class TTSService {
1270
1402
  if (!chunks.some((chunk) => chunk.sourceElement))
1271
1403
  return null;
1272
1404
  const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
1273
- const visibleText = collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1405
+ // A selection's visible text is the text it holds, which the caller passes:
1406
+ // the highlight offsets index it from `wordBoundaryOffset`.
1407
+ const visibleText = options.contentRange
1408
+ ? normalizedInputText
1409
+ : collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1274
1410
  return {
1275
1411
  contentToSpeak: speechText,
1276
1412
  speechText,
@@ -1299,13 +1435,22 @@ export class TTSService {
1299
1435
  speechMatchesVisibleText: true,
1300
1436
  });
1301
1437
  };
1438
+ const range = options.contentRange;
1302
1439
  const resolveCatalog = (element) => {
1303
1440
  const catalogIdRef = element.getAttribute("data-catalog-idref");
1304
1441
  if (!catalogIdRef)
1305
1442
  return null;
1443
+ // A card reads its whole node, so it stands in for a selection's part of
1444
+ // the node only when the selection holds all of it. A part reads as the
1445
+ // visible text selected.
1446
+ if (range && !rangeHoldsSpeakableElement(range, element, root)) {
1447
+ return null;
1448
+ }
1306
1449
  const lookup = {
1307
1450
  type: "spoken",
1308
- language: options.language || "en-US",
1451
+ // The node's own language first: a `lang` inside the read content
1452
+ // names the language of the part it marks.
1453
+ language: findLangAttribute(element, root) || options.language,
1309
1454
  useFallback: true,
1310
1455
  context: options.catalogContext,
1311
1456
  };
@@ -1342,7 +1487,7 @@ export class TTSService {
1342
1487
  : null;
1343
1488
  };
1344
1489
  const getMathElementsForAlignment = (element) => {
1345
- const mathElements = Array.from(element.querySelectorAll("math"));
1490
+ const mathElements = flatQuerySelectorAll(element, "math");
1346
1491
  if (element.localName?.toLowerCase() === "math") {
1347
1492
  return [element, ...mathElements];
1348
1493
  }
@@ -1355,7 +1500,12 @@ export class TTSService {
1355
1500
  if (isNodeExcludedFromSpeech(node, root))
1356
1501
  return;
1357
1502
  if (node.nodeType === Node.TEXT_NODE) {
1358
- textBuffer += ` ${node.textContent || ""}`;
1503
+ if (!range) {
1504
+ textBuffer += ` ${node.textContent || ""}`;
1505
+ }
1506
+ else if (rangeIntersectsComposedNode(range, node)) {
1507
+ textBuffer += ` ${textInRange(node, range)}`;
1508
+ }
1359
1509
  return;
1360
1510
  }
1361
1511
  if (node.nodeType !== Node.ELEMENT_NODE)
@@ -1366,7 +1516,7 @@ export class TTSService {
1366
1516
  flushTextBuffer();
1367
1517
  const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1368
1518
  const visibleText = collectedVisible.visibleText ||
1369
- normalizeTextForSpeech(element.textContent || "");
1519
+ normalizeTextForSpeech(flatTextContent(element));
1370
1520
  const regionElement = resolveReadableRegion(element, root);
1371
1521
  // The script chunk, when there is a script. Built exactly as before,
1372
1522
  // and it doubles as the recording's fallback: word-level alignment is
@@ -1434,7 +1584,7 @@ export class TTSService {
1434
1584
  chunks.push(scriptChunk);
1435
1585
  return;
1436
1586
  }
1437
- for (const child of Array.from(element.childNodes)) {
1587
+ for (const child of flatTreeChildNodes(element)) {
1438
1588
  visit(child);
1439
1589
  }
1440
1590
  };
@@ -1456,12 +1606,13 @@ export class TTSService {
1456
1606
  const provider = this.currentProvider;
1457
1607
  if (!provider)
1458
1608
  return "plain";
1459
- // Belt-and-suspenders: the browser provider can never voice SSML.
1460
- if (provider.providerId?.toLowerCase() === "browser")
1461
- return "plain";
1462
1609
  return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
1463
1610
  }
1464
- async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language) {
1611
+ /**
1612
+ * With `range`, the selection only: an equation it holds whole reads as math
1613
+ * speech, and a selection without one reads the selected text, `normalizedInputText`.
1614
+ */
1615
+ async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language, range) {
1465
1616
  // Delegate to the generated-speech module: a pure plan assembly + SRE
1466
1617
  // memoization core, plus a DOM adapter that binds live-DOM anchors and
1467
1618
  // emits playback chunks. The aggregate `contentToSpeak` stays plain text
@@ -1469,14 +1620,19 @@ export class TTSService {
1469
1620
  const playbackFormat = this.resolveGeneratedPlaybackFormat();
1470
1621
  const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
1471
1622
  contentRoot: contentElement,
1623
+ range,
1472
1624
  language,
1473
1625
  mathSpeech: this.getMathSpeechOptions(),
1474
1626
  textProcessingOptions: this.getTextProcessingOptions(language),
1475
1627
  produceSsml: playbackFormat === "ssml",
1476
1628
  resolveMathSpeech: this.generatedMathSpeechResolver,
1477
1629
  });
1478
- const visibleText = collectedVisibleText || normalizedInputText;
1479
1630
  if (!containsMathMarkup) {
1631
+ // The input text is the selection's text in the root's visible-text map,
1632
+ // which its highlight offsets index.
1633
+ const visibleText = range
1634
+ ? normalizedInputText
1635
+ : collectedVisibleText || normalizedInputText;
1480
1636
  return {
1481
1637
  contentToSpeak: visibleText,
1482
1638
  speechText: visibleText,
@@ -1487,6 +1643,7 @@ export class TTSService {
1487
1643
  speechMatchesVisibleText: true,
1488
1644
  };
1489
1645
  }
1646
+ const visibleText = collectedVisibleText || normalizedInputText;
1490
1647
  const speechChunks = planToCompositionChunkInputs(plan, {
1491
1648
  format: playbackFormat,
1492
1649
  });
@@ -1507,7 +1664,7 @@ export class TTSService {
1507
1664
  .replace(/\s+/g, " ")
1508
1665
  .trim()
1509
1666
  .slice(0, 200);
1510
- this.debugLog("[TTSService] Speak resolved content", {
1667
+ logger.debug("Speak resolved content", {
1511
1668
  source: args.speechSource,
1512
1669
  catalogId: args.catalogId || null,
1513
1670
  length: args.contentToSpeak.length,
@@ -1516,17 +1673,16 @@ export class TTSService {
1516
1673
  }
1517
1674
  initializeSpeakTracking(contentToSpeak, options) {
1518
1675
  this.currentText = contentToSpeak;
1519
- this.currentContentElement = options?.contentElement || null;
1676
+ this.currentContentElement = options.contentElement;
1520
1677
  this.lastError = null;
1521
1678
  this.currentBoundaryOffset = 0;
1522
1679
  this.playbackChunks = [];
1523
- this.currentSeekSegmentIndex = 0;
1524
1680
  this.activeSentenceStartOffset = null;
1525
1681
  }
1526
1682
  prepareHighlightsForSpeak(args) {
1527
1683
  if (!this.currentContentElement || !this.highlightCoordinator)
1528
1684
  return;
1529
- this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options?.language);
1685
+ this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
1530
1686
  if (args.highlightMode === "sentence" && args.shouldUsePlan) {
1531
1687
  return;
1532
1688
  }
@@ -1534,7 +1690,7 @@ export class TTSService {
1534
1690
  this.runWhenPlaybackStarts(args.runId, () => {
1535
1691
  if (initialSegment) {
1536
1692
  this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
1537
- this.debugLog("[TTSService] Applied initial sentence highlighting");
1693
+ logger.debug("Applied initial sentence highlighting");
1538
1694
  }
1539
1695
  else {
1540
1696
  try {
@@ -1549,33 +1705,29 @@ export class TTSService {
1549
1705
  });
1550
1706
  }
1551
1707
  configureWordBoundaryHighlighting(args) {
1552
- if (!this.provider ||
1553
- args.highlightMode !== "word" ||
1554
- !this.highlightCoordinator ||
1555
- !this.currentContentElement) {
1708
+ if (!this.provider)
1709
+ return;
1710
+ // A sentence-mode read paints no words, so it takes no word boundaries.
1711
+ if (args.highlightMode === "sentence") {
1712
+ this.provider.onWordBoundary = undefined;
1556
1713
  return;
1557
1714
  }
1558
- // Word index uses the same globalIndex as highlightTTSWord (browser boundaries
1559
- // or ServerTTSProvider time-scaled onWordBoundary).
1715
+ if (!this.highlightCoordinator || !this.currentContentElement)
1716
+ return;
1717
+ // Browser boundaries and the server provider's time-scaled boundaries index
1718
+ // the same spoken text.
1560
1719
  this.provider.onWordBoundary = (word, charIndex, length) => {
1561
1720
  const wordLength = length || word.length;
1562
- const globalIndex = charIndex + this.currentBoundaryOffset + args.wordBoundaryOffset;
1563
- this.highlightSentenceForOffset(globalIndex);
1564
- const highlightRange = this.findHighlightRange(globalIndex, wordLength);
1565
- if (highlightRange && this.highlightCoordinator) {
1566
- const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
1567
- this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1568
- const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
1569
- if (nativeRange) {
1570
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1571
- }
1572
- else {
1573
- this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1574
- }
1575
- }
1576
- else {
1577
- console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
1721
+ const spokenIndex = charIndex + this.currentBoundaryOffset;
1722
+ const globalIndex = spokenIndex + args.wordBoundaryOffset;
1723
+ this.highlightSentenceForOffset(spokenIndex);
1724
+ const ranges = createRangesFromVisibleMap(this.normalizedToDOM, globalIndex, globalIndex + wordLength);
1725
+ if (ranges.length === 0) {
1726
+ logger.debug(`no text at position ${globalIndex}, length ${wordLength}, to highlight`);
1727
+ return;
1578
1728
  }
1729
+ logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
1730
+ this.paintTTSWord(ranges);
1579
1731
  };
1580
1732
  }
1581
1733
  clearWordBoundaryHighlighting() {
@@ -1590,26 +1742,22 @@ export class TTSService {
1590
1742
  try {
1591
1743
  this.lastRenderedRegionTarget = null;
1592
1744
  if (chunk.regionRange) {
1593
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1594
- this.paintTTSSentenceRanges([chunk.regionRange]);
1745
+ const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
1746
+ this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1747
+ this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
1595
1748
  return;
1596
1749
  }
1597
1750
  if (!element)
1598
1751
  return;
1599
1752
  const range = document.createRange();
1600
1753
  range.selectNodeContents(element);
1601
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1754
+ this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1602
1755
  this.paintTTSSentenceRanges([range]);
1603
1756
  }
1604
1757
  catch {
1605
1758
  // Continue playback even when a detached node cannot be highlighted.
1606
1759
  }
1607
1760
  }
1608
- highlightCatalogActiveRange(range) {
1609
- if (!this.highlightCoordinator)
1610
- return;
1611
- this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
1612
- }
1613
1761
  highlightRenderableRegionTarget(target) {
1614
1762
  if (!this.highlightCoordinator ||
1615
1763
  !target ||
@@ -1626,7 +1774,7 @@ export class TTSService {
1626
1774
  range.selectNodeContents(target.element);
1627
1775
  }
1628
1776
  else if (target.type === "range") {
1629
- this.paintTTSSentenceRanges([target.range]);
1777
+ this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
1630
1778
  this.lastRenderedRegionTarget = target;
1631
1779
  return;
1632
1780
  }
@@ -1656,23 +1804,19 @@ export class TTSService {
1656
1804
  }
1657
1805
  if (target.type === "text-range") {
1658
1806
  const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
1659
- if (nativeRange) {
1660
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1661
- }
1662
- else {
1663
- this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1664
- }
1807
+ if (nativeRange)
1808
+ this.paintTTSWord([nativeRange]);
1665
1809
  return;
1666
1810
  }
1667
1811
  if (target.type === "range") {
1668
- this.highlightCatalogActiveRange(target.range);
1812
+ this.paintTTSWord(target.ranges ?? [target.range]);
1669
1813
  return;
1670
1814
  }
1671
1815
  const elementNativeRange = document.createRange();
1672
1816
  elementNativeRange.selectNodeContents(target.element);
1673
1817
  const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
1674
1818
  if (resolvedElementRange.remapped) {
1675
- this.paintTTSWordRange(resolvedElementRange.range);
1819
+ this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
1676
1820
  return;
1677
1821
  }
1678
1822
  // Element targets are atomic: a resolved math token, a whole-expression
@@ -1687,10 +1831,12 @@ export class TTSService {
1687
1831
  const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
1688
1832
  if (target.quality === "semantic-token" &&
1689
1833
  onlyChild?.nodeType === Node.TEXT_NODE) {
1690
- this.highlightCoordinator.highlightTTSWord(onlyChild, 0, onlyChild.textContent?.length || 0);
1834
+ const tokenRange = document.createRange();
1835
+ tokenRange.selectNodeContents(onlyChild);
1836
+ this.highlightCoordinator.highlightTTSWord([tokenRange]);
1691
1837
  return;
1692
1838
  }
1693
- this.highlightCoordinator.highlightTTSWordElement?.(target.element);
1839
+ this.highlightCoordinator.highlightTTSWordElement(target.element);
1694
1840
  }
1695
1841
  renderHighlightDecision(decision) {
1696
1842
  this.highlightRenderableRegionTarget(decision.regionTarget);
@@ -1706,7 +1852,9 @@ export class TTSService {
1706
1852
  // anchors, plain alignment). The fallback chunk carries no further
1707
1853
  // fallback, so this cannot recurse.
1708
1854
  if (chunk.plainFallback && runId === this.speakRunId) {
1709
- this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
1855
+ logger.debug("SSML chunk speak failed; retrying plain text", {
1856
+ message: error instanceof Error ? error.message : String(error),
1857
+ });
1710
1858
  // A recorded-first run can own the start barrier even when its fallback
1711
1859
  // provider has no formal start signal. Drop the failed recording's queued
1712
1860
  // highlight and resume that non-start-aware provider's immediate-start lifecycle.
@@ -1715,7 +1863,7 @@ export class TTSService {
1715
1863
  !("onPlaybackStart" in this.provider)) {
1716
1864
  this.pendingPlaybackStartHighlights = [];
1717
1865
  this.clearPlaybackStartBarrier();
1718
- this.setState(PlaybackState.PLAYING);
1866
+ this.markPlaying();
1719
1867
  }
1720
1868
  await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
1721
1869
  return;
@@ -1775,6 +1923,18 @@ export class TTSService {
1775
1923
  cleanup();
1776
1924
  reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1777
1925
  };
1926
+ // A pause before the clip starts rejects its play with an AbortError;
1927
+ // resume plays it, so that rejection is no failure of the clip.
1928
+ const play = () => {
1929
+ Promise.resolve(element.play())
1930
+ .then(markStarted)
1931
+ .catch((error) => {
1932
+ const aborted = error?.name === "AbortError";
1933
+ if (aborted && this.state === PlaybackState.PAUSED)
1934
+ return;
1935
+ onError();
1936
+ });
1937
+ };
1778
1938
  // Cancellation has to settle this promise, not just stop the element:
1779
1939
  // `stop()` bumps the run id, and a pending play that never resolves would
1780
1940
  // wedge the chunk loop on a run nobody is listening to any more.
@@ -1788,6 +1948,7 @@ export class TTSService {
1788
1948
  element.pause();
1789
1949
  resolve();
1790
1950
  },
1951
+ play,
1791
1952
  };
1792
1953
  element.addEventListener("ended", onEnded);
1793
1954
  element.addEventListener("error", onError);
@@ -1795,7 +1956,7 @@ export class TTSService {
1795
1956
  // Reaching the slice's end is this clip finishing, so the chunk sequence
1796
1957
  // advances rather than the element merely pausing.
1797
1958
  disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
1798
- Promise.resolve(element.play()).then(markStarted).catch(onError);
1959
+ play();
1799
1960
  }).finally(() => {
1800
1961
  if (this.activeRecordedAudio?.element === element) {
1801
1962
  this.activeRecordedAudio = null;
@@ -1848,7 +2009,9 @@ export class TTSService {
1848
2009
  this.highlightCatalogRegion(chunk);
1849
2010
  }
1850
2011
  });
1851
- const canUseChunkBoundaries = chunk.sourceElement &&
2012
+ // A sentence-mode read takes no word boundaries, aligned chunk or not.
2013
+ const canUseChunkBoundaries = this.resolveHighlightMode() === "word" &&
2014
+ chunk.sourceElement &&
1852
2015
  ((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
1853
2016
  (chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
1854
2017
  (chunk.alignment &&
@@ -1860,7 +2023,7 @@ export class TTSService {
1860
2023
  const previousOnWordBoundary = provider.onWordBoundary;
1861
2024
  provider.onWordBoundary = undefined;
1862
2025
  try {
1863
- await provider.speak(chunk.speechText);
2026
+ await this.speakWithinLimit(chunk.speechText, runId);
1864
2027
  }
1865
2028
  finally {
1866
2029
  if (this.provider === provider &&
@@ -1892,7 +2055,7 @@ export class TTSService {
1892
2055
  };
1893
2056
  provider.onWordBoundary = installedHandler;
1894
2057
  try {
1895
- await provider.speak(chunk.speechText);
2058
+ await this.speakWithinLimit(chunk.speechText, runId);
1896
2059
  }
1897
2060
  finally {
1898
2061
  if (this.provider === provider &&
@@ -1905,9 +2068,9 @@ export class TTSService {
1905
2068
  async executeSpeakPlayback(args) {
1906
2069
  if (args.speechChunks?.length && this.provider) {
1907
2070
  for (let index = 0; index < args.speechChunks.length; index++) {
2071
+ await this.waitWhilePaused(args.runId);
1908
2072
  if (args.runId !== this.speakRunId)
1909
2073
  return;
1910
- this.currentSeekSegmentIndex = index;
1911
2074
  const segment = this.seekSegments[index];
1912
2075
  if (segment) {
1913
2076
  this.currentBoundaryOffset = segment.startOffset;
@@ -1924,16 +2087,16 @@ export class TTSService {
1924
2087
  highlightMode: args.highlightMode,
1925
2088
  });
1926
2089
  }
1927
- else if (this.provider) {
1928
- await this.provider.speak(args.contentToSpeak);
2090
+ else {
2091
+ await this.speakWithinLimit(args.contentToSpeak, args.runId);
1929
2092
  }
1930
2093
  return;
1931
2094
  }
1932
- if (this.provider) {
1933
- await this.provider.speak(args.contentToSpeak);
1934
- }
2095
+ await this.speakWithinLimit(args.contentToSpeak, args.runId);
1935
2096
  }
1936
2097
  clearHighlightsAndTracking() {
2098
+ // A finished run leaves no handler for the next read to inherit.
2099
+ this.clearWordBoundaryHighlighting();
1937
2100
  this.pendingPlaybackStartHighlights = [];
1938
2101
  this.activePlaybackRate = null;
1939
2102
  if (this.highlightCoordinator) {
@@ -1947,99 +2110,63 @@ export class TTSService {
1947
2110
  this.seekSegments = [];
1948
2111
  this.playbackChunks = [];
1949
2112
  this.sentenceHighlightSegments = [];
1950
- this.currentSeekSegmentIndex = 0;
1951
2113
  this.activeSentenceStartOffset = null;
1952
2114
  }
1953
2115
  /**
1954
- * Speak a text range with accurate word highlighting
1955
- *
1956
- * This calculates the offset of the range within its parent element
1957
- * to ensure word highlighting aligns correctly with the selected text.
1958
- *
1959
- * @param range DOM Range to speak
2116
+ * The selected part of `root`'s normalized visible text and where it starts, or
2117
+ * null when the map is unavailable or holds no selected character.
1960
2118
  */
1961
- async speakRange(range, options) {
1962
- if (!this.provider) {
1963
- throw new Error("TTS service not initialized");
1964
- }
1965
- // Enforced from the live ancestors rather than from `root`, because the
1966
- // element carrying the suppression may sit above whatever root the caller
1967
- // passed or the range happens to resolve to.
1968
- if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
1969
- 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.");
1970
- return;
1971
- }
1972
- // Use explicit content root when provided; otherwise keep highlighting scoped
1973
- // to the selected range's nearest element ancestor.
1974
- const fromOptions = options?.contentRoot || null;
1975
- let root = fromOptions;
1976
- if (!root) {
1977
- const ancestor = range.commonAncestorContainer;
1978
- root =
1979
- ancestor.nodeType === Node.ELEMENT_NODE
1980
- ? ancestor
1981
- : ancestor.parentElement;
1982
- }
1983
- if (!root)
1984
- return;
1985
- const selected = collectRangeTextForSpeech(range, root);
1986
- const text = selected.text.trim();
1987
- if (!text) {
1988
- if (selected.filtered) {
1989
- console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1990
- }
1991
- return;
2119
+ selectMappedRangeText(range, root, language) {
2120
+ if (typeof range.comparePoint !== "function")
2121
+ return null;
2122
+ const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
2123
+ let start = -1;
2124
+ let end = -1;
2125
+ for (const [index, { node, offset }] of map) {
2126
+ if (index >= rootText.length)
2127
+ continue;
2128
+ if (!rangeHoldsTextPosition(range, node, offset))
2129
+ continue;
2130
+ if (start === -1 || index < start)
2131
+ start = index;
2132
+ if (index > end)
2133
+ end = index;
1992
2134
  }
1993
- // Calculate the offset of the range start within the root element. Filtered
1994
- // the same way as the speech itself: the offset indexes into the highlight
1995
- // text, which comes from the exclusion-aware collectors, so counting
1996
- // characters here that never reach that text would shift every highlight
1997
- // after the excluded node.
1998
- const beforeRange = document.createRange();
1999
- beforeRange.selectNodeContents(root);
2000
- beforeRange.setEnd(range.startContainer, range.startOffset);
2001
- const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
2002
- const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
2003
- const offset = normalizedTextBeforeRange.length +
2004
- (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
2005
- this.debugLog("[TTSService] speakRange offset calculation:", {
2006
- selectedText: text,
2007
- textBeforeRange: textBeforeRange.substring(0, 100),
2008
- offset,
2009
- rootTag: root.tagName,
2010
- });
2011
- // Speak the text with the root element as context
2012
- await this.speak(text, {
2013
- contentElement: root,
2014
- ignoreCatalogs: true,
2015
- wordBoundaryOffset: offset,
2016
- });
2135
+ if (start === -1)
2136
+ return null;
2137
+ const raw = rootText.slice(start, end + 1);
2138
+ const leading = raw.length - raw.trimStart().length;
2139
+ const text = raw.trim();
2140
+ return text ? { text, offset: start + leading } : null;
2017
2141
  }
2018
2142
  /**
2019
- * Pause playback
2143
+ * Pause playback. A pause while the read is loading holds it: its audio does
2144
+ * not start until {@link resume}.
2020
2145
  */
2021
2146
  pause() {
2022
- if (!this.provider)
2147
+ if (this.state !== PlaybackState.PLAYING &&
2148
+ this.state !== PlaybackState.LOADING) {
2023
2149
  return;
2024
- if (this.state === PlaybackState.PLAYING) {
2025
- this.activeRecordedAudio?.element.pause();
2026
- this.provider.pause();
2027
- this.setState(PlaybackState.PAUSED);
2028
2150
  }
2151
+ this.resumeState = this.state;
2152
+ this.activeRecordedAudio?.element.pause();
2153
+ this.provider?.pause();
2154
+ // Between two parts, nothing is playing to pause: the run holds before
2155
+ // its next part instead.
2156
+ this.setState(PlaybackState.PAUSED);
2029
2157
  }
2030
2158
  /**
2031
- * Resume playback
2159
+ * Resume playback, back to loading when the pause came before the audio
2160
+ * started.
2032
2161
  */
2033
2162
  resume() {
2034
- if (!this.provider)
2163
+ if (this.state !== PlaybackState.PAUSED)
2035
2164
  return;
2036
- if (this.state === PlaybackState.PAUSED) {
2037
- const recorded = this.activeRecordedAudio?.element;
2038
- if (recorded)
2039
- void Promise.resolve(recorded.play()).catch(() => { });
2040
- this.provider.resume();
2041
- this.setState(PlaybackState.PLAYING);
2042
- }
2165
+ // State first: audio that starts during resume reports into it.
2166
+ this.setState(this.resumeState);
2167
+ this.activeRecordedAudio?.play();
2168
+ this.provider?.resume();
2169
+ this.releasePauseHold();
2043
2170
  }
2044
2171
  normalizePlaybackRate(rate) {
2045
2172
  if (!Number.isFinite(rate))
@@ -2053,19 +2180,27 @@ export class TTSService {
2053
2180
  const restartChunks = this.playbackChunks.length
2054
2181
  ? this.playbackChunks.slice(safeTargetIndex)
2055
2182
  : null;
2183
+ // A seek under a pause moves the cursor and stays paused: the restarted
2184
+ // run holds before its first part until resume.
2185
+ const holdPaused = this.state === PlaybackState.PAUSED;
2056
2186
  this.speakRunId += 1;
2057
- this.clearPlaybackStartBarrier();
2058
- this.cancelRecordedAudio();
2187
+ this.abandonRun();
2059
2188
  this.provider.onWordBoundary = undefined;
2060
2189
  this.provider.stop();
2061
- this.currentSeekSegmentIndex = safeTargetIndex;
2062
2190
  const runId = ++this.speakRunId;
2063
2191
  const restartSegments = this.seekSegments.slice(safeTargetIndex);
2064
2192
  this.highlightCoordinator?.clearTTS();
2065
2193
  this.activeSentenceStartOffset = null;
2066
- this.setState(PlaybackState.LOADING);
2194
+ this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
2195
+ if (!holdPaused)
2196
+ this.setState(PlaybackState.LOADING);
2067
2197
  const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
2068
- if (!playbackStartBarrier) {
2198
+ if (holdPaused) {
2199
+ this.resumeState = playbackStartBarrier
2200
+ ? PlaybackState.LOADING
2201
+ : PlaybackState.PLAYING;
2202
+ }
2203
+ else if (!playbackStartBarrier) {
2069
2204
  this.setState(PlaybackState.PLAYING);
2070
2205
  }
2071
2206
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
@@ -2076,10 +2211,10 @@ export class TTSService {
2076
2211
  });
2077
2212
  if (restartChunks) {
2078
2213
  for (let index = 0; index < restartChunks.length; index++) {
2214
+ await this.waitWhilePaused(runId);
2079
2215
  if (runId !== this.speakRunId)
2080
2216
  return;
2081
2217
  const absoluteIndex = safeTargetIndex + index;
2082
- this.currentSeekSegmentIndex = absoluteIndex;
2083
2218
  const segment = this.seekSegments[absoluteIndex];
2084
2219
  if (segment) {
2085
2220
  this.currentBoundaryOffset = segment.startOffset;
@@ -2200,13 +2335,12 @@ export class TTSService {
2200
2335
  * Stop playback
2201
2336
  */
2202
2337
  stop() {
2203
- if (!this.provider)
2204
- return;
2205
2338
  this.speakRunId += 1;
2206
- this.clearPlaybackStartBarrier();
2207
- this.cancelRecordedAudio();
2208
- this.provider.onWordBoundary = undefined;
2209
- this.provider.stop();
2339
+ this.abandonRun();
2340
+ if (this.provider) {
2341
+ this.provider.onWordBoundary = undefined;
2342
+ this.provider.stop();
2343
+ }
2210
2344
  this.setState(PlaybackState.IDLE);
2211
2345
  this.currentText = null;
2212
2346
  // Clear highlights
@@ -2222,10 +2356,39 @@ export class TTSService {
2222
2356
  this.seekSegments = [];
2223
2357
  this.playbackChunks = [];
2224
2358
  this.sentenceHighlightSegments = [];
2225
- this.currentSeekSegmentIndex = 0;
2226
2359
  this.activeSentenceStartOffset = null;
2227
2360
  this.activePlaybackRate = null;
2228
2361
  }
2362
+ /**
2363
+ * Stop playback and drop the provider, whose owner destroys it. A later speak
2364
+ * waits on the readiness gate, as before the first {@link initialize}.
2365
+ */
2366
+ releaseProvider() {
2367
+ this.stop();
2368
+ this.provider = null;
2369
+ this.currentProvider = null;
2370
+ }
2371
+ /**
2372
+ * Stop, release the provider and drop every listener, hook and pending timer.
2373
+ * A disposed service cannot speak or be initialized again.
2374
+ */
2375
+ dispose() {
2376
+ if (this.disposed)
2377
+ return;
2378
+ this.releaseProvider();
2379
+ this.disposed = true;
2380
+ this.speakRunId += 1;
2381
+ this.abandonRun();
2382
+ this.playbackRateRequestId += 1;
2383
+ this.pendingPlaybackRateRequest = null;
2384
+ this.listeners.clear();
2385
+ this.readinessGate = null;
2386
+ this.mathSpeechSource = null;
2387
+ this.highlightTargetResolverProvider = null;
2388
+ this.highlightCoordinator = null;
2389
+ this.catalogResolver = null;
2390
+ this.telemetryReporter = null;
2391
+ }
2229
2392
  /**
2230
2393
  * Request UI-level TTS controls to hand off/deactivate.
2231
2394
  *
@@ -2272,28 +2435,15 @@ export class TTSService {
2272
2435
  return this.lastError;
2273
2436
  }
2274
2437
  /**
2275
- * Subscribe to state changes
2276
- *
2277
- * @param id Unique listener ID
2278
- * @param callback Function to call on state change
2438
+ * Subscribe to playback state changes. The returned function unsubscribes;
2439
+ * each subscription is its own, so the same callback can be subscribed twice.
2279
2440
  */
2280
- onStateChange(id, callback) {
2281
- if (!this.listeners.has(id)) {
2282
- this.listeners.set(id, new Set());
2283
- }
2284
- this.listeners.get(id).add(callback);
2285
- }
2286
- /**
2287
- * Unsubscribe from state changes
2288
- */
2289
- offStateChange(id, callback) {
2290
- const callbacks = this.listeners.get(id);
2291
- if (callbacks) {
2292
- callbacks.delete(callback);
2293
- if (callbacks.size === 0) {
2294
- this.listeners.delete(id);
2295
- }
2296
- }
2441
+ onStateChange(callback) {
2442
+ const listener = (state) => callback(state);
2443
+ this.listeners.add(listener);
2444
+ return () => {
2445
+ this.listeners.delete(listener);
2446
+ };
2297
2447
  }
2298
2448
  /**
2299
2449
  * Set state and notify listeners
@@ -2304,14 +2454,11 @@ export class TTSService {
2304
2454
  const previousState = this.state;
2305
2455
  this.state = newState;
2306
2456
  // Notify all listeners
2307
- for (const callbacks of this.listeners.values()) {
2308
- for (const callback of callbacks) {
2309
- callback(newState);
2310
- }
2457
+ for (const listener of [...this.listeners]) {
2458
+ listener(newState);
2311
2459
  }
2312
2460
  void this.emitTelemetry("pie-tool-playback-state-changed", {
2313
2461
  toolId: "textToSpeech",
2314
- providerId: "tts",
2315
2462
  previousState,
2316
2463
  state: newState,
2317
2464
  });
@@ -2321,7 +2468,6 @@ export class TTSService {
2321
2468
  : "pie-tool-playback-start";
2322
2469
  void this.emitTelemetry(eventName, {
2323
2470
  toolId: "textToSpeech",
2324
- providerId: "tts",
2325
2471
  previousState,
2326
2472
  state: newState,
2327
2473
  });
@@ -2330,7 +2476,6 @@ export class TTSService {
2330
2476
  if (newState === PlaybackState.PAUSED) {
2331
2477
  void this.emitTelemetry("pie-tool-playback-pause", {
2332
2478
  toolId: "textToSpeech",
2333
- providerId: "tts",
2334
2479
  previousState,
2335
2480
  state: newState,
2336
2481
  });
@@ -2339,7 +2484,6 @@ export class TTSService {
2339
2484
  if (newState === PlaybackState.ERROR) {
2340
2485
  void this.emitTelemetry("pie-tool-playback-error", {
2341
2486
  toolId: "textToSpeech",
2342
- providerId: "tts",
2343
2487
  previousState,
2344
2488
  state: newState,
2345
2489
  message: this.lastError || undefined,
@@ -2352,7 +2496,6 @@ export class TTSService {
2352
2496
  previousState === PlaybackState.LOADING)) {
2353
2497
  void this.emitTelemetry("pie-tool-playback-stop", {
2354
2498
  toolId: "textToSpeech",
2355
- providerId: "tts",
2356
2499
  previousState,
2357
2500
  state: newState,
2358
2501
  });