@pie-players/pie-assessment-toolkit 0.3.74 → 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 (228) hide show
  1. package/README.md +349 -313
  2. package/dist/attempt/TestSession.d.ts +1 -20
  3. package/dist/attempt/TestSession.js +8 -99
  4. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  5. package/dist/components/ItemScope.custom-element.js +1 -0
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  10. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-97hx60z4.js} +1 -1
  11. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  13. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  14. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  15. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  16. package/dist/components/item-scope-element.d.ts +2 -0
  17. package/dist/components/item-scope-element.js +2 -0
  18. package/dist/context/assessment-toolkit-context.d.ts +27 -6
  19. package/dist/index.d.ts +22 -47
  20. package/dist/index.js +12 -36
  21. package/dist/policy/core/PolicySource.d.ts +1 -1
  22. package/dist/policy/core/ToolPolicyEngine.d.ts +77 -32
  23. package/dist/policy/core/ToolPolicyEngine.js +136 -46
  24. package/dist/policy/core/compose-decision.d.ts +28 -7
  25. package/dist/policy/core/compose-decision.js +75 -54
  26. package/dist/policy/core/decision-types.d.ts +65 -34
  27. package/dist/policy/core/feature-decision.d.ts +16 -9
  28. package/dist/policy/core/feature-decision.js +14 -8
  29. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  30. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  31. package/dist/policy/core/provenance.d.ts +9 -4
  32. package/dist/policy/core/provenance.js +1 -1
  33. package/dist/policy/engine.d.ts +7 -23
  34. package/dist/policy/engine.js +5 -20
  35. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  36. package/dist/policy/sources/PnpPolicySource.js +117 -118
  37. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  38. package/dist/runtime/SectionControllerBinding.js +450 -0
  39. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  40. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  41. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  42. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  43. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  44. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  45. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  46. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  47. package/dist/runtime/catalog-context.d.ts +19 -0
  48. package/dist/runtime/catalog-context.js +28 -0
  49. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  50. package/dist/runtime/composition-emit-scheduler.js +9 -15
  51. package/dist/runtime/content-language.d.ts +32 -0
  52. package/dist/runtime/content-language.js +46 -0
  53. package/dist/runtime/content-region.d.ts +17 -0
  54. package/dist/runtime/content-region.js +29 -0
  55. package/dist/runtime/content-registration.d.ts +13 -0
  56. package/dist/runtime/content-registration.js +25 -0
  57. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  58. package/dist/runtime/core/SectionEngineCore.js +2 -2
  59. package/dist/runtime/core/cohort.d.ts +1 -1
  60. package/dist/runtime/core/cohort.js +1 -1
  61. package/dist/runtime/core/engine-input.d.ts +18 -30
  62. package/dist/runtime/core/engine-input.js +11 -12
  63. package/dist/runtime/core/engine-output.d.ts +16 -40
  64. package/dist/runtime/core/engine-output.js +9 -21
  65. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  66. package/dist/runtime/core/engine-readiness.js +8 -41
  67. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  68. package/dist/runtime/core/engine-resolver.js +18 -43
  69. package/dist/runtime/core/engine-state.d.ts +28 -36
  70. package/dist/runtime/core/engine-state.js +18 -21
  71. package/dist/runtime/core/engine-transition.d.ts +14 -19
  72. package/dist/runtime/core/engine-transition.js +71 -51
  73. package/dist/runtime/engine.d.ts +16 -19
  74. package/dist/runtime/engine.js +12 -19
  75. package/dist/runtime/page-warnings.d.ts +13 -0
  76. package/dist/runtime/page-warnings.js +22 -0
  77. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  78. package/dist/runtime/policy-input-forwarding.js +25 -0
  79. package/dist/runtime/registration-events.d.ts +11 -0
  80. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  81. package/dist/runtime/runtime-event-claim.js +15 -0
  82. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  83. package/dist/runtime/shell-event-bridge.js +127 -0
  84. package/dist/runtime/shell-registration.d.ts +73 -0
  85. package/dist/runtime/shell-registration.js +95 -0
  86. package/dist/runtime/shell-scope.d.ts +68 -0
  87. package/dist/runtime/shell-scope.js +188 -0
  88. package/dist/runtime/tool-host-contract.d.ts +8 -10
  89. package/dist/runtime/tool-host-contract.js +11 -11
  90. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  91. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  92. package/dist/services/ElementToolStateStore.d.ts +14 -10
  93. package/dist/services/ElementToolStateStore.js +37 -13
  94. package/dist/services/HighlightCoordinator.d.ts +21 -21
  95. package/dist/services/HighlightCoordinator.js +431 -325
  96. package/dist/services/RangeSerializer.d.ts +10 -1
  97. package/dist/services/RangeSerializer.js +87 -14
  98. package/dist/services/TTSService.d.ts +117 -70
  99. package/dist/services/TTSService.js +668 -578
  100. package/dist/services/ToolCoordinator.d.ts +43 -60
  101. package/dist/services/ToolCoordinator.js +164 -206
  102. package/dist/services/ToolRegistry.d.ts +74 -57
  103. package/dist/services/ToolRegistry.js +104 -114
  104. package/dist/services/ToolkitCoordinator.d.ts +183 -189
  105. package/dist/services/ToolkitCoordinator.js +477 -431
  106. package/dist/services/audio-handoff.d.ts +6 -7
  107. package/dist/services/audio-handoff.js +20 -14
  108. package/dist/services/catalog-owner.d.ts +0 -1
  109. package/dist/services/framework-error-bus.d.ts +3 -6
  110. package/dist/services/framework-error-bus.js +3 -6
  111. package/dist/services/framework-error.d.ts +22 -7
  112. package/dist/services/framework-error.js +9 -7
  113. package/dist/services/interfaces.d.ts +158 -129
  114. package/dist/services/section-session-assignment.d.ts +21 -0
  115. package/dist/services/section-session-assignment.js +82 -0
  116. package/dist/services/spoken-audio-cards.d.ts +0 -2
  117. package/dist/services/spoken-audio-cards.js +0 -2
  118. package/dist/services/tool-config-defaults.d.ts +3 -3
  119. package/dist/services/tool-config-defaults.js +3 -3
  120. package/dist/services/tool-config-validation.d.ts +4 -5
  121. package/dist/services/tool-config-validation.js +36 -61
  122. package/dist/services/tool-context.d.ts +0 -8
  123. package/dist/services/tool-context.js +0 -12
  124. package/dist/services/tool-icons.d.ts +4 -3
  125. package/dist/services/tool-icons.js +4 -3
  126. package/dist/services/tool-instance-id.d.ts +0 -1
  127. package/dist/services/tool-instance-id.js +0 -6
  128. package/dist/services/tool-providers/TTSToolProvider.d.ts +18 -19
  129. package/dist/services/tool-providers/TTSToolProvider.js +37 -34
  130. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  131. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +30 -26
  132. package/dist/services/tool-providers/ToolProviderRegistry.js +119 -93
  133. package/dist/services/tool-request.d.ts +27 -12
  134. package/dist/services/tool-request.js +24 -14
  135. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  136. package/dist/services/toolbar-decided-tools.js +28 -0
  137. package/dist/services/tools-config-normalizer.d.ts +17 -22
  138. package/dist/services/tools-config-normalizer.js +2 -10
  139. package/dist/services/tts/browser-provider.d.ts +38 -2
  140. package/dist/services/tts/browser-provider.js +276 -140
  141. package/dist/services/tts/flat-tree.d.ts +71 -0
  142. package/dist/services/tts/flat-tree.js +269 -0
  143. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  144. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  145. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  146. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  147. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  148. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  149. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +12 -4
  150. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  151. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  152. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  153. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  154. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  155. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  156. package/dist/services/tts/math-aware-text-processing.js +32 -18
  157. package/dist/services/tts/math-control-names.d.ts +21 -0
  158. package/dist/services/tts/math-control-names.js +142 -0
  159. package/dist/services/tts/math-speech.d.ts +11 -0
  160. package/dist/services/tts/math-speech.js +51 -18
  161. package/dist/services/tts/provider-options.d.ts +41 -0
  162. package/dist/services/tts/provider-options.js +1 -0
  163. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  164. package/dist/services/tts/ssml/spoken-text.js +29 -0
  165. package/dist/services/tts/start-failure.d.ts +11 -0
  166. package/dist/services/tts/start-failure.js +19 -0
  167. package/dist/services/tts/text-processing.d.ts +9 -0
  168. package/dist/services/tts/text-processing.js +46 -33
  169. package/dist/services/tts/text-segmentation.d.ts +7 -0
  170. package/dist/services/tts/text-segmentation.js +47 -0
  171. package/dist/services/tts-runtime-config.d.ts +25 -17
  172. package/dist/services/tts-runtime-config.js +16 -95
  173. package/dist/tools/content-capability-resolution.d.ts +7 -9
  174. package/dist/tools/content-capability-resolution.js +8 -45
  175. package/dist/tools/registration.d.ts +58 -0
  176. package/dist/tools/registration.js +54 -0
  177. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  178. package/dist/tools/tool-surface-host.d.ts +6 -10
  179. package/dist/tools/tool-surface-host.js +44 -42
  180. package/dist/tools/tool-tag-map.d.ts +5 -15
  181. package/dist/tools/tool-tag-map.js +6 -20
  182. package/dist/utils/structural-equality.d.ts +11 -0
  183. package/dist/utils/structural-equality.js +47 -0
  184. package/package.json +13 -29
  185. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  186. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  187. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  188. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  189. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  190. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  191. package/dist/context/runtime-context-consumer.d.ts +0 -13
  192. package/dist/context/runtime-context-consumer.js +0 -18
  193. package/dist/item-loader.d.ts +0 -44
  194. package/dist/item-loader.js +0 -61
  195. package/dist/policy/core/engine-context.d.ts +0 -29
  196. package/dist/policy/core/engine-context.js +0 -27
  197. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  198. package/dist/policy/core/policy-source-tag.js +0 -28
  199. package/dist/policy/internal.d.ts +0 -25
  200. package/dist/policy/internal.js +0 -25
  201. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  202. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  203. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  204. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  205. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  206. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  207. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  208. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  209. package/dist/runtime/engine-context.d.ts +0 -39
  210. package/dist/runtime/engine-context.js +0 -31
  211. package/dist/runtime/internal.d.ts +0 -43
  212. package/dist/runtime/internal.js +0 -45
  213. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  214. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  215. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  216. package/dist/runtime/stage-emit-gate.js +0 -82
  217. package/dist/services/I18nService.d.ts +0 -47
  218. package/dist/services/I18nService.js +0 -84
  219. package/dist/services/ThemeProvider.d.ts +0 -95
  220. package/dist/services/ThemeProvider.js +0 -334
  221. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  222. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  223. package/dist/services/pnp-standard-features.d.ts +0 -238
  224. package/dist/services/pnp-standard-features.js +0 -338
  225. package/dist/tools/client.d.ts +0 -10
  226. package/dist/tools/client.js +0 -10
  227. package/dist/tools/internal.d.ts +0 -44
  228. package/dist/tools/internal.js +0 -39
@@ -16,18 +16,25 @@
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
18
  import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
19
- import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
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,13 +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
132
  activeSentenceStartOffset = null;
99
133
  playbackStartDeferredRunId = null;
100
134
  pendingPlaybackStartHighlights = [];
101
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;
102
140
  playbackRateWriteQueue = Promise.resolve();
103
141
  playbackRateRequestId = 0;
104
142
  pendingPlaybackRateRequest = null;
@@ -110,32 +148,7 @@ export class TTSService {
110
148
  // Caches DOM-free spoken text keyed by SRE settings and MathML source across
111
149
  // utterances within this service instance.
112
150
  generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
113
- // Verbose alignment/highlight tracing is opt-in: it is expensive (large
114
- // objects, a full text diff per utterance) and noisy in production. Enable
115
- // with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
116
- static debugEnabled = (() => {
117
- try {
118
- if (typeof process !== "undefined" &&
119
- process.env?.PIE_TTS_DEBUG === "1") {
120
- return true;
121
- }
122
- }
123
- catch {
124
- // `process` is not defined in the browser; fall through to the global.
125
- }
126
- return (globalThis.__PIE_TTS_DEBUG__ === true);
127
- })();
128
151
  constructor() { }
129
- debugLog(message, detail) {
130
- if (!TTSService.debugEnabled)
131
- return;
132
- if (detail === undefined) {
133
- console.debug(message);
134
- }
135
- else {
136
- console.debug(message, detail);
137
- }
138
- }
139
152
  clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
140
153
  cleanup?.();
141
154
  if (this.playbackStartBarrierCleanup === cleanup) {
@@ -147,7 +160,12 @@ export class TTSService {
147
160
  runId !== this.speakRunId) {
148
161
  return;
149
162
  }
150
- 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();
151
169
  const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
152
170
  for (const applyHighlight of pendingHighlights) {
153
171
  applyHighlight();
@@ -191,6 +209,82 @@ export class TTSService {
191
209
  }
192
210
  callback();
193
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
+ }
194
288
  async emitTelemetry(eventName, payload) {
195
289
  try {
196
290
  await this.telemetryReporter?.(eventName, payload);
@@ -206,72 +300,39 @@ export class TTSService {
206
300
  * @param config Provider configuration
207
301
  */
208
302
  async initialize(provider, config = {}) {
303
+ if (this.disposed)
304
+ throw new Error("TTS service disposed");
209
305
  this.currentProvider = provider;
210
306
  this.ttsConfig = { ...config };
211
- const providerOptions = config.providerOptions && typeof config.providerOptions === "object"
212
- ? config.providerOptions
213
- : {};
214
- this.telemetryReporter =
215
- typeof providerOptions.__pieTelemetry === "function"
216
- ? providerOptions.__pieTelemetry
217
- : null;
218
- // Initialize provider and get implementation. Browser fallback is only
219
- // allowed here, before the configured provider has successfully become
220
- // the active runtime. Per-call playback/synthesis errors must surface
221
- // instead of silently replacing a user-selected provider and voice.
222
- try {
223
- this.provider = await provider.initialize(config);
224
- }
225
- catch (error) {
226
- if (provider.providerId.toLowerCase() === "browser") {
227
- throw error;
228
- }
229
- if (!this.isBrowserSpeechFallbackAvailable()) {
230
- throw error;
231
- }
232
- const switched = await this.switchProviderToBrowser(error, {
233
- operation: "tts-initialize",
234
- });
235
- if (!switched) {
236
- throw error;
237
- }
238
- }
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);
239
313
  }
240
314
  /**
241
- * Update TTS settings dynamically without full reinitialization
242
- *
243
- * This allows changing rate, pitch, and voice on the fly.
244
- * Note: Some providers may require reinitialization for voice changes.
245
- *
246
- * @param settings Partial settings to update (rate, pitch, voice)
315
+ * Update rate, pitch, voice or provider options without reinitializing.
316
+ * `providerOptions` merge over the ones already configured.
247
317
  */
248
318
  async updateSettings(settings) {
249
319
  if (!this.provider) {
250
320
  throw new Error("TTSService not initialized. Call initialize() first.");
251
321
  }
252
- // If the provider implementation has an updateSettings method, use it
253
- const provider = this.provider;
254
- if (typeof provider.updateSettings === "function") {
255
- const mergedSettings = {
256
- ...settings,
257
- ...(settings.providerOptions
258
- ? {
259
- providerOptions: {
260
- ...(this.ttsConfig.providerOptions || {}),
261
- ...settings.providerOptions,
262
- },
263
- }
264
- : {}),
265
- };
266
- await provider.updateSettings(mergedSettings);
267
- this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
268
- }
269
- else {
270
- // Fallback: Reinitialize with merged config
271
- // This requires storing the original config, which we don't have
272
- // So for now, just log a warning
273
- console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
274
- }
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);
275
336
  }
276
337
  /**
277
338
  * Set highlight coordinator for word highlighting
@@ -279,6 +340,29 @@ export class TTSService {
279
340
  setHighlightCoordinator(coordinator) {
280
341
  this.highlightCoordinator = coordinator;
281
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
+ }
282
366
  /**
283
367
  * Set a late-bound provider for optional host TTS highlight target remapping.
284
368
  *
@@ -313,31 +397,6 @@ export class TTSService {
313
397
  setCatalogResolver(resolver) {
314
398
  this.catalogResolver = resolver;
315
399
  }
316
- /**
317
- * Whether a catalog holds spoken content this service could actually speak.
318
- *
319
- * Exists because `data-catalog-idref` is one attribute with several readers:
320
- * a node can be docked to a catalog that carries only a signing card, and the
321
- * nearest docked ancestor of a selection is therefore not necessarily the one
322
- * holding its authored SSML. A caller resolving a selection climbs ancestors
323
- * and asks this before settling on an id — see the TTS tool's
324
- * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
325
- * silently shadows authored speech on an outer one.
326
- */
327
- hasSpokenAlternate(catalogId, language = "en-US") {
328
- if (!catalogId || !this.catalogResolver)
329
- return false;
330
- const resolved = this.catalogResolver.getAlternative(catalogId, {
331
- type: "spoken",
332
- language,
333
- useFallback: true,
334
- // The string form specifically. A node may also carry a recording of the
335
- // same script; that one is not what this reports on, and card order must
336
- // not decide the answer.
337
- form: "content",
338
- });
339
- return resolved?.content !== undefined;
340
- }
341
400
  getHighlightResolverRuntime() {
342
401
  let provided = null;
343
402
  try {
@@ -364,12 +423,12 @@ export class TTSService {
364
423
  return null;
365
424
  return node.nodeType === Node.ELEMENT_NODE
366
425
  ? node
367
- : node.parentElement;
426
+ : composedParentElement(node);
368
427
  }
369
428
  isElementWithinScope(element, scope) {
370
429
  if (!scope)
371
430
  return true;
372
- return element === scope || scope.contains(element);
431
+ return composedContains(scope, element);
373
432
  }
374
433
  isRangeWithinScope(range, scope) {
375
434
  const startElement = this.getNodeElement(range.startContainer);
@@ -444,28 +503,21 @@ export class TTSService {
444
503
  return null;
445
504
  }
446
505
  }
447
- paintTTSWordRange(range) {
448
- 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)
449
513
  return;
450
- try {
451
- if (range.startContainer === range.endContainer &&
452
- range.startContainer.nodeType === Node.TEXT_NODE) {
453
- this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
454
- return;
455
- }
456
- }
457
- catch {
458
- // Fall through to generic range painting for lightweight test doubles.
459
- }
460
- this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
514
+ this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
461
515
  }
462
516
  paintTTSSentenceRanges(nativeRanges) {
463
517
  if (!this.highlightCoordinator)
464
518
  return;
465
519
  const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
466
- if (elements.length > 0 &&
467
- ranges.length === 0 &&
468
- this.highlightCoordinator.highlightTTSSentenceElements) {
520
+ if (elements.length > 0 && ranges.length === 0) {
469
521
  this.highlightCoordinator.highlightTTSSentenceElements(elements);
470
522
  return;
471
523
  }
@@ -483,36 +535,119 @@ export class TTSService {
483
535
  getCapabilities() {
484
536
  return this.currentProvider?.getCapabilities() || null;
485
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
+ }
486
605
  /**
487
606
  * Build a character-by-character map from normalized text positions to DOM positions
488
607
  * This handles the complexity of whitespace normalization
489
608
  */
490
- getTextProcessingOptions(language) {
491
- const providerOptions = (this.ttsConfig.providerOptions || {});
492
- const textNormalization = (providerOptions.textNormalization ||
493
- {});
494
- const mode = textNormalization.boundarySpacingMode;
609
+ getTextProcessingOptions(locale) {
610
+ const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
495
611
  const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
496
612
  ? mode
497
613
  : undefined;
498
- const locale = language ||
499
- (typeof textNormalization.locale === "string"
500
- ? textNormalization.locale
501
- : undefined);
502
614
  return { locale, boundarySpacingMode };
503
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
+ */
504
637
  getMathSpeechOptions() {
505
- const providerOptions = (this.ttsConfig.providerOptions || {});
506
- const mathSpeech = providerOptions.mathSpeech;
507
- return normalizeSREMathSpeechOptions(mathSpeech);
638
+ if (this.mathSpeechSource) {
639
+ return normalizeSREMathSpeechOptions(this.mathSpeechSource());
640
+ }
641
+ return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
508
642
  }
509
643
  buildPositionMap(element, spokenText, language) {
510
644
  this.normalizedToDOM.clear();
511
645
  const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
512
646
  this.normalizedToDOM = map;
513
- if (!TTSService.debugEnabled)
647
+ // The comparison below diffs the whole text, so it runs only when traced.
648
+ if (!isTtsDebugEnabled())
514
649
  return;
515
- this.debugLog("[TTSService] Text comparison:", {
650
+ logger.debug("Text comparison:", {
516
651
  spokenLength: spokenText.length,
517
652
  normalizedDomLength: normalizedDomText.length,
518
653
  match: spokenText === normalizedDomText,
@@ -535,7 +670,7 @@ export class TTSService {
535
670
  return null;
536
671
  })(),
537
672
  });
538
- this.debugLog("[TTSService] Position map built:", {
673
+ logger.debug("Position map built:", {
539
674
  entries: this.normalizedToDOM.size,
540
675
  spokenTextLength: spokenText.length,
541
676
  normalizedDomLength: normalizedDomText.length,
@@ -551,9 +686,8 @@ export class TTSService {
551
686
  });
552
687
  }
553
688
  getStructuralPauseProfile() {
554
- const providerOptions = (this.ttsConfig.providerOptions || {});
555
- const custom = (providerOptions.structuralPauses || {});
556
- const customUnits = (custom.units || {});
689
+ const custom = this.providerOptions().structuralPauses || {};
690
+ const customUnits = custom.units || {};
557
691
  const toNumber = (value, fallback) => {
558
692
  if (typeof value === "number" && Number.isFinite(value))
559
693
  return value;
@@ -581,17 +715,15 @@ export class TTSService {
581
715
  return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
582
716
  }
583
717
  resolveHighlightMode() {
584
- const providerOptions = (this.ttsConfig.providerOptions || {});
585
- const configuredMode = providerOptions.highlightMode;
718
+ const configuredMode = this.providerOptions().highlightMode;
586
719
  if (configuredMode === "word" || configuredMode === "sentence") {
587
720
  return configuredMode;
588
721
  }
589
- const providerId = (this.currentProvider?.providerId || "").toLowerCase();
590
- if (providerId === "browser") {
591
- return "sentence";
722
+ const capabilities = this.currentProvider?.getCapabilities();
723
+ if (capabilities?.defaultHighlightMode) {
724
+ return capabilities.defaultHighlightMode;
592
725
  }
593
- const supportsWordBoundary = this.currentProvider?.getCapabilities().supportsWordBoundary ?? false;
594
- return supportsWordBoundary ? "word" : "sentence";
726
+ return capabilities?.supportsWordBoundary ? "word" : "sentence";
595
727
  }
596
728
  hasExplicitBreakSemantics(text) {
597
729
  if (!text.includes("<"))
@@ -631,7 +763,7 @@ export class TTSService {
631
763
  return "minor";
632
764
  }
633
765
  getBoundaryAnchor(textNode, root) {
634
- let current = textNode.parentElement;
766
+ let current = flatTreeParentElement(textNode);
635
767
  let best = null;
636
768
  while (current && current !== root) {
637
769
  if (this.isElementHidden(current))
@@ -664,7 +796,7 @@ export class TTSService {
664
796
  }
665
797
  }
666
798
  }
667
- current = current.parentElement;
799
+ current = flatTreeParentElement(current);
668
800
  }
669
801
  if (!best)
670
802
  return null;
@@ -679,8 +811,8 @@ export class TTSService {
679
811
  * boundaries are collected over the whole element, and a selection speaks only
680
812
  * part of it.
681
813
  */
682
- createSpeechPlan(contentElement, normalizedText, textOffset = 0) {
683
- const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText);
814
+ createSpeechPlan(contentElement, normalizedText, textOffset, language) {
815
+ const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText, language);
684
816
  if (textOffset === 0) {
685
817
  return this.createSpeechPlanSegments(normalizedText, boundaries);
686
818
  }
@@ -690,16 +822,15 @@ export class TTSService {
690
822
  }
691
823
  return this.createSpeechPlanSegments(normalizedText, shifted);
692
824
  }
693
- collectSpeechPlanBoundaries(contentElement, normalizedText) {
825
+ collectSpeechPlanBoundaries(contentElement, normalizedText, language) {
694
826
  const boundaries = new Map();
695
- 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));
696
830
  const nodeStartOffsets = this.createNodeStartOffsets(map);
697
- const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
698
- let currentNode = walker.nextNode();
699
831
  let previousBoundaryAnchor = null;
700
- while (currentNode) {
701
- const textNode = currentNode;
702
- const parent = textNode.parentElement;
832
+ for (const textNode of flatTreeTextNodes(contentElement)) {
833
+ const parent = flatTreeParentElement(textNode);
703
834
  if (parent &&
704
835
  !this.isElementHidden(parent) &&
705
836
  !isNodeSuppressedForTTS(textNode)) {
@@ -713,7 +844,6 @@ export class TTSService {
713
844
  previousBoundaryAnchor = boundary.anchor;
714
845
  }
715
846
  }
716
- currentNode = walker.nextNode();
717
847
  }
718
848
  return boundaries;
719
849
  }
@@ -761,9 +891,7 @@ export class TTSService {
761
891
  return segments.filter((segment) => segment.text.trim().length > 0);
762
892
  }
763
893
  segmentSentences(text) {
764
- const locale = this.ttsConfig.providerOptions
765
- ?.locale || undefined;
766
- return segmentTextToSentences(text, { locale });
894
+ return segmentTextToSentences(text, { locale: this.readLocale });
767
895
  }
768
896
  createSeekSegmentsFromText(text) {
769
897
  return this.segmentSentences(text)
@@ -863,6 +991,10 @@ export class TTSService {
863
991
  const providerWithPlan = this.provider;
864
992
  if (!shouldTrackSentenceProgress &&
865
993
  typeof providerWithPlan.speakSegments === "function") {
994
+ await this.waitWhilePaused(runId);
995
+ if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
996
+ return;
997
+ }
866
998
  this.currentBoundaryOffset = 0;
867
999
  const originalOnWordBoundary = this.provider.onWordBoundary;
868
1000
  this.provider.onWordBoundary = (word, position, length) => {
@@ -872,7 +1004,7 @@ export class TTSService {
872
1004
  }
873
1005
  };
874
1006
  try {
875
- await providerWithPlan.speakSegments(segments);
1007
+ await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
876
1008
  }
877
1009
  finally {
878
1010
  if (runId === this.speakRunId && this.provider === providerWithPlan) {
@@ -886,6 +1018,7 @@ export class TTSService {
886
1018
  wordBoundaryOffset: this.activeWordBoundaryOffset,
887
1019
  });
888
1020
  for (const segment of segments) {
1021
+ await this.waitWhilePaused(runId);
889
1022
  if (runId !== this.speakRunId)
890
1023
  return;
891
1024
  this.currentBoundaryOffset = segment.startOffset;
@@ -894,10 +1027,10 @@ export class TTSService {
894
1027
  this.highlightSentenceSegment(segment.startOffset, segment.text);
895
1028
  });
896
1029
  }
897
- await this.provider.speak(segment.text);
1030
+ await this.speakWithinLimit(segment.text, runId);
898
1031
  const pauseMs = segment.pauseMsAfter ?? 0;
899
- if (pauseMs > 0) {
900
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
1032
+ if (pauseMs > 0 && runId === this.speakRunId) {
1033
+ await this.waitStructuralPause(pauseMs);
901
1034
  }
902
1035
  }
903
1036
  }
@@ -917,10 +1050,11 @@ export class TTSService {
917
1050
  const end = this.normalizedToDOM.get(mapStart + length - 1);
918
1051
  if (!start || !end)
919
1052
  return;
920
- const range = document.createRange();
921
- range.setStart(start.node, start.offset);
922
- range.setEnd(end.node, end.offset + 1);
923
- this.paintTTSSentenceRanges([range]);
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);
924
1058
  this.activeSentenceStartOffset = startOffset;
925
1059
  }
926
1060
  getSegmentIndexForOffset(segments, offset) {
@@ -952,66 +1086,145 @@ export class TTSService {
952
1086
  this.highlightSentenceSegment(segment.startOffset, segment.text);
953
1087
  }
954
1088
  /**
955
- * 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.
956
1093
  *
957
- * @param charIndex Character position in normalized/spoken text
958
- * @param length Length of the word
959
- * @returns Text node and local offsets, or null if not found
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.
960
1097
  */
961
- findHighlightRange(charIndex, length) {
962
- const startPos = this.normalizedToDOM.get(charIndex);
963
- if (!startPos) {
964
- console.warn(`[TTSService] No mapping found for start position ${charIndex}`, {
965
- totalMappings: this.normalizedToDOM.size,
966
- nearbyMappings: Array.from(this.normalizedToDOM.entries())
967
- .filter(([pos]) => Math.abs(pos - charIndex) < 5)
968
- .map(([pos, { offset, node }]) => ({
969
- pos,
970
- offset,
971
- char: node.textContent?.[offset],
972
- })),
973
- });
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.");
974
1150
  return null;
975
1151
  }
976
- // Find the end position (last character of the word)
977
- const endIndex = charIndex + length - 1;
978
- const endPos = this.normalizedToDOM.get(endIndex);
979
- if (!endPos) {
980
- console.warn(`[TTSService] No mapping found for end position ${endIndex}`, {
981
- startChar: charIndex,
982
- length,
983
- totalMappings: this.normalizedToDOM.size,
984
- });
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
+ }
985
1181
  return null;
986
1182
  }
987
- // For simplicity, if the word spans multiple nodes, just highlight in the first node
988
- // (This is a rare edge case and would require creating multiple ranges)
989
- if (startPos.node !== endPos.node) {
990
- console.warn(`[TTSService] Word spans multiple nodes, highlighting in first node only`);
991
- return {
992
- node: startPos.node,
993
- start: startPos.offset,
994
- end: (startPos.node.textContent || "").length,
995
- };
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;
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);
996
1204
  }
1205
+ logger.debug("selection offset calculation:", {
1206
+ selectedText: text,
1207
+ mapped: !!mapped,
1208
+ offset,
1209
+ rootTag: root.tagName,
1210
+ });
997
1211
  return {
998
- node: startPos.node,
999
- start: startPos.offset,
1000
- 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
+ },
1001
1225
  };
1002
1226
  }
1003
- /**
1004
- * Speak text with optional catalog support
1005
- *
1006
- * @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
1007
- * @param options Optional catalog ID, language, and content element for highlighting
1008
- */
1009
- async speak(text, options) {
1010
- if (!this.provider) {
1011
- throw new Error("TTS service not initialized");
1012
- }
1013
- const runId = ++this.speakRunId;
1014
- this.clearPlaybackStartBarrier();
1227
+ async speakContent(text, options, runId) {
1015
1228
  let playbackStartBarrier = null;
1016
1229
  try {
1017
1230
  await this.applyLanguageSettings(options);
@@ -1027,8 +1240,9 @@ export class TTSService {
1027
1240
  catalogId: options?.catalogId,
1028
1241
  });
1029
1242
  this.initializeSpeakTracking(highlightText, options);
1030
- const highlightMode = options?.highlightModeOverride ||
1031
- (speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
1243
+ const highlightMode = speechMatchesVisibleText
1244
+ ? this.resolveHighlightMode()
1245
+ : "sentence";
1032
1246
  this.activeHighlightMode = highlightMode;
1033
1247
  this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
1034
1248
  const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
@@ -1042,7 +1256,7 @@ export class TTSService {
1042
1256
  : hasExplicitBreaks || !speechMatchesVisibleText
1043
1257
  ? []
1044
1258
  : shouldUsePlan && this.currentContentElement
1045
- ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0)
1259
+ ? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0, options?.language)
1046
1260
  : this.createSeekSegmentsFromText(highlightText);
1047
1261
  this.sentenceHighlightSegments = hasExplicitBreaks
1048
1262
  ? []
@@ -1051,7 +1265,6 @@ export class TTSService {
1051
1265
  shouldUsePlan,
1052
1266
  playbackSegments: this.seekSegments,
1053
1267
  });
1054
- this.setState(PlaybackState.LOADING);
1055
1268
  playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
1056
1269
  this.prepareHighlightsForSpeak({
1057
1270
  contentToSpeak: highlightText,
@@ -1069,9 +1282,8 @@ export class TTSService {
1069
1282
  else {
1070
1283
  this.clearWordBoundaryHighlighting();
1071
1284
  }
1072
- if (!playbackStartBarrier) {
1073
- this.setState(PlaybackState.PLAYING);
1074
- }
1285
+ if (!playbackStartBarrier)
1286
+ this.markPlaying();
1075
1287
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
1076
1288
  await this.executeSpeakPlayback({
1077
1289
  shouldUsePlan,
@@ -1086,146 +1298,68 @@ export class TTSService {
1086
1298
  this.clearHighlightsAndTracking();
1087
1299
  }
1088
1300
  catch (error) {
1089
- let finalError = error;
1090
- console.error("TTS error:", finalError);
1091
- this.lastError =
1092
- finalError instanceof Error ? finalError.message : String(finalError);
1301
+ // A superseded run's failure is the abort that superseded it.
1093
1302
  if (runId !== this.speakRunId)
1094
1303
  return;
1304
+ console.error("TTS error:", error);
1305
+ this.lastError = error instanceof Error ? error.message : String(error);
1095
1306
  this.setState(PlaybackState.ERROR);
1096
1307
  this.clearHighlightsAndTracking();
1097
- throw finalError;
1308
+ throw error;
1098
1309
  }
1099
1310
  finally {
1100
1311
  this.clearPlaybackStartBarrier(playbackStartBarrier);
1101
1312
  }
1102
1313
  }
1103
- async switchProviderToBrowser(reason, context) {
1104
- if (!this.currentProvider)
1105
- return false;
1106
- const fallbackProvider = new BrowserTTSProvider();
1107
- const previousProviderId = this.currentProvider.providerId;
1108
- // Voice identifiers and provider extensions belong to the provider that
1109
- // rejected initialization. Carry only settings whose meaning is portable
1110
- // when crossing the provider boundary; otherwise a Polly/Google/SC voice
1111
- // such as "Joanna" can incorrectly become an explicit Browser voice.
1112
- const browserConfig = {};
1113
- if (this.ttsConfig.rate !== undefined) {
1114
- browserConfig.rate = this.ttsConfig.rate;
1115
- }
1116
- if (this.ttsConfig.pitch !== undefined) {
1117
- browserConfig.pitch = this.ttsConfig.pitch;
1118
- }
1119
- if (this.ttsConfig.mathTokenHighlighting !== undefined) {
1120
- browserConfig.mathTokenHighlighting =
1121
- this.ttsConfig.mathTokenHighlighting;
1122
- }
1123
- const operation = context?.operation || "tts-initialize";
1124
- const offendingText = context?.contentToSpeak ?? "";
1125
- const offendingPreview = offendingText
1126
- .replace(/\s+/g, " ")
1127
- .trim()
1128
- .slice(0, 120);
1129
- const offendingDiagnostic = offendingText.length > 0
1130
- ? {
1131
- textLength: offendingText.length,
1132
- textPreview: offendingPreview.length < offendingText.length
1133
- ? `${offendingPreview}…`
1134
- : offendingPreview,
1135
- }
1136
- : {};
1137
- try {
1138
- this.provider?.stop();
1139
- this.currentProvider.destroy();
1140
- this.currentProvider = fallbackProvider;
1141
- this.provider = await fallbackProvider.initialize(browserConfig);
1142
- this.ttsConfig = browserConfig;
1143
- await this.emitTelemetry("pie-tool-runtime-fallback", {
1144
- toolId: "textToSpeech",
1145
- operation,
1146
- fromProvider: previousProviderId,
1147
- toProvider: fallbackProvider.providerId,
1148
- reason: reason instanceof Error ? reason.message : String(reason),
1149
- ...offendingDiagnostic,
1150
- });
1151
- console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
1152
- fromProvider: previousProviderId,
1153
- reason,
1154
- ...offendingDiagnostic,
1155
- });
1156
- return true;
1157
- }
1158
- catch (fallbackError) {
1159
- await this.emitTelemetry("pie-tool-runtime-fallback-error", {
1160
- toolId: "textToSpeech",
1161
- operation,
1162
- fromProvider: previousProviderId,
1163
- toProvider: "browser",
1164
- errorType: "TTSRuntimeFallbackError",
1165
- message: fallbackError instanceof Error
1166
- ? fallbackError.message
1167
- : String(fallbackError),
1168
- });
1169
- console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
1170
- return false;
1171
- }
1172
- }
1173
- isBrowserSpeechFallbackAvailable() {
1174
- if (typeof window === "undefined")
1175
- return false;
1176
- if (!("speechSynthesis" in window))
1177
- return false;
1178
- return (typeof globalThis
1179
- .SpeechSynthesisUtterance === "function");
1180
- }
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
+ */
1181
1320
  async applyLanguageSettings(options) {
1182
- if (!options?.language || !this.provider)
1321
+ if (!this.provider)
1183
1322
  return;
1184
- const providerOptions = (this.ttsConfig.providerOptions || {});
1185
- const textNormalization = (providerOptions.textNormalization ||
1186
- {});
1187
- const segmenter = (providerOptions.segmenter || {});
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) {
1336
+ return;
1337
+ }
1338
+ // Every key is set, undefined included: providers merge options shallowly.
1188
1339
  const mergedProviderOptions = {
1189
1340
  ...providerOptions,
1190
- locale: options.language,
1341
+ locale: next.locale,
1191
1342
  textNormalization: {
1192
- ...textNormalization,
1193
- locale: options.language,
1194
- },
1195
- segmenter: {
1196
- ...segmenter,
1197
- locale: options.language,
1343
+ ...providerOptions.textNormalization,
1344
+ locale: next.textNormalization,
1198
1345
  },
1346
+ segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
1347
+ contentLanguage: named,
1199
1348
  };
1200
1349
  this.ttsConfig = {
1201
1350
  ...this.ttsConfig,
1202
1351
  providerOptions: mergedProviderOptions,
1203
1352
  };
1204
- if ("updateSettings" in this.provider &&
1205
- typeof this.provider.updateSettings ===
1206
- "function") {
1207
- await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
1208
- }
1353
+ await this.provider.updateSettings({
1354
+ providerOptions: mergedProviderOptions,
1355
+ });
1209
1356
  }
1210
1357
  async resolveSpeechContent(text, options) {
1211
1358
  const normalizedInputText = normalizeTextForSpeech(text);
1212
- if (options?.ignoreCatalogs) {
1213
- return {
1214
- contentToSpeak: normalizedInputText,
1215
- speechText: normalizedInputText,
1216
- visibleText: normalizedInputText,
1217
- highlightText: normalizedInputText,
1218
- usedCatalogSpoken: false,
1219
- speechSource: "dom-or-input",
1220
- normalizedText: normalizedInputText,
1221
- containsMathMarkup: false,
1222
- speechMatchesVisibleText: true,
1223
- };
1224
- }
1225
- if (options?.catalogId && this.catalogResolver) {
1359
+ if (options.catalogId && this.catalogResolver) {
1226
1360
  const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
1227
1361
  type: "spoken",
1228
- language: options.language || "en-US",
1362
+ language: options.language,
1229
1363
  useFallback: true,
1230
1364
  context: options.catalogContext,
1231
1365
  form: "content",
@@ -1234,11 +1368,9 @@ export class TTSService {
1234
1368
  // instance. Fall through to generated TTS rather than speaking "".
1235
1369
  const spokenText = catalogContent?.content;
1236
1370
  if (catalogContent && spokenText !== undefined) {
1237
- const visibleText = options?.contentElement
1238
- ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
1239
- : normalizedInputText;
1371
+ const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
1240
1372
  const normalizedCatalogText = normalizeTextForSpeech(spokenText);
1241
- this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1373
+ logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1242
1374
  return {
1243
1375
  contentToSpeak: spokenText,
1244
1376
  speechText: spokenText,
@@ -1251,24 +1383,12 @@ export class TTSService {
1251
1383
  speechMatchesVisibleText: normalizedCatalogText === visibleText,
1252
1384
  };
1253
1385
  }
1254
- 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`);
1255
1387
  }
1256
- const composed = options?.contentElement
1257
- ? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
1258
- : null;
1388
+ const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
1259
1389
  if (composed)
1260
1390
  return composed;
1261
- const generated = options?.contentElement
1262
- ? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
1263
- : {
1264
- contentToSpeak: normalizedInputText,
1265
- speechText: normalizedInputText,
1266
- visibleText: normalizedInputText,
1267
- highlightText: normalizedInputText,
1268
- normalizedText: normalizedInputText,
1269
- containsMathMarkup: false,
1270
- speechMatchesVisibleText: true,
1271
- };
1391
+ const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
1272
1392
  return {
1273
1393
  ...generated,
1274
1394
  usedCatalogSpoken: false,
@@ -1282,7 +1402,11 @@ export class TTSService {
1282
1402
  if (!chunks.some((chunk) => chunk.sourceElement))
1283
1403
  return null;
1284
1404
  const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
1285
- 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;
1286
1410
  return {
1287
1411
  contentToSpeak: speechText,
1288
1412
  speechText,
@@ -1311,13 +1435,22 @@ export class TTSService {
1311
1435
  speechMatchesVisibleText: true,
1312
1436
  });
1313
1437
  };
1438
+ const range = options.contentRange;
1314
1439
  const resolveCatalog = (element) => {
1315
1440
  const catalogIdRef = element.getAttribute("data-catalog-idref");
1316
1441
  if (!catalogIdRef)
1317
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
+ }
1318
1449
  const lookup = {
1319
1450
  type: "spoken",
1320
- 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,
1321
1454
  useFallback: true,
1322
1455
  context: options.catalogContext,
1323
1456
  };
@@ -1354,7 +1487,7 @@ export class TTSService {
1354
1487
  : null;
1355
1488
  };
1356
1489
  const getMathElementsForAlignment = (element) => {
1357
- const mathElements = Array.from(element.querySelectorAll("math"));
1490
+ const mathElements = flatQuerySelectorAll(element, "math");
1358
1491
  if (element.localName?.toLowerCase() === "math") {
1359
1492
  return [element, ...mathElements];
1360
1493
  }
@@ -1367,7 +1500,12 @@ export class TTSService {
1367
1500
  if (isNodeExcludedFromSpeech(node, root))
1368
1501
  return;
1369
1502
  if (node.nodeType === Node.TEXT_NODE) {
1370
- textBuffer += ` ${node.textContent || ""}`;
1503
+ if (!range) {
1504
+ textBuffer += ` ${node.textContent || ""}`;
1505
+ }
1506
+ else if (rangeIntersectsComposedNode(range, node)) {
1507
+ textBuffer += ` ${textInRange(node, range)}`;
1508
+ }
1371
1509
  return;
1372
1510
  }
1373
1511
  if (node.nodeType !== Node.ELEMENT_NODE)
@@ -1378,7 +1516,7 @@ export class TTSService {
1378
1516
  flushTextBuffer();
1379
1517
  const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1380
1518
  const visibleText = collectedVisible.visibleText ||
1381
- normalizeTextForSpeech(element.textContent || "");
1519
+ normalizeTextForSpeech(flatTextContent(element));
1382
1520
  const regionElement = resolveReadableRegion(element, root);
1383
1521
  // The script chunk, when there is a script. Built exactly as before,
1384
1522
  // and it doubles as the recording's fallback: word-level alignment is
@@ -1446,7 +1584,7 @@ export class TTSService {
1446
1584
  chunks.push(scriptChunk);
1447
1585
  return;
1448
1586
  }
1449
- for (const child of Array.from(element.childNodes)) {
1587
+ for (const child of flatTreeChildNodes(element)) {
1450
1588
  visit(child);
1451
1589
  }
1452
1590
  };
@@ -1468,12 +1606,13 @@ export class TTSService {
1468
1606
  const provider = this.currentProvider;
1469
1607
  if (!provider)
1470
1608
  return "plain";
1471
- // Belt-and-suspenders: the browser provider can never voice SSML.
1472
- if (provider.providerId?.toLowerCase() === "browser")
1473
- return "plain";
1474
1609
  return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
1475
1610
  }
1476
- 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) {
1477
1616
  // Delegate to the generated-speech module: a pure plan assembly + SRE
1478
1617
  // memoization core, plus a DOM adapter that binds live-DOM anchors and
1479
1618
  // emits playback chunks. The aggregate `contentToSpeak` stays plain text
@@ -1481,14 +1620,19 @@ export class TTSService {
1481
1620
  const playbackFormat = this.resolveGeneratedPlaybackFormat();
1482
1621
  const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
1483
1622
  contentRoot: contentElement,
1623
+ range,
1484
1624
  language,
1485
1625
  mathSpeech: this.getMathSpeechOptions(),
1486
1626
  textProcessingOptions: this.getTextProcessingOptions(language),
1487
1627
  produceSsml: playbackFormat === "ssml",
1488
1628
  resolveMathSpeech: this.generatedMathSpeechResolver,
1489
1629
  });
1490
- const visibleText = collectedVisibleText || normalizedInputText;
1491
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;
1492
1636
  return {
1493
1637
  contentToSpeak: visibleText,
1494
1638
  speechText: visibleText,
@@ -1499,6 +1643,7 @@ export class TTSService {
1499
1643
  speechMatchesVisibleText: true,
1500
1644
  };
1501
1645
  }
1646
+ const visibleText = collectedVisibleText || normalizedInputText;
1502
1647
  const speechChunks = planToCompositionChunkInputs(plan, {
1503
1648
  format: playbackFormat,
1504
1649
  });
@@ -1519,7 +1664,7 @@ export class TTSService {
1519
1664
  .replace(/\s+/g, " ")
1520
1665
  .trim()
1521
1666
  .slice(0, 200);
1522
- this.debugLog("[TTSService] Speak resolved content", {
1667
+ logger.debug("Speak resolved content", {
1523
1668
  source: args.speechSource,
1524
1669
  catalogId: args.catalogId || null,
1525
1670
  length: args.contentToSpeak.length,
@@ -1528,7 +1673,7 @@ export class TTSService {
1528
1673
  }
1529
1674
  initializeSpeakTracking(contentToSpeak, options) {
1530
1675
  this.currentText = contentToSpeak;
1531
- this.currentContentElement = options?.contentElement || null;
1676
+ this.currentContentElement = options.contentElement;
1532
1677
  this.lastError = null;
1533
1678
  this.currentBoundaryOffset = 0;
1534
1679
  this.playbackChunks = [];
@@ -1537,7 +1682,7 @@ export class TTSService {
1537
1682
  prepareHighlightsForSpeak(args) {
1538
1683
  if (!this.currentContentElement || !this.highlightCoordinator)
1539
1684
  return;
1540
- this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options?.language);
1685
+ this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
1541
1686
  if (args.highlightMode === "sentence" && args.shouldUsePlan) {
1542
1687
  return;
1543
1688
  }
@@ -1545,7 +1690,7 @@ export class TTSService {
1545
1690
  this.runWhenPlaybackStarts(args.runId, () => {
1546
1691
  if (initialSegment) {
1547
1692
  this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
1548
- this.debugLog("[TTSService] Applied initial sentence highlighting");
1693
+ logger.debug("Applied initial sentence highlighting");
1549
1694
  }
1550
1695
  else {
1551
1696
  try {
@@ -1560,34 +1705,29 @@ export class TTSService {
1560
1705
  });
1561
1706
  }
1562
1707
  configureWordBoundaryHighlighting(args) {
1563
- if (!this.provider ||
1564
- args.highlightMode !== "word" ||
1565
- !this.highlightCoordinator ||
1566
- !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;
1567
1713
  return;
1568
1714
  }
1569
- // Word index uses the same globalIndex as highlightTTSWord (browser boundaries
1570
- // 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.
1571
1719
  this.provider.onWordBoundary = (word, charIndex, length) => {
1572
1720
  const wordLength = length || word.length;
1573
1721
  const spokenIndex = charIndex + this.currentBoundaryOffset;
1574
1722
  const globalIndex = spokenIndex + args.wordBoundaryOffset;
1575
1723
  this.highlightSentenceForOffset(spokenIndex);
1576
- const highlightRange = this.findHighlightRange(globalIndex, wordLength);
1577
- if (highlightRange && this.highlightCoordinator) {
1578
- const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
1579
- this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1580
- const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
1581
- if (nativeRange) {
1582
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1583
- }
1584
- else {
1585
- this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1586
- }
1587
- }
1588
- else {
1589
- console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
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;
1590
1728
  }
1729
+ logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
1730
+ this.paintTTSWord(ranges);
1591
1731
  };
1592
1732
  }
1593
1733
  clearWordBoundaryHighlighting() {
@@ -1602,26 +1742,22 @@ export class TTSService {
1602
1742
  try {
1603
1743
  this.lastRenderedRegionTarget = null;
1604
1744
  if (chunk.regionRange) {
1605
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1606
- 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]);
1607
1748
  return;
1608
1749
  }
1609
1750
  if (!element)
1610
1751
  return;
1611
1752
  const range = document.createRange();
1612
1753
  range.selectNodeContents(element);
1613
- this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1754
+ this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
1614
1755
  this.paintTTSSentenceRanges([range]);
1615
1756
  }
1616
1757
  catch {
1617
1758
  // Continue playback even when a detached node cannot be highlighted.
1618
1759
  }
1619
1760
  }
1620
- highlightCatalogActiveRange(range) {
1621
- if (!this.highlightCoordinator)
1622
- return;
1623
- this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
1624
- }
1625
1761
  highlightRenderableRegionTarget(target) {
1626
1762
  if (!this.highlightCoordinator ||
1627
1763
  !target ||
@@ -1638,7 +1774,7 @@ export class TTSService {
1638
1774
  range.selectNodeContents(target.element);
1639
1775
  }
1640
1776
  else if (target.type === "range") {
1641
- this.paintTTSSentenceRanges([target.range]);
1777
+ this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
1642
1778
  this.lastRenderedRegionTarget = target;
1643
1779
  return;
1644
1780
  }
@@ -1668,23 +1804,19 @@ export class TTSService {
1668
1804
  }
1669
1805
  if (target.type === "text-range") {
1670
1806
  const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
1671
- if (nativeRange) {
1672
- this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1673
- }
1674
- else {
1675
- this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1676
- }
1807
+ if (nativeRange)
1808
+ this.paintTTSWord([nativeRange]);
1677
1809
  return;
1678
1810
  }
1679
1811
  if (target.type === "range") {
1680
- this.highlightCatalogActiveRange(target.range);
1812
+ this.paintTTSWord(target.ranges ?? [target.range]);
1681
1813
  return;
1682
1814
  }
1683
1815
  const elementNativeRange = document.createRange();
1684
1816
  elementNativeRange.selectNodeContents(target.element);
1685
1817
  const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
1686
1818
  if (resolvedElementRange.remapped) {
1687
- this.paintTTSWordRange(resolvedElementRange.range);
1819
+ this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
1688
1820
  return;
1689
1821
  }
1690
1822
  // Element targets are atomic: a resolved math token, a whole-expression
@@ -1699,10 +1831,12 @@ export class TTSService {
1699
1831
  const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
1700
1832
  if (target.quality === "semantic-token" &&
1701
1833
  onlyChild?.nodeType === Node.TEXT_NODE) {
1702
- this.highlightCoordinator.highlightTTSWord(onlyChild, 0, onlyChild.textContent?.length || 0);
1834
+ const tokenRange = document.createRange();
1835
+ tokenRange.selectNodeContents(onlyChild);
1836
+ this.highlightCoordinator.highlightTTSWord([tokenRange]);
1703
1837
  return;
1704
1838
  }
1705
- this.highlightCoordinator.highlightTTSWordElement?.(target.element);
1839
+ this.highlightCoordinator.highlightTTSWordElement(target.element);
1706
1840
  }
1707
1841
  renderHighlightDecision(decision) {
1708
1842
  this.highlightRenderableRegionTarget(decision.regionTarget);
@@ -1718,7 +1852,9 @@ export class TTSService {
1718
1852
  // anchors, plain alignment). The fallback chunk carries no further
1719
1853
  // fallback, so this cannot recurse.
1720
1854
  if (chunk.plainFallback && runId === this.speakRunId) {
1721
- this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
1855
+ logger.debug("SSML chunk speak failed; retrying plain text", {
1856
+ message: error instanceof Error ? error.message : String(error),
1857
+ });
1722
1858
  // A recorded-first run can own the start barrier even when its fallback
1723
1859
  // provider has no formal start signal. Drop the failed recording's queued
1724
1860
  // highlight and resume that non-start-aware provider's immediate-start lifecycle.
@@ -1727,7 +1863,7 @@ export class TTSService {
1727
1863
  !("onPlaybackStart" in this.provider)) {
1728
1864
  this.pendingPlaybackStartHighlights = [];
1729
1865
  this.clearPlaybackStartBarrier();
1730
- this.setState(PlaybackState.PLAYING);
1866
+ this.markPlaying();
1731
1867
  }
1732
1868
  await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
1733
1869
  return;
@@ -1787,6 +1923,18 @@ export class TTSService {
1787
1923
  cleanup();
1788
1924
  reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1789
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
+ };
1790
1938
  // Cancellation has to settle this promise, not just stop the element:
1791
1939
  // `stop()` bumps the run id, and a pending play that never resolves would
1792
1940
  // wedge the chunk loop on a run nobody is listening to any more.
@@ -1800,6 +1948,7 @@ export class TTSService {
1800
1948
  element.pause();
1801
1949
  resolve();
1802
1950
  },
1951
+ play,
1803
1952
  };
1804
1953
  element.addEventListener("ended", onEnded);
1805
1954
  element.addEventListener("error", onError);
@@ -1807,7 +1956,7 @@ export class TTSService {
1807
1956
  // Reaching the slice's end is this clip finishing, so the chunk sequence
1808
1957
  // advances rather than the element merely pausing.
1809
1958
  disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
1810
- Promise.resolve(element.play()).then(markStarted).catch(onError);
1959
+ play();
1811
1960
  }).finally(() => {
1812
1961
  if (this.activeRecordedAudio?.element === element) {
1813
1962
  this.activeRecordedAudio = null;
@@ -1860,7 +2009,9 @@ export class TTSService {
1860
2009
  this.highlightCatalogRegion(chunk);
1861
2010
  }
1862
2011
  });
1863
- 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 &&
1864
2015
  ((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
1865
2016
  (chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
1866
2017
  (chunk.alignment &&
@@ -1872,7 +2023,7 @@ export class TTSService {
1872
2023
  const previousOnWordBoundary = provider.onWordBoundary;
1873
2024
  provider.onWordBoundary = undefined;
1874
2025
  try {
1875
- await provider.speak(chunk.speechText);
2026
+ await this.speakWithinLimit(chunk.speechText, runId);
1876
2027
  }
1877
2028
  finally {
1878
2029
  if (this.provider === provider &&
@@ -1904,7 +2055,7 @@ export class TTSService {
1904
2055
  };
1905
2056
  provider.onWordBoundary = installedHandler;
1906
2057
  try {
1907
- await provider.speak(chunk.speechText);
2058
+ await this.speakWithinLimit(chunk.speechText, runId);
1908
2059
  }
1909
2060
  finally {
1910
2061
  if (this.provider === provider &&
@@ -1917,6 +2068,7 @@ export class TTSService {
1917
2068
  async executeSpeakPlayback(args) {
1918
2069
  if (args.speechChunks?.length && this.provider) {
1919
2070
  for (let index = 0; index < args.speechChunks.length; index++) {
2071
+ await this.waitWhilePaused(args.runId);
1920
2072
  if (args.runId !== this.speakRunId)
1921
2073
  return;
1922
2074
  const segment = this.seekSegments[index];
@@ -1935,16 +2087,16 @@ export class TTSService {
1935
2087
  highlightMode: args.highlightMode,
1936
2088
  });
1937
2089
  }
1938
- else if (this.provider) {
1939
- await this.provider.speak(args.contentToSpeak);
2090
+ else {
2091
+ await this.speakWithinLimit(args.contentToSpeak, args.runId);
1940
2092
  }
1941
2093
  return;
1942
2094
  }
1943
- if (this.provider) {
1944
- await this.provider.speak(args.contentToSpeak);
1945
- }
2095
+ await this.speakWithinLimit(args.contentToSpeak, args.runId);
1946
2096
  }
1947
2097
  clearHighlightsAndTracking() {
2098
+ // A finished run leaves no handler for the next read to inherit.
2099
+ this.clearWordBoundaryHighlighting();
1948
2100
  this.pendingPlaybackStartHighlights = [];
1949
2101
  this.activePlaybackRate = null;
1950
2102
  if (this.highlightCoordinator) {
@@ -1960,37 +2112,20 @@ export class TTSService {
1960
2112
  this.sentenceHighlightSegments = [];
1961
2113
  this.activeSentenceStartOffset = null;
1962
2114
  }
1963
- /**
1964
- * Speak a text range with accurate word highlighting
1965
- *
1966
- * This calculates the offset of the range within its parent element
1967
- * to ensure word highlighting aligns correctly with the selected text.
1968
- *
1969
- * @param range DOM Range to speak
1970
- */
1971
2115
  /**
1972
2116
  * The selected part of `root`'s normalized visible text and where it starts, or
1973
2117
  * null when the map is unavailable or holds no selected character.
1974
2118
  */
1975
- selectMappedRangeText(range, root) {
2119
+ selectMappedRangeText(range, root, language) {
1976
2120
  if (typeof range.comparePoint !== "function")
1977
2121
  return null;
1978
- const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions());
2122
+ const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
1979
2123
  let start = -1;
1980
2124
  let end = -1;
1981
2125
  for (const [index, { node, offset }] of map) {
1982
2126
  if (index >= rootText.length)
1983
2127
  continue;
1984
- let inside = false;
1985
- try {
1986
- inside =
1987
- range.comparePoint(node, offset) === 0 &&
1988
- range.comparePoint(node, Math.min(offset + 1, node.length)) === 0;
1989
- }
1990
- catch {
1991
- inside = false;
1992
- }
1993
- if (!inside)
2128
+ if (!rangeHoldsTextPosition(range, node, offset))
1994
2129
  continue;
1995
2130
  if (start === -1 || index < start)
1996
2131
  start = index;
@@ -2004,98 +2139,34 @@ export class TTSService {
2004
2139
  const text = raw.trim();
2005
2140
  return text ? { text, offset: start + leading } : null;
2006
2141
  }
2007
- async speakRange(range, options) {
2008
- if (!this.provider) {
2009
- throw new Error("TTS service not initialized");
2010
- }
2011
- // Enforced from the live ancestors rather than from `root`, because the
2012
- // element carrying the suppression may sit above whatever root the caller
2013
- // passed or the range happens to resolve to.
2014
- if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
2015
- console.warn("[tts] selection lies inside content marked not-to-be-spoken; nothing was spoken. Selecting a node and pressing read-aloud must not become a way around suppression.");
2016
- return;
2017
- }
2018
- // Use explicit content root when provided; otherwise keep highlighting scoped
2019
- // to the selected range's nearest element ancestor.
2020
- const fromOptions = options?.contentRoot || null;
2021
- let root = fromOptions;
2022
- if (!root) {
2023
- const ancestor = range.commonAncestorContainer;
2024
- root =
2025
- ancestor.nodeType === Node.ELEMENT_NODE
2026
- ? ancestor
2027
- : ancestor.parentElement;
2028
- }
2029
- if (!root)
2030
- return;
2031
- const selected = collectRangeTextForSpeech(range, root);
2032
- const selectedText = selected.text.trim();
2033
- if (!selectedText) {
2034
- if (selected.filtered) {
2035
- console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
2036
- }
2037
- return;
2038
- }
2039
- // The spoken text and its offset come from the root's visible-text map, which
2040
- // the highlight position map and the structural speech plan index too. Joined
2041
- // from raw text nodes instead, a selection loses the space the map inserts
2042
- // between blocks, and every offset after that boundary drifts.
2043
- const mapped = this.selectMappedRangeText(range, root);
2044
- let text = selectedText;
2045
- let offset = mapped?.offset ?? 0;
2046
- if (mapped) {
2047
- text = mapped.text;
2048
- }
2049
- else {
2050
- // Filtered the same way as the speech itself: the offset indexes into the
2051
- // highlight text, which comes from the exclusion-aware collectors.
2052
- const beforeRange = document.createRange();
2053
- beforeRange.selectNodeContents(root);
2054
- beforeRange.setEnd(range.startContainer, range.startOffset);
2055
- const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
2056
- const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
2057
- offset =
2058
- normalizedTextBeforeRange.length +
2059
- (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
2060
- }
2061
- this.debugLog("[TTSService] speakRange offset calculation:", {
2062
- selectedText: text,
2063
- mapped: !!mapped,
2064
- offset,
2065
- rootTag: root.tagName,
2066
- });
2067
- // Speak the text with the root element as context
2068
- await this.speak(text, {
2069
- contentElement: root,
2070
- ignoreCatalogs: true,
2071
- wordBoundaryOffset: offset,
2072
- });
2073
- }
2074
2142
  /**
2075
- * Pause playback
2143
+ * Pause playback. A pause while the read is loading holds it: its audio does
2144
+ * not start until {@link resume}.
2076
2145
  */
2077
2146
  pause() {
2078
- if (!this.provider)
2147
+ if (this.state !== PlaybackState.PLAYING &&
2148
+ this.state !== PlaybackState.LOADING) {
2079
2149
  return;
2080
- if (this.state === PlaybackState.PLAYING) {
2081
- this.activeRecordedAudio?.element.pause();
2082
- this.provider.pause();
2083
- this.setState(PlaybackState.PAUSED);
2084
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);
2085
2157
  }
2086
2158
  /**
2087
- * Resume playback
2159
+ * Resume playback, back to loading when the pause came before the audio
2160
+ * started.
2088
2161
  */
2089
2162
  resume() {
2090
- if (!this.provider)
2163
+ if (this.state !== PlaybackState.PAUSED)
2091
2164
  return;
2092
- if (this.state === PlaybackState.PAUSED) {
2093
- const recorded = this.activeRecordedAudio?.element;
2094
- if (recorded)
2095
- void Promise.resolve(recorded.play()).catch(() => { });
2096
- this.provider.resume();
2097
- this.setState(PlaybackState.PLAYING);
2098
- }
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();
2099
2170
  }
2100
2171
  normalizePlaybackRate(rate) {
2101
2172
  if (!Number.isFinite(rate))
@@ -2109,18 +2180,27 @@ export class TTSService {
2109
2180
  const restartChunks = this.playbackChunks.length
2110
2181
  ? this.playbackChunks.slice(safeTargetIndex)
2111
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;
2112
2186
  this.speakRunId += 1;
2113
- this.clearPlaybackStartBarrier();
2114
- this.cancelRecordedAudio();
2187
+ this.abandonRun();
2115
2188
  this.provider.onWordBoundary = undefined;
2116
2189
  this.provider.stop();
2117
2190
  const runId = ++this.speakRunId;
2118
2191
  const restartSegments = this.seekSegments.slice(safeTargetIndex);
2119
2192
  this.highlightCoordinator?.clearTTS();
2120
2193
  this.activeSentenceStartOffset = null;
2121
- this.setState(PlaybackState.LOADING);
2194
+ this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
2195
+ if (!holdPaused)
2196
+ this.setState(PlaybackState.LOADING);
2122
2197
  const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
2123
- if (!playbackStartBarrier) {
2198
+ if (holdPaused) {
2199
+ this.resumeState = playbackStartBarrier
2200
+ ? PlaybackState.LOADING
2201
+ : PlaybackState.PLAYING;
2202
+ }
2203
+ else if (!playbackStartBarrier) {
2124
2204
  this.setState(PlaybackState.PLAYING);
2125
2205
  }
2126
2206
  this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
@@ -2131,6 +2211,7 @@ export class TTSService {
2131
2211
  });
2132
2212
  if (restartChunks) {
2133
2213
  for (let index = 0; index < restartChunks.length; index++) {
2214
+ await this.waitWhilePaused(runId);
2134
2215
  if (runId !== this.speakRunId)
2135
2216
  return;
2136
2217
  const absoluteIndex = safeTargetIndex + index;
@@ -2254,13 +2335,12 @@ export class TTSService {
2254
2335
  * Stop playback
2255
2336
  */
2256
2337
  stop() {
2257
- if (!this.provider)
2258
- return;
2259
2338
  this.speakRunId += 1;
2260
- this.clearPlaybackStartBarrier();
2261
- this.cancelRecordedAudio();
2262
- this.provider.onWordBoundary = undefined;
2263
- this.provider.stop();
2339
+ this.abandonRun();
2340
+ if (this.provider) {
2341
+ this.provider.onWordBoundary = undefined;
2342
+ this.provider.stop();
2343
+ }
2264
2344
  this.setState(PlaybackState.IDLE);
2265
2345
  this.currentText = null;
2266
2346
  // Clear highlights
@@ -2279,6 +2359,36 @@ export class TTSService {
2279
2359
  this.activeSentenceStartOffset = null;
2280
2360
  this.activePlaybackRate = null;
2281
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
+ }
2282
2392
  /**
2283
2393
  * Request UI-level TTS controls to hand off/deactivate.
2284
2394
  *
@@ -2325,28 +2435,15 @@ export class TTSService {
2325
2435
  return this.lastError;
2326
2436
  }
2327
2437
  /**
2328
- * Subscribe to state changes
2329
- *
2330
- * @param id Unique listener ID
2331
- * @param callback Function to call on state change
2332
- */
2333
- onStateChange(id, callback) {
2334
- if (!this.listeners.has(id)) {
2335
- this.listeners.set(id, new Set());
2336
- }
2337
- this.listeners.get(id).add(callback);
2338
- }
2339
- /**
2340
- * Unsubscribe from state changes
2438
+ * Subscribe to playback state changes. The returned function unsubscribes;
2439
+ * each subscription is its own, so the same callback can be subscribed twice.
2341
2440
  */
2342
- offStateChange(id, callback) {
2343
- const callbacks = this.listeners.get(id);
2344
- if (callbacks) {
2345
- callbacks.delete(callback);
2346
- if (callbacks.size === 0) {
2347
- this.listeners.delete(id);
2348
- }
2349
- }
2441
+ onStateChange(callback) {
2442
+ const listener = (state) => callback(state);
2443
+ this.listeners.add(listener);
2444
+ return () => {
2445
+ this.listeners.delete(listener);
2446
+ };
2350
2447
  }
2351
2448
  /**
2352
2449
  * Set state and notify listeners
@@ -2357,14 +2454,11 @@ export class TTSService {
2357
2454
  const previousState = this.state;
2358
2455
  this.state = newState;
2359
2456
  // Notify all listeners
2360
- for (const callbacks of this.listeners.values()) {
2361
- for (const callback of callbacks) {
2362
- callback(newState);
2363
- }
2457
+ for (const listener of [...this.listeners]) {
2458
+ listener(newState);
2364
2459
  }
2365
2460
  void this.emitTelemetry("pie-tool-playback-state-changed", {
2366
2461
  toolId: "textToSpeech",
2367
- providerId: "tts",
2368
2462
  previousState,
2369
2463
  state: newState,
2370
2464
  });
@@ -2374,7 +2468,6 @@ export class TTSService {
2374
2468
  : "pie-tool-playback-start";
2375
2469
  void this.emitTelemetry(eventName, {
2376
2470
  toolId: "textToSpeech",
2377
- providerId: "tts",
2378
2471
  previousState,
2379
2472
  state: newState,
2380
2473
  });
@@ -2383,7 +2476,6 @@ export class TTSService {
2383
2476
  if (newState === PlaybackState.PAUSED) {
2384
2477
  void this.emitTelemetry("pie-tool-playback-pause", {
2385
2478
  toolId: "textToSpeech",
2386
- providerId: "tts",
2387
2479
  previousState,
2388
2480
  state: newState,
2389
2481
  });
@@ -2392,7 +2484,6 @@ export class TTSService {
2392
2484
  if (newState === PlaybackState.ERROR) {
2393
2485
  void this.emitTelemetry("pie-tool-playback-error", {
2394
2486
  toolId: "textToSpeech",
2395
- providerId: "tts",
2396
2487
  previousState,
2397
2488
  state: newState,
2398
2489
  message: this.lastError || undefined,
@@ -2405,7 +2496,6 @@ export class TTSService {
2405
2496
  previousState === PlaybackState.LOADING)) {
2406
2497
  void this.emitTelemetry("pie-tool-playback-stop", {
2407
2498
  toolId: "textToSpeech",
2408
- providerId: "tts",
2409
2499
  previousState,
2410
2500
  state: newState,
2411
2501
  });