@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
@@ -6,9 +6,81 @@
6
6
  *
7
7
  * Part of PIE Assessment Toolkit.
8
8
  */
9
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
10
+ import { extractSpokenText, isSsmlDocument } from "./ssml/spoken-text.js";
9
11
  import { segmentSentences as segmentTextToSentences } from "./text-segmentation.js";
12
+ const logger = createPieLogger("browser-tts-provider", isTtsDebugEnabled);
10
13
  const NATIVE_START_TIMEOUT_MS = 5_000;
11
14
  const VOICE_INVENTORY_TIMEOUT_MS = 2_000;
15
+ // The provider options the toolkit and this provider read. Every other option,
16
+ // and `voice`, belongs to the provider it was configured for: a Polly voice such
17
+ // as "Joanna" resolves to no browser voice, and an unresolved voice fails speak.
18
+ const PORTABLE_PROVIDER_OPTIONS = [
19
+ "__pieTelemetry",
20
+ "contentLanguage",
21
+ "highlightMode",
22
+ "locale",
23
+ "mathSpeech",
24
+ "segmenter",
25
+ "structuralPauses",
26
+ "textNormalization",
27
+ ];
28
+ /**
29
+ * The part of another provider's configuration that keeps its meaning on
30
+ * browser speech. Every path that replaces a provider with browser speech
31
+ * starts it with this.
32
+ */
33
+ export const browserFallbackConfig = (config) => {
34
+ const fallback = {};
35
+ if (config.rate !== undefined)
36
+ fallback.rate = config.rate;
37
+ if (config.pitch !== undefined)
38
+ fallback.pitch = config.pitch;
39
+ if (config.mathTokenHighlighting !== undefined) {
40
+ fallback.mathTokenHighlighting = config.mathTokenHighlighting;
41
+ }
42
+ const options = config.providerOptions;
43
+ if (options && typeof options === "object") {
44
+ const portable = {};
45
+ for (const key of PORTABLE_PROVIDER_OPTIONS) {
46
+ if (options[key] !== undefined)
47
+ portable[key] = options[key];
48
+ }
49
+ if (Object.keys(portable).length > 0)
50
+ fallback.providerOptions = portable;
51
+ }
52
+ return fallback;
53
+ };
54
+ /**
55
+ * The Web Speech API reads SSML tags aloud, so a `<speak>` document is voiced
56
+ * as its spoken text. Boundary offsets still index the text as sent: that is
57
+ * the space the highlight pipeline resolves a raw-SSML chunk in.
58
+ */
59
+ const toUtteranceText = (text) => {
60
+ if (!isSsmlDocument(text)) {
61
+ return { text, sourceOffset: (offset) => offset };
62
+ }
63
+ const { spokenText, rawToSpokenOffsetMap } = extractSpokenText(text);
64
+ const spokenToRaw = [];
65
+ for (const [raw, spoken] of rawToSpokenOffsetMap) {
66
+ const known = spokenToRaw[spoken];
67
+ if (known === undefined || raw < known)
68
+ spokenToRaw[spoken] = raw;
69
+ }
70
+ return {
71
+ text: spokenText,
72
+ // Alias text (`<sub alias>`) has no raw offset; it takes the nearest
73
+ // preceding one.
74
+ sourceOffset: (offset) => {
75
+ for (let index = offset; index >= 0; index--) {
76
+ const raw = spokenToRaw[index];
77
+ if (raw !== undefined)
78
+ return raw;
79
+ }
80
+ return 0;
81
+ },
82
+ };
83
+ };
12
84
  const normalizeLanguageCode = (value) => String(value || "")
13
85
  .trim()
14
86
  .toLowerCase();
@@ -18,19 +90,28 @@ const browserLanguage = () => {
18
90
  : "";
19
91
  return normalizeLanguageCode(navigatorLanguage || "en-US");
20
92
  };
21
- const findBrowserVoice = (voices, preferredVoice) => {
93
+ /**
94
+ * Whether a voice speaks `contentLanguage`, or the browser's own language when
95
+ * the content names none: the same language, or the same primary subtag.
96
+ */
97
+ export const browserVoiceMatchesLanguage = (voice, contentLanguage) => {
98
+ const language = normalizeLanguageCode(contentLanguage) || browserLanguage();
99
+ const languagePrefix = language.split("-")[0] || "en";
100
+ const voiceLanguage = normalizeLanguageCode(voice.lang);
101
+ return (voiceLanguage === language || voiceLanguage.startsWith(`${languagePrefix}-`));
102
+ };
103
+ /**
104
+ * The voice to read with: the configured one, else one for `contentLanguage`
105
+ * (the language of the content being read), else one for the browser's own
106
+ * language.
107
+ */
108
+ export const findBrowserVoice = (voices, preferredVoice, contentLanguage) => {
22
109
  if (preferredVoice) {
23
110
  return (voices.find((voice) => voice.voiceURI === preferredVoice) ||
24
111
  voices.find((voice) => voice.name === preferredVoice) ||
25
112
  null);
26
113
  }
27
- const language = browserLanguage();
28
- const languagePrefix = language.split("-")[0] || "en";
29
- const matchesLanguage = (voice) => {
30
- const voiceLanguage = normalizeLanguageCode(voice.lang);
31
- return (voiceLanguage === language ||
32
- voiceLanguage.startsWith(`${languagePrefix}-`));
33
- };
114
+ const matchesLanguage = (voice) => browserVoiceMatchesLanguage(voice, contentLanguage);
34
115
  const ranked = [
35
116
  (voice) => voice.localService && matchesLanguage(voice),
36
117
  (voice) => voice.default && matchesLanguage(voice),
@@ -45,6 +126,64 @@ const findBrowserVoice = (voices, preferredVoice) => {
45
126
  }
46
127
  return voices[0] || null;
47
128
  };
129
+ /**
130
+ * Waits for `synth` to publish its voice inventory, which browsers fill
131
+ * asynchronously after page load. Resolves `true` once `getVoices()` is
132
+ * non-empty, at once when it already is; `false` after `timeoutMs`, once
133
+ * `isCurrent` reports the wait superseded, or when `synth` offers no
134
+ * `voiceschanged` channel to wait on. A previous `onvoiceschanged` handler
135
+ * keeps running and is restored afterwards.
136
+ */
137
+ export const waitForBrowserVoices = (synth, timeoutMs, isCurrent = () => true) => {
138
+ if (synth.getVoices().length > 0)
139
+ return Promise.resolve(true);
140
+ const canUseEventTarget = typeof synth.addEventListener === "function" &&
141
+ typeof synth.removeEventListener === "function";
142
+ if (!canUseEventTarget && !("onvoiceschanged" in synth)) {
143
+ return Promise.resolve(false);
144
+ }
145
+ return new Promise((resolve) => {
146
+ const previousHandler = canUseEventTarget ? null : synth.onvoiceschanged;
147
+ let settled = false;
148
+ const finish = (voicesAvailable) => {
149
+ if (settled)
150
+ return;
151
+ settled = true;
152
+ clearTimeout(timeout);
153
+ if (canUseEventTarget) {
154
+ synth.removeEventListener("voiceschanged", onVoicesChanged);
155
+ }
156
+ else if (synth.onvoiceschanged === propertyHandler) {
157
+ synth.onvoiceschanged = previousHandler;
158
+ }
159
+ resolve(voicesAvailable);
160
+ };
161
+ const inspectVoiceInventory = () => {
162
+ if (!isCurrent())
163
+ finish(false);
164
+ else if (synth.getVoices().length > 0)
165
+ finish(true);
166
+ };
167
+ const onVoicesChanged = () => inspectVoiceInventory();
168
+ const propertyHandler = (event) => {
169
+ try {
170
+ previousHandler?.call(synth, event);
171
+ }
172
+ finally {
173
+ inspectVoiceInventory();
174
+ }
175
+ };
176
+ const timeout = setTimeout(() => finish(false), timeoutMs);
177
+ if (canUseEventTarget) {
178
+ synth.addEventListener("voiceschanged", onVoicesChanged);
179
+ }
180
+ else {
181
+ synth.onvoiceschanged = propertyHandler;
182
+ }
183
+ // Close the getVoices()/listener-registration race without polling.
184
+ inspectVoiceInventory();
185
+ });
186
+ };
48
187
  const shouldAssignBrowserVoice = (voice) => !voice.default;
49
188
  /**
50
189
  * Browser TTS Provider
@@ -64,17 +203,20 @@ export class BrowserTTSProvider {
64
203
  if (!("speechSynthesis" in window)) {
65
204
  throw new Error("Browser does not support Speech Synthesis API");
66
205
  }
206
+ // Only a configured voice needs the inventory; waiting otherwise would
207
+ // hold readiness on a browser that publishes no voices.
208
+ if (typeof config.voice === "string" && config.voice.trim()) {
209
+ await waitForBrowserVoices(window.speechSynthesis, VOICE_INVENTORY_TIMEOUT_MS);
210
+ }
67
211
  return new BrowserTTSProviderImpl(config);
68
212
  }
69
- supportsFeature(feature) {
70
- // Browser supports all TTS features
71
- return true;
72
- }
73
213
  getCapabilities() {
74
214
  return {
75
215
  supportsPause: true,
76
216
  supportsResume: true,
77
217
  supportsWordBoundary: true,
218
+ // Boundary events depend on the voice: several network voices send none.
219
+ defaultHighlightMode: "sentence",
78
220
  supportsVoiceSelection: true,
79
221
  supportsRateControl: true,
80
222
  supportsPitchControl: true,
@@ -95,99 +237,45 @@ class BrowserTTSProviderImpl {
95
237
  utterance = null;
96
238
  config = null;
97
239
  _isPlaying = false;
240
+ // The run's pause, which outlasts the utterance it paused: a pause between
241
+ // utterances holds the next one until resume.
98
242
  _isPaused = false;
99
243
  speakRunId = 0;
100
- settlePendingVoiceWait = null;
244
+ // The run speaking now, between its utterances as well as during them.
245
+ activeRunId = null;
246
+ releasePauseHold = null;
101
247
  settlePendingChunk = null;
248
+ settlePendingGap = null;
102
249
  onPlaybackStart = undefined;
103
250
  constructor(config) {
104
251
  this.config = config;
105
252
  }
106
- waitForBrowserVoices(runId, configuredVoice) {
107
- return new Promise((resolve, reject) => {
108
- const synth = speechSynthesis;
109
- const canUseEventTarget = typeof synth.addEventListener === "function" &&
110
- typeof synth.removeEventListener === "function";
111
- const previousHandler = canUseEventTarget ? null : synth.onvoiceschanged;
112
- let settled = false;
113
- let timeout = null;
114
- let pendingSettlement = null;
115
- const cleanup = () => {
116
- if (timeout !== null) {
117
- clearTimeout(timeout);
118
- timeout = null;
119
- }
120
- if (canUseEventTarget) {
121
- synth.removeEventListener("voiceschanged", onVoicesChanged);
122
- }
123
- else if (synth.onvoiceschanged === propertyHandler) {
124
- synth.onvoiceschanged = previousHandler;
125
- }
126
- if (pendingSettlement &&
127
- this.settlePendingVoiceWait === pendingSettlement) {
128
- this.settlePendingVoiceWait = null;
129
- }
130
- };
131
- const finish = (voicesAvailable, error) => {
132
- if (settled)
133
- return;
134
- settled = true;
135
- cleanup();
136
- if (error) {
137
- reject(error);
138
- }
139
- else {
140
- resolve(voicesAvailable);
141
- }
142
- };
143
- const inspectVoiceInventory = () => {
144
- if (runId !== this.speakRunId) {
145
- finish(false);
146
- return;
147
- }
148
- if (synth.getVoices().length > 0)
149
- finish(true);
150
- };
151
- const onVoicesChanged = () => inspectVoiceInventory();
152
- const propertyHandler = (event) => {
153
- try {
154
- previousHandler?.call(synth, event);
155
- }
156
- finally {
157
- inspectVoiceInventory();
158
- }
159
- };
160
- pendingSettlement = () => finish(false);
161
- this.settlePendingVoiceWait = pendingSettlement;
162
- timeout = setTimeout(() => finish(false, new Error(`Configured browser voice "${configuredVoice}" could not be resolved because the browser did not publish its voice inventory within ${VOICE_INVENTORY_TIMEOUT_MS / 1_000} seconds.`)), VOICE_INVENTORY_TIMEOUT_MS);
163
- if (canUseEventTarget) {
164
- synth.addEventListener("voiceschanged", onVoicesChanged);
165
- }
166
- else {
167
- synth.onvoiceschanged = propertyHandler;
168
- }
169
- // Close the getVoices()/listener-registration race without polling.
170
- inspectVoiceInventory();
171
- });
253
+ /** Whether run `runId` is still current once any pause between utterances ends. */
254
+ async waitWhilePaused(runId) {
255
+ while (this._isPaused && runId === this.speakRunId) {
256
+ await new Promise((resolve) => {
257
+ this.releasePauseHold = resolve;
258
+ });
259
+ }
260
+ return runId === this.speakRunId;
261
+ }
262
+ releaseHold() {
263
+ const release = this.releasePauseHold;
264
+ this.releasePauseHold = null;
265
+ release?.();
172
266
  }
173
- async resolveBrowserVoice(runId) {
267
+ /** The voice to read with; `initialize()` already waited for the inventory. */
268
+ resolveBrowserVoice() {
174
269
  const configuredVoice = typeof this.config?.voice === "string" ? this.config.voice.trim() : "";
175
- let voices = speechSynthesis.getVoices();
270
+ const voices = speechSynthesis.getVoices();
176
271
  if (configuredVoice && voices.length === 0) {
177
- const voicesAvailable = await this.waitForBrowserVoices(runId, configuredVoice);
178
- if (!voicesAvailable || runId !== this.speakRunId) {
179
- return { shouldContinue: false, voice: null };
180
- }
181
- voices = speechSynthesis.getVoices();
182
- }
183
- if (runId !== this.speakRunId) {
184
- return { shouldContinue: false, voice: null };
272
+ throw new Error(`Configured browser voice "${configuredVoice}" could not be resolved because the browser did not publish its voice inventory within ${VOICE_INVENTORY_TIMEOUT_MS / 1_000} seconds.`);
185
273
  }
186
- const voice = findBrowserVoice(voices, configuredVoice || undefined);
274
+ const voice = findBrowserVoice(voices, configuredVoice || undefined, this.getContentLocale());
187
275
  if (configuredVoice && !voice) {
188
276
  throw new Error(`Configured browser voice "${configuredVoice}" is unavailable. Select a voice exposed by this browser using its voiceURI or name.`);
189
277
  }
190
- return { shouldContinue: true, voice };
278
+ return voice;
191
279
  }
192
280
  async speak(text) {
193
281
  if (!this.config) {
@@ -196,19 +284,24 @@ class BrowserTTSProviderImpl {
196
284
  // Invalidate any in-flight run and cancel current utterance.
197
285
  this.stop();
198
286
  const runId = this.speakRunId;
199
- const voiceResolution = await this.resolveBrowserVoice(runId);
200
- if (!voiceResolution.shouldContinue)
201
- return;
202
- const chunks = this.splitIntoChunks(text);
203
- for (const chunk of chunks) {
204
- if (runId !== this.speakRunId) {
205
- break;
206
- }
207
- const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voiceResolution.voice);
208
- if (!shouldContinue) {
209
- break;
287
+ this.activeRunId = runId;
288
+ try {
289
+ const voice = this.resolveBrowserVoice();
290
+ const utterance = toUtteranceText(text);
291
+ const chunks = this.splitIntoChunks(utterance.text);
292
+ for (const chunk of chunks) {
293
+ if (this._isPaused && !(await this.waitWhilePaused(runId)))
294
+ break;
295
+ const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voice, utterance.sourceOffset);
296
+ if (!shouldContinue) {
297
+ break;
298
+ }
210
299
  }
211
300
  }
301
+ finally {
302
+ if (this.activeRunId === runId)
303
+ this.activeRunId = null;
304
+ }
212
305
  }
213
306
  async speakSegments(segments) {
214
307
  if (!this.config) {
@@ -216,30 +309,45 @@ class BrowserTTSProviderImpl {
216
309
  }
217
310
  this.stop();
218
311
  const runId = this.speakRunId;
219
- const voiceResolution = await this.resolveBrowserVoice(runId);
220
- if (!voiceResolution.shouldContinue)
221
- return;
222
- for (const segment of segments) {
223
- if (runId !== this.speakRunId)
224
- break;
225
- const chunks = this.splitIntoChunks(segment.text);
226
- for (const chunk of chunks) {
312
+ this.activeRunId = runId;
313
+ try {
314
+ const voice = this.resolveBrowserVoice();
315
+ for (const segment of segments) {
227
316
  if (runId !== this.speakRunId)
228
317
  break;
229
- const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId, voiceResolution.voice);
230
- if (!shouldContinue)
231
- break;
232
- }
233
- const pauseMsAfter = Math.max(0, Number(segment.pauseMsAfter || 0));
234
- if (pauseMsAfter > 0 && runId === this.speakRunId) {
235
- const shouldContinue = await this.waitForPause(pauseMsAfter, runId);
236
- if (!shouldContinue)
237
- break;
318
+ const utterance = toUtteranceText(segment.text);
319
+ const chunks = this.splitIntoChunks(utterance.text);
320
+ for (const chunk of chunks) {
321
+ if (this._isPaused && !(await this.waitWhilePaused(runId)))
322
+ break;
323
+ const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voice, (offset) => segment.startOffset + utterance.sourceOffset(offset));
324
+ if (!shouldContinue)
325
+ break;
326
+ }
327
+ const pauseMsAfter = Math.max(0, Number(segment.pauseMsAfter || 0));
328
+ if (pauseMsAfter > 0 && runId === this.speakRunId) {
329
+ const shouldContinue = await this.waitForPause(pauseMsAfter, runId);
330
+ if (!shouldContinue)
331
+ break;
332
+ }
238
333
  }
239
334
  }
335
+ finally {
336
+ if (this.activeRunId === runId)
337
+ this.activeRunId = null;
338
+ }
240
339
  }
241
340
  async waitForPause(pauseMs, runId) {
242
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
341
+ await new Promise((resolve) => {
342
+ const settle = () => {
343
+ clearTimeout(timer);
344
+ if (this.settlePendingGap === settle)
345
+ this.settlePendingGap = null;
346
+ resolve();
347
+ };
348
+ const timer = setTimeout(settle, pauseMs);
349
+ this.settlePendingGap = settle;
350
+ });
243
351
  return runId === this.speakRunId;
244
352
  }
245
353
  splitIntoChunks(text) {
@@ -277,13 +385,19 @@ class BrowserTTSProviderImpl {
277
385
  }
278
386
  return chunks.length ? chunks : [{ text, offset: 0 }];
279
387
  }
280
- getHighlightMode() {
281
- const providerOptions = (this.config?.providerOptions || {});
282
- return providerOptions.highlightMode === "word" ? "word" : "sentence";
388
+ /**
389
+ * The language of the content being read, which the toolkit sets per speak as
390
+ * `providerOptions.contentLanguage` when the content or the host names one.
391
+ */
392
+ getContentLocale() {
393
+ const language = this.config?.providerOptions?.contentLanguage;
394
+ return typeof language === "string" && language.trim()
395
+ ? language.trim()
396
+ : undefined;
283
397
  }
284
398
  getSegmentationPolicy() {
285
- const providerOptions = (this.config?.providerOptions || {});
286
- const segmenter = (providerOptions.segmenter || {});
399
+ const providerOptions = this.config?.providerOptions || {};
400
+ const segmenter = providerOptions.segmenter || {};
287
401
  const mode = segmenter.mode;
288
402
  const useSegmenter = mode !== "regexOnly";
289
403
  const locale = typeof segmenter.locale === "string" && segmenter.locale.trim().length > 0
@@ -311,7 +425,11 @@ class BrowserTTSProviderImpl {
311
425
  const match = slice.match(/^\s*([^\s]+)/);
312
426
  return match?.[1]?.length || 1;
313
427
  }
314
- async speakChunk(chunkText, chunkOffset, runId, voice) {
428
+ /**
429
+ * `chunkOffset` places the chunk in the utterance text; `sourceOffset` maps an
430
+ * utterance offset to the boundary position reported for it.
431
+ */
432
+ async speakChunk(chunkText, chunkOffset, runId, voice, sourceOffset) {
315
433
  return new Promise((resolve, reject) => {
316
434
  if (runId !== this.speakRunId) {
317
435
  resolve(false);
@@ -319,7 +437,11 @@ class BrowserTTSProviderImpl {
319
437
  }
320
438
  const utterance = new SpeechSynthesisUtterance(chunkText);
321
439
  this.utterance = utterance;
322
- // Apply config
440
+ // Apply config. The language tells the engine how to read the text, and
441
+ // lets it choose a voice when the default one is left in place.
442
+ const contentLocale = this.getContentLocale();
443
+ if (contentLocale)
444
+ utterance.lang = contentLocale;
323
445
  if (voice && shouldAssignBrowserVoice(voice))
324
446
  utterance.voice = voice;
325
447
  if (this.config?.rate)
@@ -362,7 +484,6 @@ class BrowserTTSProviderImpl {
362
484
  }
363
485
  if (runId === this.speakRunId) {
364
486
  this._isPlaying = false;
365
- this._isPaused = false;
366
487
  }
367
488
  if (error) {
368
489
  reject(error);
@@ -384,7 +505,9 @@ class BrowserTTSProviderImpl {
384
505
  didStart = true;
385
506
  clearStartTimeout();
386
507
  this._isPlaying = true;
387
- this._isPaused = false;
508
+ // A pause that landed while the engine was starting holds this utterance.
509
+ if (this._isPaused)
510
+ speechSynthesis.pause();
388
511
  try {
389
512
  this.onPlaybackStart?.();
390
513
  }
@@ -427,12 +550,9 @@ class BrowserTTSProviderImpl {
427
550
  utterance.onboundary = (event) => {
428
551
  if (runId !== this.speakRunId)
429
552
  return;
430
- console.log("[BrowserProvider] Boundary event:", event.name, "charIndex:", event.charIndex, "charLength:", event.charLength);
553
+ logger.debug(`boundary event: ${event.name}, charIndex ${event.charIndex}, charLength ${event.charLength}`);
431
554
  if (event.name !== "word" || !this.onWordBoundary)
432
555
  return;
433
- if (this.getHighlightMode() === "sentence") {
434
- return;
435
- }
436
556
  const charIndex = Math.max(0, Math.min(event.charIndex, Math.max(0, chunkText.length - 1)));
437
557
  const reportedLength = Number(event.charLength || 0);
438
558
  const inferredLength = this.inferWordLength(chunkText, charIndex);
@@ -445,9 +565,13 @@ class BrowserTTSProviderImpl {
445
565
  const word = chunkText
446
566
  .substring(charIndex, Math.min(chunkText.length, charIndex + wordLength))
447
567
  .trim();
448
- const absoluteBoundaryStart = chunkOffset + charIndex;
449
- console.log("[BrowserProvider] Calling onWordBoundary with word:", word, "at position:", absoluteBoundaryStart);
450
- this.onWordBoundary(word, absoluteBoundaryStart, wordLength);
568
+ const utteranceStart = chunkOffset + charIndex;
569
+ const absoluteBoundaryStart = sourceOffset(utteranceStart);
570
+ const boundaryLength = sourceOffset(utteranceStart + wordLength - 1) +
571
+ 1 -
572
+ absoluteBoundaryStart;
573
+ logger.debug(`word boundary "${word}" at ${absoluteBoundaryStart}`);
574
+ this.onWordBoundary(word, absoluteBoundaryStart, boundaryLength);
451
575
  };
452
576
  startTimeout = setTimeout(() => {
453
577
  if (settled || didStart || runId !== this.speakRunId)
@@ -466,27 +590,39 @@ class BrowserTTSProviderImpl {
466
590
  });
467
591
  }
468
592
  pause() {
469
- if (this._isPlaying && !this._isPaused) {
593
+ if (this._isPaused)
594
+ return;
595
+ if (this._isPlaying) {
470
596
  speechSynthesis.pause();
597
+ this._isPaused = true;
598
+ }
599
+ else if (this.activeRunId === this.speakRunId) {
600
+ // Between utterances, or in a structural gap: the next one holds.
601
+ this._isPaused = true;
471
602
  }
472
603
  }
473
604
  resume() {
474
- if (this._isPlaying && this._isPaused) {
605
+ if (!this._isPaused)
606
+ return;
607
+ this._isPaused = false;
608
+ if (this._isPlaying)
475
609
  speechSynthesis.resume();
476
- }
610
+ this.releaseHold();
477
611
  }
478
612
  stop() {
479
613
  const utterance = this.utterance;
480
614
  const shouldCancel = this._isPlaying || utterance !== null;
481
615
  this.speakRunId += 1;
482
- this.settlePendingVoiceWait?.();
616
+ this.activeRunId = null;
483
617
  this.settlePendingChunk?.();
618
+ this.settlePendingGap?.();
484
619
  if (shouldCancel) {
485
620
  speechSynthesis.cancel();
486
621
  }
487
622
  this.utterance = null;
488
623
  this._isPlaying = false;
489
624
  this._isPaused = false;
625
+ this.releaseHold();
490
626
  }
491
627
  isPlaying() {
492
628
  return this._isPlaying && !this._isPaused;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The tree read-aloud walks: the flat tree, which is the tree the page renders.
3
+ *
4
+ * An element with an open shadow root contributes that root's children in place
5
+ * of its own light children, and a `<slot>` inside a shadow root contributes the
6
+ * nodes assigned to it, flattened through nested slots, so a host's light
7
+ * children are reached once, at the slot that renders them. Closed shadow roots
8
+ * are invisible to script, so their hosts are walked as light DOM.
9
+ *
10
+ * Toolkit chrome is skipped wherever it appears: toolbars and tool elements
11
+ * render their controls into open shadow roots, and descending into them would
12
+ * read button labels as content. So are the empty `aria-hidden` anchors the
13
+ * shells and the toolkit keep beside their slots, and elements whose text never
14
+ * renders (`style`, `script`, `noscript`, `template`).
15
+ *
16
+ * Node types are compared as the spec's numbers, so this module reads no DOM
17
+ * global, at import or at call time.
18
+ */
19
+ export declare const isShadowRootNode: (node: Node | null | undefined) => node is ShadowRoot;
20
+ /** Toolbars, tool elements and debug panels: chrome around content, never content. */
21
+ export declare const isToolkitChromeElement: (element: Element) => boolean;
22
+ /** `node`'s children in the flat tree, with skipped elements left out. */
23
+ export declare const flatTreeChildNodes: (node: Node) => Node[];
24
+ /**
25
+ * What a visitor returns: `"skip"` leaves the node's subtree out, `"stop"` ends
26
+ * the walk, anything else descends.
27
+ */
28
+ export type FlatTreeVisitResult = "skip" | "stop" | undefined;
29
+ /** Visits `root`'s flat-tree descendants in rendering order; `root` itself is not visited. */
30
+ export declare const walkFlatTree: (root: Node, visit: (node: Node) => FlatTreeVisitResult) => void;
31
+ /** `root`'s text nodes in rendering order. */
32
+ export declare const flatTreeTextNodes: (root: Node) => Text[];
33
+ /** `textContent` over the flat tree: shadow content in, skipped elements out. */
34
+ export declare const flatTextContent: (root: Node) => string;
35
+ /** Elements under `root` matching `selector`, in rendering order. */
36
+ export declare const flatQuerySelectorAll: (root: Node, selector: string) => Element[];
37
+ export declare const flatQuerySelector: (root: Node, selector: string) => Element | null;
38
+ /**
39
+ * `node`'s parent in the flat tree: the slot rendering it, else its parent, with
40
+ * a shadow root standing for its host.
41
+ */
42
+ export declare const flatTreeParentNode: (node: Node) => Node | null;
43
+ export declare const flatTreeParentElement: (node: Node) => Element | null;
44
+ /** The nearest element at or above `node` in the flat tree that matches `selector`. */
45
+ export declare const flatTreeClosest: (node: Node, selector: string) => Element | null;
46
+ /**
47
+ * `node`'s parent element, continuing from a shadow root to its host. This is the
48
+ * chain inheritance follows (`lang`, regions an author marks up), and for slotted
49
+ * content it stays in the light tree the author wrote.
50
+ */
51
+ export declare const composedParentElement: (node: Node) => Element | null;
52
+ /**
53
+ * The nearest element at or above `node` on the composed chain that matches
54
+ * `selector`, or null. With `boundary`, the climb ends after testing it.
55
+ */
56
+ export declare const composedClosest: (node: Node, selector: string, boundary?: Element | null) => Element | null;
57
+ /** `contains` across shadow boundaries: whether `node` is `container` or below it. */
58
+ export declare const composedContains: (container: Node | null | undefined, node: Node | null | undefined) => boolean;
59
+ /**
60
+ * `node`, or the shadow host above it, that lies in the tree rooted at
61
+ * `treeRoot`; null when `node` is not inside that tree at any depth.
62
+ */
63
+ export declare const retargetToTree: (node: Node, treeRoot: Node) => Node | null;
64
+ /**
65
+ * Whether the character at `offset` in `node` lies inside `range`. A live range
66
+ * holds nodes of one tree only, so a node in a shadow tree below it counts as
67
+ * inside when the range holds that tree's host whole.
68
+ */
69
+ export declare const rangeHoldsTextPosition: (range: Range, node: Text, offset: number) => boolean;
70
+ /** Whether `range` intersects `node`, counting a shadow tree below the range as {@link rangeHoldsTextPosition} does. */
71
+ export declare const rangeIntersectsComposedNode: (range: Range, node: Node) => boolean;