@pie-players/pie-assessment-toolkit 0.3.74 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/README.md +658 -714
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +1 -21
  4. package/dist/attempt/TestSession.js +16 -102
  5. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  6. package/dist/components/ItemScope.custom-element.js +1 -0
  7. package/dist/components/ItemToolBar.custom-element.js +1 -1
  8. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-3zrc4p3d.js +4 -0
  11. package/dist/components/chunks/ItemToolBar-66hcwmfw.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  13. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  14. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-kkj69ptr.js} +1 -1
  15. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  16. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  17. package/dist/components/item-scope-element.d.ts +2 -0
  18. package/dist/components/item-scope-element.js +2 -0
  19. package/dist/context/assessment-toolkit-context.d.ts +33 -6
  20. package/dist/index.d.ts +25 -50
  21. package/dist/index.js +15 -45
  22. package/dist/policy/core/PolicySource.d.ts +2 -2
  23. package/dist/policy/core/ToolPolicyEngine.d.ts +118 -34
  24. package/dist/policy/core/ToolPolicyEngine.js +203 -56
  25. package/dist/policy/core/compose-decision.d.ts +28 -7
  26. package/dist/policy/core/compose-decision.js +95 -74
  27. package/dist/policy/core/decision-types.d.ts +98 -55
  28. package/dist/policy/core/feature-decision.d.ts +28 -19
  29. package/dist/policy/core/feature-decision.js +15 -11
  30. package/dist/policy/core/pnp-policy-inputs.d.ts +29 -35
  31. package/dist/policy/core/pnp-policy-inputs.js +40 -88
  32. package/dist/policy/core/provenance.d.ts +10 -5
  33. package/dist/policy/core/provenance.js +1 -1
  34. package/dist/policy/core/tool-parameters.d.ts +11 -0
  35. package/dist/policy/core/tool-parameters.js +15 -0
  36. package/dist/policy/engine.d.ts +7 -23
  37. package/dist/policy/engine.js +5 -20
  38. package/dist/policy/sources/PnpPolicySource.d.ts +43 -68
  39. package/dist/policy/sources/PnpPolicySource.js +133 -132
  40. package/dist/runtime/SectionControllerBinding.d.ts +283 -0
  41. package/dist/runtime/SectionControllerBinding.js +456 -0
  42. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  43. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  44. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  45. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  46. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  47. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  48. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -3
  49. package/dist/runtime/adapter/subscriber-fanout.js +2 -6
  50. package/dist/runtime/catalog-context.d.ts +19 -0
  51. package/dist/runtime/catalog-context.js +28 -0
  52. package/dist/runtime/composition-emit-scheduler.d.ts +12 -18
  53. package/dist/runtime/composition-emit-scheduler.js +10 -16
  54. package/dist/runtime/content-language.d.ts +32 -0
  55. package/dist/runtime/content-language.js +46 -0
  56. package/dist/runtime/content-region.d.ts +13 -0
  57. package/dist/runtime/content-region.js +29 -0
  58. package/dist/runtime/content-registration.d.ts +13 -0
  59. package/dist/runtime/content-registration.js +25 -0
  60. package/dist/runtime/core/SectionEngineCore.d.ts +2 -4
  61. package/dist/runtime/core/SectionEngineCore.js +2 -6
  62. package/dist/runtime/core/cohort.d.ts +1 -6
  63. package/dist/runtime/core/cohort.js +1 -10
  64. package/dist/runtime/core/engine-input.d.ts +18 -30
  65. package/dist/runtime/core/engine-input.js +11 -12
  66. package/dist/runtime/core/engine-output.d.ts +17 -40
  67. package/dist/runtime/core/engine-output.js +9 -21
  68. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  69. package/dist/runtime/core/engine-readiness.js +8 -41
  70. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  71. package/dist/runtime/core/engine-resolver.js +18 -43
  72. package/dist/runtime/core/engine-state.d.ts +28 -36
  73. package/dist/runtime/core/engine-state.js +18 -21
  74. package/dist/runtime/core/engine-transition.d.ts +14 -19
  75. package/dist/runtime/core/engine-transition.js +74 -53
  76. package/dist/runtime/engine.d.ts +16 -19
  77. package/dist/runtime/engine.js +12 -19
  78. package/dist/runtime/page-warnings.d.ts +13 -0
  79. package/dist/runtime/page-warnings.js +22 -0
  80. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  81. package/dist/runtime/policy-input-forwarding.js +25 -0
  82. package/dist/runtime/registration-events.d.ts +22 -14
  83. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  84. package/dist/runtime/runtime-event-claim.js +15 -0
  85. package/dist/runtime/runtime-id.d.ts +1 -1
  86. package/dist/runtime/runtime-id.js +1 -1
  87. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  88. package/dist/runtime/shell-event-bridge.js +127 -0
  89. package/dist/runtime/shell-registration.d.ts +73 -0
  90. package/dist/runtime/shell-registration.js +95 -0
  91. package/dist/runtime/shell-scope.d.ts +68 -0
  92. package/dist/runtime/shell-scope.js +194 -0
  93. package/dist/runtime/tool-host-contract.d.ts +8 -14
  94. package/dist/runtime/tool-host-contract.js +12 -12
  95. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  96. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  97. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  98. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  99. package/dist/services/ElementToolStateStore.d.ts +42 -67
  100. package/dist/services/ElementToolStateStore.js +65 -71
  101. package/dist/services/HighlightCoordinator.d.ts +28 -40
  102. package/dist/services/HighlightCoordinator.js +459 -381
  103. package/dist/services/RangeSerializer.d.ts +11 -33
  104. package/dist/services/RangeSerializer.js +88 -59
  105. package/dist/services/SSMLExtractor.d.ts +8 -31
  106. package/dist/services/SSMLExtractor.js +18 -40
  107. package/dist/services/TTSService.d.ts +159 -119
  108. package/dist/services/TTSService.js +937 -822
  109. package/dist/services/ToolCoordinator.d.ts +42 -102
  110. package/dist/services/ToolCoordinator.js +178 -256
  111. package/dist/services/ToolRegistry.d.ts +152 -133
  112. package/dist/services/ToolRegistry.js +192 -178
  113. package/dist/services/ToolkitCoordinator.d.ts +233 -237
  114. package/dist/services/ToolkitCoordinator.js +615 -557
  115. package/dist/services/audio-handoff.d.ts +6 -7
  116. package/dist/services/audio-handoff.js +20 -14
  117. package/dist/services/catalog-owner.d.ts +0 -1
  118. package/dist/services/framework-error-bus.d.ts +3 -6
  119. package/dist/services/framework-error-bus.js +3 -6
  120. package/dist/services/framework-error.d.ts +51 -13
  121. package/dist/services/framework-error.js +19 -20
  122. package/dist/services/interfaces.d.ts +189 -184
  123. package/dist/services/section-controller-types.d.ts +7 -7
  124. package/dist/services/section-session-assignment.d.ts +21 -0
  125. package/dist/services/section-session-assignment.js +80 -0
  126. package/dist/services/spoken-audio-cards.d.ts +2 -6
  127. package/dist/services/spoken-audio-cards.js +2 -6
  128. package/dist/services/tool-config-defaults.d.ts +3 -3
  129. package/dist/services/tool-config-defaults.js +3 -3
  130. package/dist/services/tool-config-validation.d.ts +9 -12
  131. package/dist/services/tool-config-validation.js +47 -87
  132. package/dist/services/tool-context.d.ts +7 -31
  133. package/dist/services/tool-context.js +99 -65
  134. package/dist/services/tool-failure.d.ts +30 -0
  135. package/dist/services/tool-failure.js +11 -0
  136. package/dist/services/tool-icons.d.ts +5 -5
  137. package/dist/services/tool-icons.js +6 -5
  138. package/dist/services/tool-instance-id.d.ts +8 -7
  139. package/dist/services/tool-instance-id.js +12 -24
  140. package/dist/services/tool-providers/TTSToolProvider.d.ts +20 -58
  141. package/dist/services/tool-providers/TTSToolProvider.js +32 -85
  142. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -11
  143. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +29 -60
  144. package/dist/services/tool-providers/ToolProviderRegistry.js +128 -144
  145. package/dist/services/tool-request.d.ts +31 -12
  146. package/dist/services/tool-request.js +31 -16
  147. package/dist/services/toolbar-decided-tools.d.ts +16 -0
  148. package/dist/services/toolbar-decided-tools.js +29 -0
  149. package/dist/services/toolbar-items.js +2 -5
  150. package/dist/services/tools-config-normalizer.d.ts +33 -29
  151. package/dist/services/tools-config-normalizer.js +26 -28
  152. package/dist/services/tts/browser-provider.d.ts +39 -6
  153. package/dist/services/tts/browser-provider.js +280 -160
  154. package/dist/services/tts/catalog-span-alignment.js +14 -7
  155. package/dist/services/tts/flat-tree.d.ts +64 -0
  156. package/dist/services/tts/flat-tree.js +269 -0
  157. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  158. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  159. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  160. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  161. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  162. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  163. package/dist/services/tts/generated-speech/index.js +1 -1
  164. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  165. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  166. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  167. package/dist/services/tts/generated-speech/types.js +1 -1
  168. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +22 -16
  169. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  170. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  171. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  172. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  173. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  174. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +42 -47
  177. package/dist/services/tts/math-control-names.d.ts +21 -0
  178. package/dist/services/tts/math-control-names.js +142 -0
  179. package/dist/services/tts/math-speech.d.ts +12 -1
  180. package/dist/services/tts/math-speech.js +54 -18
  181. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  182. package/dist/services/tts/mathml-sanitization.js +9 -1
  183. package/dist/services/tts/provider-options.d.ts +52 -0
  184. package/dist/services/tts/provider-options.js +1 -0
  185. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  186. package/dist/services/tts/ssml/spoken-text.js +29 -0
  187. package/dist/services/tts/start-failure.d.ts +4 -0
  188. package/dist/services/tts/start-failure.js +19 -0
  189. package/dist/services/tts/text-processing.d.ts +9 -0
  190. package/dist/services/tts/text-processing.js +46 -33
  191. package/dist/services/tts/text-segmentation.d.ts +12 -0
  192. package/dist/services/tts/text-segmentation.js +60 -10
  193. package/dist/services/tts-runtime-config.d.ts +54 -26
  194. package/dist/services/tts-runtime-config.js +27 -112
  195. package/dist/tools/content-capability-resolution.d.ts +9 -11
  196. package/dist/tools/content-capability-resolution.js +8 -45
  197. package/dist/tools/registration.d.ts +59 -0
  198. package/dist/tools/registration.js +55 -0
  199. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  200. package/dist/tools/registrations/toolbar-registration-helpers.js +8 -2
  201. package/dist/tools/tool-surface-host.d.ts +6 -10
  202. package/dist/tools/tool-surface-host.js +45 -50
  203. package/dist/tools/tool-tag-map.d.ts +5 -15
  204. package/dist/tools/tool-tag-map.js +6 -20
  205. package/dist/utils/structural-equality.d.ts +11 -0
  206. package/dist/utils/structural-equality.js +47 -0
  207. package/package.json +15 -29
  208. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  210. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  211. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  212. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  213. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  214. package/dist/context/runtime-context-consumer.d.ts +0 -13
  215. package/dist/context/runtime-context-consumer.js +0 -18
  216. package/dist/item-loader.d.ts +0 -44
  217. package/dist/item-loader.js +0 -61
  218. package/dist/policy/core/engine-context.d.ts +0 -29
  219. package/dist/policy/core/engine-context.js +0 -27
  220. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  221. package/dist/policy/core/policy-source-tag.js +0 -28
  222. package/dist/policy/internal.d.ts +0 -25
  223. package/dist/policy/internal.js +0 -25
  224. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  225. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  226. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  227. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  228. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  229. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  230. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  231. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  232. package/dist/runtime/engine-context.d.ts +0 -39
  233. package/dist/runtime/engine-context.js +0 -31
  234. package/dist/runtime/internal.d.ts +0 -43
  235. package/dist/runtime/internal.js +0 -45
  236. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  237. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  238. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  239. package/dist/runtime/stage-emit-gate.js +0 -82
  240. package/dist/services/ContextVariableStore.d.ts +0 -113
  241. package/dist/services/ContextVariableStore.js +0 -206
  242. package/dist/services/I18nService.d.ts +0 -47
  243. package/dist/services/I18nService.js +0 -84
  244. package/dist/services/ThemeProvider.d.ts +0 -95
  245. package/dist/services/ThemeProvider.js +0 -334
  246. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  247. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  248. package/dist/services/pnp-standard-features.d.ts +0 -238
  249. package/dist/services/pnp-standard-features.js +0 -338
  250. package/dist/services/tts-control-events.d.ts +0 -13
  251. package/dist/services/tts-control-events.js +0 -7
  252. package/dist/tools/client.d.ts +0 -10
  253. package/dist/tools/client.js +0 -10
  254. package/dist/tools/internal.d.ts +0 -44
  255. package/dist/tools/internal.js +0 -39
  256. package/dist/utils/logger.d.ts +0 -6
  257. package/dist/utils/logger.js +0 -10
@@ -3,12 +3,85 @@
3
3
  *
4
4
  * Text-to-speech provider using the browser's Web Speech API.
5
5
  * Works in all modern browsers that support SpeechSynthesis.
6
- *
7
- * Part of PIE Assessment Toolkit.
8
6
  */
7
+ import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
8
+ import { extractSpokenText, isSsmlDocument } from "./ssml/spoken-text.js";
9
9
  import { segmentSentences as segmentTextToSentences } from "./text-segmentation.js";
10
+ const logger = createPieLogger("browser-tts-provider", isTtsDebugEnabled);
10
11
  const NATIVE_START_TIMEOUT_MS = 5_000;
11
12
  const VOICE_INVENTORY_TIMEOUT_MS = 2_000;
13
+ const MAX_CHUNK_LENGTH = 260;
14
+ // A longer reported word length falls back to the inferred one.
15
+ const MAX_TRUSTED_REPORTED_WORD_LENGTH = 80;
16
+ // The provider options the toolkit and this provider read. Every other option,
17
+ // and `voice`, belongs to the provider it was configured for: a Polly voice such
18
+ // as "Joanna" resolves to no browser voice, and an unresolved voice fails speak.
19
+ const PORTABLE_PROVIDER_OPTIONS = [
20
+ "__pieTelemetry",
21
+ "contentLanguage",
22
+ "highlightMode",
23
+ "locale",
24
+ "mathSpeech",
25
+ "segmenter",
26
+ "structuralPauses",
27
+ "textNormalization",
28
+ ];
29
+ /**
30
+ * The part of another provider's configuration that keeps its meaning on
31
+ * browser speech. Every path that replaces a provider with browser speech
32
+ * starts it with this.
33
+ */
34
+ export const browserFallbackConfig = (config) => {
35
+ const fallback = {};
36
+ if (config.rate !== undefined)
37
+ fallback.rate = config.rate;
38
+ if (config.pitch !== undefined)
39
+ fallback.pitch = config.pitch;
40
+ if (config.mathTokenHighlighting !== undefined) {
41
+ fallback.mathTokenHighlighting = config.mathTokenHighlighting;
42
+ }
43
+ const options = config.providerOptions;
44
+ if (options && typeof options === "object") {
45
+ const portable = {};
46
+ for (const key of PORTABLE_PROVIDER_OPTIONS) {
47
+ if (options[key] !== undefined)
48
+ portable[key] = options[key];
49
+ }
50
+ if (Object.keys(portable).length > 0)
51
+ fallback.providerOptions = portable;
52
+ }
53
+ return fallback;
54
+ };
55
+ /**
56
+ * The Web Speech API reads SSML tags aloud, so a `<speak>` document is voiced
57
+ * as its spoken text. Boundary offsets still index the text as sent: that is
58
+ * the space the highlight pipeline resolves a raw-SSML chunk in.
59
+ */
60
+ const toUtteranceText = (text) => {
61
+ if (!isSsmlDocument(text)) {
62
+ return { text, sourceOffset: (offset) => offset };
63
+ }
64
+ const { spokenText, rawToSpokenOffsetMap } = extractSpokenText(text);
65
+ const spokenToRaw = [];
66
+ for (const [raw, spoken] of rawToSpokenOffsetMap) {
67
+ const known = spokenToRaw[spoken];
68
+ if (known === undefined || raw < known)
69
+ spokenToRaw[spoken] = raw;
70
+ }
71
+ return {
72
+ text: spokenText,
73
+ // Alias text (`<sub alias>`) has no raw offset; it takes the nearest
74
+ // preceding one.
75
+ sourceOffset: (offset) => {
76
+ for (let index = offset; index >= 0; index--) {
77
+ const raw = spokenToRaw[index];
78
+ if (raw !== undefined)
79
+ return raw;
80
+ }
81
+ return 0;
82
+ },
83
+ };
84
+ };
12
85
  const normalizeLanguageCode = (value) => String(value || "")
13
86
  .trim()
14
87
  .toLowerCase();
@@ -18,19 +91,28 @@ const browserLanguage = () => {
18
91
  : "";
19
92
  return normalizeLanguageCode(navigatorLanguage || "en-US");
20
93
  };
21
- const findBrowserVoice = (voices, preferredVoice) => {
94
+ /**
95
+ * Whether a voice speaks `contentLanguage`, or the browser's own language when
96
+ * the content names none: the same language, or the same primary subtag.
97
+ */
98
+ export const browserVoiceMatchesLanguage = (voice, contentLanguage) => {
99
+ const language = normalizeLanguageCode(contentLanguage) || browserLanguage();
100
+ const languagePrefix = language.split("-")[0] || "en";
101
+ const voiceLanguage = normalizeLanguageCode(voice.lang);
102
+ return (voiceLanguage === language || voiceLanguage.startsWith(`${languagePrefix}-`));
103
+ };
104
+ /**
105
+ * The voice to read with: the configured one, else one for `contentLanguage`
106
+ * (the language of the content being read), else one for the browser's own
107
+ * language.
108
+ */
109
+ export const findBrowserVoice = (voices, preferredVoice, contentLanguage) => {
22
110
  if (preferredVoice) {
23
111
  return (voices.find((voice) => voice.voiceURI === preferredVoice) ||
24
112
  voices.find((voice) => voice.name === preferredVoice) ||
25
113
  null);
26
114
  }
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
- };
115
+ const matchesLanguage = (voice) => browserVoiceMatchesLanguage(voice, contentLanguage);
34
116
  const ranked = [
35
117
  (voice) => voice.localService && matchesLanguage(voice),
36
118
  (voice) => voice.default && matchesLanguage(voice),
@@ -45,6 +127,64 @@ const findBrowserVoice = (voices, preferredVoice) => {
45
127
  }
46
128
  return voices[0] || null;
47
129
  };
130
+ /**
131
+ * Waits for `synth` to publish its voice inventory, which browsers fill
132
+ * asynchronously after page load. Resolves `true` once `getVoices()` is
133
+ * non-empty, at once when it already is; `false` after `timeoutMs`, once
134
+ * `isCurrent` reports the wait superseded, or when `synth` offers no
135
+ * `voiceschanged` channel to wait on. A previous `onvoiceschanged` handler
136
+ * keeps running and is restored afterwards.
137
+ */
138
+ export const waitForBrowserVoices = (synth, timeoutMs, isCurrent = () => true) => {
139
+ if (synth.getVoices().length > 0)
140
+ return Promise.resolve(true);
141
+ const canUseEventTarget = typeof synth.addEventListener === "function" &&
142
+ typeof synth.removeEventListener === "function";
143
+ if (!canUseEventTarget && !("onvoiceschanged" in synth)) {
144
+ return Promise.resolve(false);
145
+ }
146
+ return new Promise((resolve) => {
147
+ const previousHandler = canUseEventTarget ? null : synth.onvoiceschanged;
148
+ let settled = false;
149
+ const finish = (voicesAvailable) => {
150
+ if (settled)
151
+ return;
152
+ settled = true;
153
+ clearTimeout(timeout);
154
+ if (canUseEventTarget) {
155
+ synth.removeEventListener("voiceschanged", onVoicesChanged);
156
+ }
157
+ else if (synth.onvoiceschanged === propertyHandler) {
158
+ synth.onvoiceschanged = previousHandler;
159
+ }
160
+ resolve(voicesAvailable);
161
+ };
162
+ const inspectVoiceInventory = () => {
163
+ if (!isCurrent())
164
+ finish(false);
165
+ else if (synth.getVoices().length > 0)
166
+ finish(true);
167
+ };
168
+ const onVoicesChanged = () => inspectVoiceInventory();
169
+ const propertyHandler = (event) => {
170
+ try {
171
+ previousHandler?.call(synth, event);
172
+ }
173
+ finally {
174
+ inspectVoiceInventory();
175
+ }
176
+ };
177
+ const timeout = setTimeout(() => finish(false), timeoutMs);
178
+ if (canUseEventTarget) {
179
+ synth.addEventListener("voiceschanged", onVoicesChanged);
180
+ }
181
+ else {
182
+ synth.onvoiceschanged = propertyHandler;
183
+ }
184
+ // Close the getVoices()/listener-registration race without polling.
185
+ inspectVoiceInventory();
186
+ });
187
+ };
48
188
  const shouldAssignBrowserVoice = (voice) => !voice.default;
49
189
  /**
50
190
  * Browser TTS Provider
@@ -53,31 +193,26 @@ const shouldAssignBrowserVoice = (voice) => !voice.default;
53
193
  */
54
194
  export class BrowserTTSProvider {
55
195
  providerId = "browser";
56
- providerName = "Browser Speech Synthesis";
57
- version = "1.0.0";
58
196
  async initialize(config) {
59
197
  // SSR guard
60
198
  if (typeof window === "undefined") {
61
199
  throw new Error("BrowserTTSProvider requires browser environment");
62
200
  }
63
- // Check browser support
64
201
  if (!("speechSynthesis" in window)) {
65
202
  throw new Error("Browser does not support Speech Synthesis API");
66
203
  }
204
+ // Only a configured voice needs the inventory; waiting otherwise would
205
+ // hold readiness on a browser that publishes no voices.
206
+ if (typeof config.voice === "string" && config.voice.trim()) {
207
+ await waitForBrowserVoices(window.speechSynthesis, VOICE_INVENTORY_TIMEOUT_MS);
208
+ }
67
209
  return new BrowserTTSProviderImpl(config);
68
210
  }
69
- supportsFeature(feature) {
70
- // Browser supports all TTS features
71
- return true;
72
- }
73
211
  getCapabilities() {
74
212
  return {
75
- supportsPause: true,
76
- supportsResume: true,
77
213
  supportsWordBoundary: true,
78
- supportsVoiceSelection: true,
79
- supportsRateControl: true,
80
- supportsPitchControl: true,
214
+ // Boundary events depend on the voice: several network voices send none.
215
+ defaultHighlightMode: "sentence",
81
216
  // The Web Speech API voices plain text only; SSML tags are read aloud.
82
217
  supportsSSML: false,
83
218
  };
@@ -95,99 +230,45 @@ class BrowserTTSProviderImpl {
95
230
  utterance = null;
96
231
  config = null;
97
232
  _isPlaying = false;
233
+ // The run's pause, which outlasts the utterance it paused: a pause between
234
+ // utterances holds the next one until resume.
98
235
  _isPaused = false;
99
236
  speakRunId = 0;
100
- settlePendingVoiceWait = null;
237
+ // The run speaking now, between its utterances as well as during them.
238
+ activeRunId = null;
239
+ releasePauseHold = null;
101
240
  settlePendingChunk = null;
241
+ settlePendingGap = null;
102
242
  onPlaybackStart = undefined;
103
243
  constructor(config) {
104
244
  this.config = config;
105
245
  }
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
- });
246
+ /** Whether run `runId` is still current once any pause between utterances ends. */
247
+ async waitWhilePaused(runId) {
248
+ while (this._isPaused && runId === this.speakRunId) {
249
+ await new Promise((resolve) => {
250
+ this.releasePauseHold = resolve;
251
+ });
252
+ }
253
+ return runId === this.speakRunId;
254
+ }
255
+ releaseHold() {
256
+ const release = this.releasePauseHold;
257
+ this.releasePauseHold = null;
258
+ release?.();
172
259
  }
173
- async resolveBrowserVoice(runId) {
260
+ /** The voice to read with; `initialize()` already waited for the inventory. */
261
+ resolveBrowserVoice() {
174
262
  const configuredVoice = typeof this.config?.voice === "string" ? this.config.voice.trim() : "";
175
- let voices = speechSynthesis.getVoices();
263
+ const voices = speechSynthesis.getVoices();
176
264
  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();
265
+ 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.`);
182
266
  }
183
- if (runId !== this.speakRunId) {
184
- return { shouldContinue: false, voice: null };
185
- }
186
- const voice = findBrowserVoice(voices, configuredVoice || undefined);
267
+ const voice = findBrowserVoice(voices, configuredVoice || undefined, this.getContentLocale());
187
268
  if (configuredVoice && !voice) {
188
269
  throw new Error(`Configured browser voice "${configuredVoice}" is unavailable. Select a voice exposed by this browser using its voiceURI or name.`);
189
270
  }
190
- return { shouldContinue: true, voice };
271
+ return voice;
191
272
  }
192
273
  async speak(text) {
193
274
  if (!this.config) {
@@ -196,19 +277,24 @@ class BrowserTTSProviderImpl {
196
277
  // Invalidate any in-flight run and cancel current utterance.
197
278
  this.stop();
198
279
  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;
280
+ this.activeRunId = runId;
281
+ try {
282
+ const voice = this.resolveBrowserVoice();
283
+ const utterance = toUtteranceText(text);
284
+ const chunks = this.splitIntoChunks(utterance.text);
285
+ for (const chunk of chunks) {
286
+ if (this._isPaused && !(await this.waitWhilePaused(runId)))
287
+ break;
288
+ const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voice, utterance.sourceOffset);
289
+ if (!shouldContinue) {
290
+ break;
291
+ }
210
292
  }
211
293
  }
294
+ finally {
295
+ if (this.activeRunId === runId)
296
+ this.activeRunId = null;
297
+ }
212
298
  }
213
299
  async speakSegments(segments) {
214
300
  if (!this.config) {
@@ -216,34 +302,48 @@ class BrowserTTSProviderImpl {
216
302
  }
217
303
  this.stop();
218
304
  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) {
305
+ this.activeRunId = runId;
306
+ try {
307
+ const voice = this.resolveBrowserVoice();
308
+ for (const segment of segments) {
227
309
  if (runId !== this.speakRunId)
228
310
  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;
311
+ const utterance = toUtteranceText(segment.text);
312
+ const chunks = this.splitIntoChunks(utterance.text);
313
+ for (const chunk of chunks) {
314
+ if (this._isPaused && !(await this.waitWhilePaused(runId)))
315
+ break;
316
+ const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voice, (offset) => segment.startOffset + utterance.sourceOffset(offset));
317
+ if (!shouldContinue)
318
+ break;
319
+ }
320
+ const pauseMsAfter = Math.max(0, Number(segment.pauseMsAfter || 0));
321
+ if (pauseMsAfter > 0 && runId === this.speakRunId) {
322
+ const shouldContinue = await this.waitForPause(pauseMsAfter, runId);
323
+ if (!shouldContinue)
324
+ break;
325
+ }
238
326
  }
239
327
  }
328
+ finally {
329
+ if (this.activeRunId === runId)
330
+ this.activeRunId = null;
331
+ }
240
332
  }
241
333
  async waitForPause(pauseMs, runId) {
242
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
334
+ await new Promise((resolve) => {
335
+ const settle = () => {
336
+ clearTimeout(timer);
337
+ if (this.settlePendingGap === settle)
338
+ this.settlePendingGap = null;
339
+ resolve();
340
+ };
341
+ const timer = setTimeout(settle, pauseMs);
342
+ this.settlePendingGap = settle;
343
+ });
243
344
  return runId === this.speakRunId;
244
345
  }
245
346
  splitIntoChunks(text) {
246
- const MAX_CHUNK_LENGTH = 260;
247
347
  if (text.length <= MAX_CHUNK_LENGTH) {
248
348
  return [{ text, offset: 0 }];
249
349
  }
@@ -277,13 +377,19 @@ class BrowserTTSProviderImpl {
277
377
  }
278
378
  return chunks.length ? chunks : [{ text, offset: 0 }];
279
379
  }
280
- getHighlightMode() {
281
- const providerOptions = (this.config?.providerOptions || {});
282
- return providerOptions.highlightMode === "word" ? "word" : "sentence";
380
+ /**
381
+ * The language of the content being read, which the toolkit sets per speak as
382
+ * `providerOptions.contentLanguage` when the content or the host names one.
383
+ */
384
+ getContentLocale() {
385
+ const language = this.config?.providerOptions?.contentLanguage;
386
+ return typeof language === "string" && language.trim()
387
+ ? language.trim()
388
+ : undefined;
283
389
  }
284
390
  getSegmentationPolicy() {
285
- const providerOptions = (this.config?.providerOptions || {});
286
- const segmenter = (providerOptions.segmenter || {});
391
+ const providerOptions = this.config?.providerOptions || {};
392
+ const segmenter = providerOptions.segmenter || {};
287
393
  const mode = segmenter.mode;
288
394
  const useSegmenter = mode !== "regexOnly";
289
395
  const locale = typeof segmenter.locale === "string" && segmenter.locale.trim().length > 0
@@ -294,7 +400,6 @@ class BrowserTTSProviderImpl {
294
400
  : undefined;
295
401
  return {
296
402
  useSentenceSegmenter: useSegmenter,
297
- useWordSegmenter: useSegmenter,
298
403
  locale,
299
404
  };
300
405
  }
@@ -311,7 +416,11 @@ class BrowserTTSProviderImpl {
311
416
  const match = slice.match(/^\s*([^\s]+)/);
312
417
  return match?.[1]?.length || 1;
313
418
  }
314
- async speakChunk(chunkText, chunkOffset, runId, voice) {
419
+ /**
420
+ * `chunkOffset` places the chunk in the utterance text; `sourceOffset` maps an
421
+ * utterance offset to the boundary position reported for it.
422
+ */
423
+ async speakChunk(chunkText, chunkOffset, runId, voice, sourceOffset) {
315
424
  return new Promise((resolve, reject) => {
316
425
  if (runId !== this.speakRunId) {
317
426
  resolve(false);
@@ -319,7 +428,11 @@ class BrowserTTSProviderImpl {
319
428
  }
320
429
  const utterance = new SpeechSynthesisUtterance(chunkText);
321
430
  this.utterance = utterance;
322
- // Apply config
431
+ // Apply config. The language tells the engine how to read the text, and
432
+ // lets it choose a voice when the default one is left in place.
433
+ const contentLocale = this.getContentLocale();
434
+ if (contentLocale)
435
+ utterance.lang = contentLocale;
323
436
  if (voice && shouldAssignBrowserVoice(voice))
324
437
  utterance.voice = voice;
325
438
  if (this.config?.rate)
@@ -362,7 +475,6 @@ class BrowserTTSProviderImpl {
362
475
  }
363
476
  if (runId === this.speakRunId) {
364
477
  this._isPlaying = false;
365
- this._isPaused = false;
366
478
  }
367
479
  if (error) {
368
480
  reject(error);
@@ -384,7 +496,9 @@ class BrowserTTSProviderImpl {
384
496
  didStart = true;
385
497
  clearStartTimeout();
386
498
  this._isPlaying = true;
387
- this._isPaused = false;
499
+ // A pause that landed while the engine was starting holds this utterance.
500
+ if (this._isPaused)
501
+ speechSynthesis.pause();
388
502
  try {
389
503
  this.onPlaybackStart?.();
390
504
  }
@@ -427,27 +541,28 @@ class BrowserTTSProviderImpl {
427
541
  utterance.onboundary = (event) => {
428
542
  if (runId !== this.speakRunId)
429
543
  return;
430
- console.log("[BrowserProvider] Boundary event:", event.name, "charIndex:", event.charIndex, "charLength:", event.charLength);
544
+ logger.debug(`boundary event: ${event.name}, charIndex ${event.charIndex}, charLength ${event.charLength}`);
431
545
  if (event.name !== "word" || !this.onWordBoundary)
432
546
  return;
433
- if (this.getHighlightMode() === "sentence") {
434
- return;
435
- }
436
547
  const charIndex = Math.max(0, Math.min(event.charIndex, Math.max(0, chunkText.length - 1)));
437
548
  const reportedLength = Number(event.charLength || 0);
438
549
  const inferredLength = this.inferWordLength(chunkText, charIndex);
439
550
  const wordLength = Number.isFinite(reportedLength) &&
440
551
  reportedLength > 0 &&
441
- reportedLength <= 80 &&
552
+ reportedLength <= MAX_TRUSTED_REPORTED_WORD_LENGTH &&
442
553
  charIndex + reportedLength <= chunkText.length
443
554
  ? reportedLength
444
555
  : inferredLength;
445
556
  const word = chunkText
446
557
  .substring(charIndex, Math.min(chunkText.length, charIndex + wordLength))
447
558
  .trim();
448
- const absoluteBoundaryStart = chunkOffset + charIndex;
449
- console.log("[BrowserProvider] Calling onWordBoundary with word:", word, "at position:", absoluteBoundaryStart);
450
- this.onWordBoundary(word, absoluteBoundaryStart, wordLength);
559
+ const utteranceStart = chunkOffset + charIndex;
560
+ const absoluteBoundaryStart = sourceOffset(utteranceStart);
561
+ const boundaryLength = sourceOffset(utteranceStart + wordLength - 1) +
562
+ 1 -
563
+ absoluteBoundaryStart;
564
+ logger.debug(`word boundary "${word}" at ${absoluteBoundaryStart}`);
565
+ this.onWordBoundary(word, absoluteBoundaryStart, boundaryLength);
451
566
  };
452
567
  startTimeout = setTimeout(() => {
453
568
  if (settled || didStart || runId !== this.speakRunId)
@@ -466,33 +581,39 @@ class BrowserTTSProviderImpl {
466
581
  });
467
582
  }
468
583
  pause() {
469
- if (this._isPlaying && !this._isPaused) {
584
+ if (this._isPaused)
585
+ return;
586
+ if (this._isPlaying) {
470
587
  speechSynthesis.pause();
588
+ this._isPaused = true;
589
+ }
590
+ else if (this.activeRunId === this.speakRunId) {
591
+ // Between utterances, or in a structural gap: the next one holds.
592
+ this._isPaused = true;
471
593
  }
472
594
  }
473
595
  resume() {
474
- if (this._isPlaying && this._isPaused) {
596
+ if (!this._isPaused)
597
+ return;
598
+ this._isPaused = false;
599
+ if (this._isPlaying)
475
600
  speechSynthesis.resume();
476
- }
601
+ this.releaseHold();
477
602
  }
478
603
  stop() {
479
604
  const utterance = this.utterance;
480
605
  const shouldCancel = this._isPlaying || utterance !== null;
481
606
  this.speakRunId += 1;
482
- this.settlePendingVoiceWait?.();
607
+ this.activeRunId = null;
483
608
  this.settlePendingChunk?.();
609
+ this.settlePendingGap?.();
484
610
  if (shouldCancel) {
485
611
  speechSynthesis.cancel();
486
612
  }
487
613
  this.utterance = null;
488
614
  this._isPlaying = false;
489
615
  this._isPaused = false;
490
- }
491
- isPlaying() {
492
- return this._isPlaying && !this._isPaused;
493
- }
494
- isPaused() {
495
- return this._isPaused;
616
+ this.releaseHold();
496
617
  }
497
618
  /**
498
619
  * Update settings dynamically (rate, pitch, voice)
@@ -502,7 +623,6 @@ class BrowserTTSProviderImpl {
502
623
  if (!this.config) {
503
624
  this.config = {};
504
625
  }
505
- // Update config with new settings
506
626
  if (settings.rate !== undefined) {
507
627
  this.config.rate = settings.rate;
508
628
  }