@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.1

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 (142) hide show
  1. package/README.md +451 -541
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +0 -1
  4. package/dist/attempt/TestSession.js +10 -5
  5. package/dist/components/ItemScope.custom-element.js +1 -1
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
  10. package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-asenkvx6.js +40 -0
  12. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  13. package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
  14. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +6 -0
  16. package/dist/index.d.ts +7 -7
  17. package/dist/index.js +4 -10
  18. package/dist/policy/core/PolicySource.d.ts +1 -1
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
  20. package/dist/policy/core/ToolPolicyEngine.js +73 -16
  21. package/dist/policy/core/compose-decision.d.ts +3 -3
  22. package/dist/policy/core/compose-decision.js +32 -30
  23. package/dist/policy/core/decision-types.d.ts +51 -34
  24. package/dist/policy/core/feature-decision.d.ts +17 -15
  25. package/dist/policy/core/feature-decision.js +11 -13
  26. package/dist/policy/core/pnp-policy-inputs.d.ts +7 -3
  27. package/dist/policy/core/pnp-policy-inputs.js +17 -33
  28. package/dist/policy/core/provenance.d.ts +2 -2
  29. package/dist/policy/core/tool-parameters.d.ts +12 -0
  30. package/dist/policy/core/tool-parameters.js +27 -0
  31. package/dist/policy/engine.d.ts +1 -1
  32. package/dist/policy/sources/PnpPolicySource.d.ts +18 -24
  33. package/dist/policy/sources/PnpPolicySource.js +57 -29
  34. package/dist/runtime/SectionControllerBinding.d.ts +6 -2
  35. package/dist/runtime/SectionControllerBinding.js +21 -15
  36. package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
  37. package/dist/runtime/adapter/subscriber-fanout.js +1 -4
  38. package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
  39. package/dist/runtime/composition-emit-scheduler.js +1 -1
  40. package/dist/runtime/content-region.d.ts +0 -4
  41. package/dist/runtime/content-region.js +2 -2
  42. package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
  43. package/dist/runtime/core/SectionEngineCore.js +0 -4
  44. package/dist/runtime/core/cohort.d.ts +0 -5
  45. package/dist/runtime/core/cohort.js +0 -9
  46. package/dist/runtime/core/engine-output.d.ts +3 -2
  47. package/dist/runtime/core/engine-transition.js +3 -2
  48. package/dist/runtime/registration-events.d.ts +11 -14
  49. package/dist/runtime/runtime-id.d.ts +1 -1
  50. package/dist/runtime/runtime-id.js +1 -1
  51. package/dist/runtime/shell-scope.d.ts +4 -4
  52. package/dist/runtime/shell-scope.js +15 -9
  53. package/dist/runtime/tool-host-contract.d.ts +0 -4
  54. package/dist/runtime/tool-host-contract.js +1 -1
  55. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  56. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  57. package/dist/services/ElementToolStateStore.d.ts +32 -61
  58. package/dist/services/ElementToolStateStore.js +37 -67
  59. package/dist/services/HighlightCoordinator.d.ts +9 -21
  60. package/dist/services/HighlightCoordinator.js +52 -80
  61. package/dist/services/RangeSerializer.d.ts +4 -35
  62. package/dist/services/RangeSerializer.js +4 -48
  63. package/dist/services/SSMLExtractor.d.ts +8 -31
  64. package/dist/services/SSMLExtractor.js +18 -40
  65. package/dist/services/TTSService.d.ts +51 -57
  66. package/dist/services/TTSService.js +323 -284
  67. package/dist/services/ToolCoordinator.d.ts +6 -49
  68. package/dist/services/ToolCoordinator.js +25 -61
  69. package/dist/services/ToolRegistry.d.ts +83 -80
  70. package/dist/services/ToolRegistry.js +91 -67
  71. package/dist/services/ToolkitCoordinator.d.ts +83 -81
  72. package/dist/services/ToolkitCoordinator.js +186 -174
  73. package/dist/services/framework-error.d.ts +30 -7
  74. package/dist/services/framework-error.js +10 -13
  75. package/dist/services/interfaces.d.ts +51 -71
  76. package/dist/services/section-controller-types.d.ts +7 -7
  77. package/dist/services/section-session-assignment.js +6 -8
  78. package/dist/services/spoken-audio-cards.d.ts +2 -4
  79. package/dist/services/spoken-audio-cards.js +2 -4
  80. package/dist/services/tool-config-validation.d.ts +5 -7
  81. package/dist/services/tool-config-validation.js +11 -26
  82. package/dist/services/tool-context.d.ts +39 -24
  83. package/dist/services/tool-context.js +158 -92
  84. package/dist/services/tool-failure.d.ts +30 -0
  85. package/dist/services/tool-failure.js +11 -0
  86. package/dist/services/tool-icons.d.ts +1 -2
  87. package/dist/services/tool-icons.js +2 -2
  88. package/dist/services/tool-instance-id.d.ts +8 -6
  89. package/dist/services/tool-instance-id.js +12 -18
  90. package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
  91. package/dist/services/tool-providers/TTSToolProvider.js +7 -63
  92. package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
  93. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
  94. package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
  95. package/dist/services/tool-request.d.ts +4 -0
  96. package/dist/services/tool-request.js +7 -2
  97. package/dist/services/toolbar-decided-tools.d.ts +2 -0
  98. package/dist/services/toolbar-decided-tools.js +2 -0
  99. package/dist/services/toolbar-items.js +2 -5
  100. package/dist/services/tools-config-normalizer.d.ts +18 -9
  101. package/dist/services/tools-config-normalizer.js +25 -19
  102. package/dist/services/tts/browser-provider.d.ts +2 -5
  103. package/dist/services/tts/browser-provider.js +13 -22
  104. package/dist/services/tts/catalog-span-alignment.js +14 -7
  105. package/dist/services/tts/flat-tree.d.ts +0 -7
  106. package/dist/services/tts/flat-tree.js +2 -2
  107. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  108. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  109. package/dist/services/tts/generated-speech/index.js +1 -1
  110. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  111. package/dist/services/tts/generated-speech/types.js +1 -1
  112. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
  113. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  114. package/dist/services/tts/math-aware-text-processing.js +10 -29
  115. package/dist/services/tts/math-speech.d.ts +1 -1
  116. package/dist/services/tts/math-speech.js +3 -0
  117. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  118. package/dist/services/tts/mathml-sanitization.js +9 -1
  119. package/dist/services/tts/provider-options.d.ts +12 -1
  120. package/dist/services/tts/ssml/spoken-text.js +1 -1
  121. package/dist/services/tts/start-failure.d.ts +0 -7
  122. package/dist/services/tts/start-failure.js +1 -1
  123. package/dist/services/tts/text-segmentation.d.ts +5 -0
  124. package/dist/services/tts/text-segmentation.js +13 -10
  125. package/dist/services/tts-runtime-config.d.ts +32 -12
  126. package/dist/services/tts-runtime-config.js +14 -20
  127. package/dist/tools/content-capability-resolution.d.ts +2 -2
  128. package/dist/tools/registration.d.ts +7 -6
  129. package/dist/tools/registration.js +10 -9
  130. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  131. package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
  132. package/dist/tools/tool-surface-host.js +1 -8
  133. package/package.json +8 -6
  134. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
  135. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
  136. package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
  137. package/dist/services/ContextVariableStore.d.ts +0 -113
  138. package/dist/services/ContextVariableStore.js +0 -206
  139. package/dist/services/tts-control-events.d.ts +0 -13
  140. package/dist/services/tts-control-events.js +0 -7
  141. package/dist/utils/logger.d.ts +0 -6
  142. package/dist/utils/logger.js +0 -10
@@ -1,12 +1,10 @@
1
1
  import { DEFAULT_TOOL_PLACEMENT } from "./tool-config-defaults.js";
2
+ import { isPlainRecord } from "@pie-players/pie-players-shared/object";
2
3
  const DEFAULT_PLACEMENT = {
3
4
  section: [...DEFAULT_TOOL_PLACEMENT.section],
4
5
  item: [...DEFAULT_TOOL_PLACEMENT.item],
5
6
  passage: [...DEFAULT_TOOL_PLACEMENT.passage],
6
7
  };
7
- function isPlainObject(value) {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- }
10
8
  function assertStringArray(value, fieldPath) {
11
9
  if (value == null)
12
10
  return [];
@@ -22,7 +20,7 @@ function assertStringArray(value, fieldPath) {
22
20
  function assertPlacementConfig(value) {
23
21
  if (value == null)
24
22
  return undefined;
25
- if (!isPlainObject(value)) {
23
+ if (!isPlainRecord(value)) {
26
24
  throw new Error('Invalid tools config at "placement": expected an object with section/item/passage arrays.');
27
25
  }
28
26
  return value;
@@ -30,7 +28,7 @@ function assertPlacementConfig(value) {
30
28
  function assertPolicyConfig(value) {
31
29
  if (value == null)
32
30
  return undefined;
33
- if (!isPlainObject(value)) {
31
+ if (!isPlainRecord(value)) {
34
32
  throw new Error('Invalid tools config at "policy": expected an object with allowed/blocked arrays.');
35
33
  }
36
34
  return value;
@@ -38,7 +36,7 @@ function assertPolicyConfig(value) {
38
36
  function assertProviderConfig(providerId, value) {
39
37
  if (value == null)
40
38
  return undefined;
41
- if (!isPlainObject(value)) {
39
+ if (!isPlainRecord(value)) {
42
40
  throw new Error(`Invalid tools config at "providers.${providerId}": expected an object.`);
43
41
  }
44
42
  const config = value;
@@ -49,12 +47,12 @@ function assertProviderConfig(providerId, value) {
49
47
  }
50
48
  if ("settings" in config &&
51
49
  config.settings !== undefined &&
52
- !isPlainObject(config.settings)) {
50
+ !isPlainRecord(config.settings)) {
53
51
  throw new Error(`Invalid tools config at "providers.${providerId}.settings": expected an object.`);
54
52
  }
55
53
  if ("provider" in config &&
56
54
  config.provider !== undefined &&
57
- !isPlainObject(config.provider)) {
55
+ !isPlainRecord(config.provider)) {
58
56
  throw new Error(`Invalid tools config at "providers.${providerId}.provider": expected an object.`);
59
57
  }
60
58
  return config;
@@ -62,7 +60,7 @@ function assertProviderConfig(providerId, value) {
62
60
  function assertProvidersConfig(value) {
63
61
  if (value == null)
64
62
  return {};
65
- if (!isPlainObject(value)) {
63
+ if (!isPlainRecord(value)) {
66
64
  throw new Error('Invalid tools config at "providers": expected an object keyed by tool id.');
67
65
  }
68
66
  const normalized = {};
@@ -71,6 +69,10 @@ function assertProvidersConfig(value) {
71
69
  }
72
70
  return normalized;
73
71
  }
72
+ /**
73
+ * Tool ids trimmed, with empty ones dropped and duplicates removed in
74
+ * first-seen order. Throws on a non-string entry.
75
+ */
74
76
  export function normalizeToolList(toolIds) {
75
77
  if (!toolIds || toolIds.length === 0)
76
78
  return [];
@@ -101,8 +103,18 @@ function assertPnpEnforcement(value, fieldPath) {
101
103
  return value;
102
104
  throw new Error(`Invalid tools config at "${fieldPath}": expected "on" or "off", got ${JSON.stringify(value)}.`);
103
105
  }
106
+ /** A placement level's tool ids, or the level's default when it names none. */
107
+ function normalizePlacementLevel(placement, level) {
108
+ const toolIds = normalizeToolList(assertStringArray(placement?.[level], `placement.${level}`));
109
+ return toolIds.length ? toolIds : [...DEFAULT_PLACEMENT[level]];
110
+ }
111
+ /**
112
+ * Check a host's tools config shape, throwing at the first malformed field, and
113
+ * return it canonical: tool lists trimmed and deduplicated, and every placement
114
+ * level that names no tool filled with its default.
115
+ */
104
116
  export function normalizeToolsConfig(input) {
105
- if (input != null && !isPlainObject(input)) {
117
+ if (input != null && !isPlainRecord(input)) {
106
118
  throw new Error('Invalid tools config: expected an object with "policy", "placement", and "providers".');
107
119
  }
108
120
  const policy = assertPolicyConfig(input?.policy);
@@ -115,15 +127,9 @@ export function normalizeToolsConfig(input) {
115
127
  blocked: normalizeToolList(assertStringArray(policy?.blocked, "policy.blocked")),
116
128
  },
117
129
  placement: {
118
- section: normalizeToolList(assertStringArray(placement?.section, "placement.section")).length
119
- ? normalizeToolList(assertStringArray(placement?.section, "placement.section"))
120
- : [...DEFAULT_PLACEMENT.section],
121
- item: normalizeToolList(assertStringArray(placement?.item, "placement.item")).length
122
- ? normalizeToolList(assertStringArray(placement?.item, "placement.item"))
123
- : [...DEFAULT_PLACEMENT.item],
124
- passage: normalizeToolList(assertStringArray(placement?.passage, "placement.passage")).length
125
- ? normalizeToolList(assertStringArray(placement?.passage, "placement.passage"))
126
- : [...DEFAULT_PLACEMENT.passage],
130
+ section: normalizePlacementLevel(placement, "section"),
131
+ item: normalizePlacementLevel(placement, "item"),
132
+ passage: normalizePlacementLevel(placement, "passage"),
127
133
  },
128
134
  providers,
129
135
  };
@@ -3,16 +3,15 @@
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
  */
9
7
  import type { ITTSProvider, ITTSProviderImplementation, TTSConfig, TTSProviderCapabilities } from "@pie-players/pie-tts";
8
+ import type { ToolkitTTSConfig } from "./provider-options.js";
10
9
  /**
11
10
  * The part of another provider's configuration that keeps its meaning on
12
11
  * browser speech. Every path that replaces a provider with browser speech
13
12
  * starts it with this.
14
13
  */
15
- export declare const browserFallbackConfig: (config: Partial<TTSConfig>) => Partial<TTSConfig>;
14
+ export declare const browserFallbackConfig: (config: Partial<ToolkitTTSConfig>) => Partial<ToolkitTTSConfig>;
16
15
  /**
17
16
  * The voice traits browser voice selection reads. A `SpeechSynthesisVoice` has
18
17
  * them all; a host's own voice list may carry a subset.
@@ -51,8 +50,6 @@ export declare const waitForBrowserVoices: (synth: SpeechSynthesis, timeoutMs: n
51
50
  */
52
51
  export declare class BrowserTTSProvider implements ITTSProvider {
53
52
  readonly providerId = "browser";
54
- readonly providerName = "Browser Speech Synthesis";
55
- readonly version = "1.0.0";
56
53
  initialize(config: TTSConfig): Promise<ITTSProviderImplementation>;
57
54
  getCapabilities(): TTSProviderCapabilities;
58
55
  destroy(): void;
@@ -3,8 +3,6 @@
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
  */
9
7
  import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
10
8
  import { extractSpokenText, isSsmlDocument } from "./ssml/spoken-text.js";
@@ -12,6 +10,9 @@ import { segmentSentences as segmentTextToSentences } from "./text-segmentation.
12
10
  const logger = createPieLogger("browser-tts-provider", isTtsDebugEnabled);
13
11
  const NATIVE_START_TIMEOUT_MS = 5_000;
14
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;
15
16
  // The provider options the toolkit and this provider read. Every other option,
16
17
  // and `voice`, belongs to the provider it was configured for: a Polly voice such
17
18
  // as "Joanna" resolves to no browser voice, and an unresolved voice fails speak.
@@ -36,6 +37,8 @@ export const browserFallbackConfig = (config) => {
36
37
  fallback.rate = config.rate;
37
38
  if (config.pitch !== undefined)
38
39
  fallback.pitch = config.pitch;
40
+ if (config.volume !== undefined)
41
+ fallback.volume = config.volume;
39
42
  if (config.mathTokenHighlighting !== undefined) {
40
43
  fallback.mathTokenHighlighting = config.mathTokenHighlighting;
41
44
  }
@@ -192,14 +195,11 @@ const shouldAssignBrowserVoice = (voice) => !voice.default;
192
195
  */
193
196
  export class BrowserTTSProvider {
194
197
  providerId = "browser";
195
- providerName = "Browser Speech Synthesis";
196
- version = "1.0.0";
197
198
  async initialize(config) {
198
199
  // SSR guard
199
200
  if (typeof window === "undefined") {
200
201
  throw new Error("BrowserTTSProvider requires browser environment");
201
202
  }
202
- // Check browser support
203
203
  if (!("speechSynthesis" in window)) {
204
204
  throw new Error("Browser does not support Speech Synthesis API");
205
205
  }
@@ -212,14 +212,9 @@ export class BrowserTTSProvider {
212
212
  }
213
213
  getCapabilities() {
214
214
  return {
215
- supportsPause: true,
216
- supportsResume: true,
217
215
  supportsWordBoundary: true,
218
216
  // Boundary events depend on the voice: several network voices send none.
219
217
  defaultHighlightMode: "sentence",
220
- supportsVoiceSelection: true,
221
- supportsRateControl: true,
222
- supportsPitchControl: true,
223
218
  // The Web Speech API voices plain text only; SSML tags are read aloud.
224
219
  supportsSSML: false,
225
220
  };
@@ -351,7 +346,6 @@ class BrowserTTSProviderImpl {
351
346
  return runId === this.speakRunId;
352
347
  }
353
348
  splitIntoChunks(text) {
354
- const MAX_CHUNK_LENGTH = 260;
355
349
  if (text.length <= MAX_CHUNK_LENGTH) {
356
350
  return [{ text, offset: 0 }];
357
351
  }
@@ -408,7 +402,6 @@ class BrowserTTSProviderImpl {
408
402
  : undefined;
409
403
  return {
410
404
  useSentenceSegmenter: useSegmenter,
411
- useWordSegmenter: useSegmenter,
412
405
  locale,
413
406
  };
414
407
  }
@@ -446,8 +439,10 @@ class BrowserTTSProviderImpl {
446
439
  utterance.voice = voice;
447
440
  if (this.config?.rate)
448
441
  utterance.rate = this.config.rate;
449
- if (this.config?.pitch)
442
+ if (this.config?.pitch !== undefined)
450
443
  utterance.pitch = this.config.pitch;
444
+ if (this.config?.volume !== undefined)
445
+ utterance.volume = this.config.volume;
451
446
  let didStart = false;
452
447
  let settled = false;
453
448
  let startTimeout = null;
@@ -558,7 +553,7 @@ class BrowserTTSProviderImpl {
558
553
  const inferredLength = this.inferWordLength(chunkText, charIndex);
559
554
  const wordLength = Number.isFinite(reportedLength) &&
560
555
  reportedLength > 0 &&
561
- reportedLength <= 80 &&
556
+ reportedLength <= MAX_TRUSTED_REPORTED_WORD_LENGTH &&
562
557
  charIndex + reportedLength <= chunkText.length
563
558
  ? reportedLength
564
559
  : inferredLength;
@@ -624,27 +619,23 @@ class BrowserTTSProviderImpl {
624
619
  this._isPaused = false;
625
620
  this.releaseHold();
626
621
  }
627
- isPlaying() {
628
- return this._isPlaying && !this._isPaused;
629
- }
630
- isPaused() {
631
- return this._isPaused;
632
- }
633
622
  /**
634
- * Update settings dynamically (rate, pitch, voice)
623
+ * Update settings dynamically (rate, pitch, volume, voice)
635
624
  * Changes take effect on the next speak() call
636
625
  */
637
626
  updateSettings(settings) {
638
627
  if (!this.config) {
639
628
  this.config = {};
640
629
  }
641
- // Update config with new settings
642
630
  if (settings.rate !== undefined) {
643
631
  this.config.rate = settings.rate;
644
632
  }
645
633
  if (settings.pitch !== undefined) {
646
634
  this.config.pitch = settings.pitch;
647
635
  }
636
+ if (settings.volume !== undefined) {
637
+ this.config.volume = settings.volume;
638
+ }
648
639
  if (settings.voice !== undefined) {
649
640
  this.config.voice = settings.voice;
650
641
  }
@@ -1,5 +1,9 @@
1
1
  import { tokenizeAlignmentText, tokenizeSpeechSource, } from "./math-alignment/speech-tokenizer.js";
2
2
  import { normalizeTextForSpeech } from "./text-processing.js";
3
+ // A spoken token equal to a visible token; phrase targets score below it.
4
+ const EXACT_TOKEN_SCORE = 10;
5
+ // Below this, an anchored chunk highlights its region instead of spans.
6
+ const MIN_ANCHOR_SPAN_CONFIDENCE = 0.12;
3
7
  // LIMITATION (i18n): the phrase table is English, so multi-word operator
4
8
  // phrases ("plus or minus", "divided by") only align for English speech; other
5
9
  // locales degrade to coarse region highlighting.
@@ -29,7 +33,7 @@ const createCandidates = (spokenTokens, visibleTokens) => {
29
33
  spokenStartToken: spokenIndex,
30
34
  spokenEndToken: spokenIndex + 1,
31
35
  visibleToken: visibleIndex,
32
- score: 10,
36
+ score: EXACT_TOKEN_SCORE,
33
37
  });
34
38
  }
35
39
  }
@@ -57,6 +61,9 @@ const createCandidates = (spokenTokens, visibleTokens) => {
57
61
  }
58
62
  return candidates;
59
63
  };
64
+ // The highest-scoring chain of candidates monotonic in both spoken and visible
65
+ // order (O(n²) DP). A gap whose spoken and visible token counts differ by more
66
+ // than 2 pays the excess.
60
67
  const computeAnchors = (spokenTokens, visibleTokens) => {
61
68
  const candidates = createCandidates(spokenTokens, visibleTokens).sort((left, right) => spokenTokens[left.spokenStartToken].start -
62
69
  spokenTokens[right.spokenStartToken].start ||
@@ -116,7 +123,7 @@ const scoreConfidence = (anchors, spokenTokens, visibleTokens) => {
116
123
  Math.max(1, Math.min(spokenTokens.length, visibleTokens.length));
117
124
  const averageScore = anchors.reduce((total, anchor) => total + anchor.score, 0) /
118
125
  anchors.length /
119
- 10;
126
+ EXACT_TOKEN_SCORE;
120
127
  return Math.min(1, tokenCoverage * averageScore);
121
128
  };
122
129
  export const createCatalogSpanAlignment = (args) => {
@@ -130,17 +137,17 @@ export const createCatalogSpanAlignment = (args) => {
130
137
  const confidence = scoreConfidence(anchors, spokenTokens, visibleTokens);
131
138
  const isExact = speech.spokenText === visibleText && speech.spokenText.length > 0;
132
139
  const hasShortSingleExactAnchor = anchors.length === 1 &&
133
- anchors[0].score === 10 &&
140
+ anchors[0].score === EXACT_TOKEN_SCORE &&
134
141
  spokenTokens.length <= 2 &&
135
142
  visibleTokens.length <= 2;
143
+ const anchorsAreTrusted = (anchors.length >= 2 || hasShortSingleExactAnchor) &&
144
+ confidence >= MIN_ANCHOR_SPAN_CONFIDENCE;
136
145
  const playbackMode = speech.unsupportedSemantic
137
146
  ? "region-fallback"
138
147
  : isExact
139
148
  ? "exact-word"
140
- : anchors.length >= 2 || hasShortSingleExactAnchor
141
- ? confidence >= 0.12
142
- ? "anchor-span"
143
- : "region-fallback"
149
+ : anchorsAreTrusted
150
+ ? "anchor-span"
144
151
  : "region-fallback";
145
152
  return {
146
153
  speech,
@@ -17,8 +17,6 @@
17
17
  * global, at import or at call time.
18
18
  */
19
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
20
  /** `node`'s children in the flat tree, with skipped elements left out. */
23
21
  export declare const flatTreeChildNodes: (node: Node) => Node[];
24
22
  /**
@@ -35,11 +33,6 @@ export declare const flatTextContent: (root: Node) => string;
35
33
  /** Elements under `root` matching `selector`, in rendering order. */
36
34
  export declare const flatQuerySelectorAll: (root: Node, selector: string) => Element[];
37
35
  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
36
  export declare const flatTreeParentElement: (node: Node) => Element | null;
44
37
  /** The nearest element at or above `node` in the flat tree that matches `selector`. */
45
38
  export declare const flatTreeClosest: (node: Node, selector: string) => Element | null;
@@ -35,7 +35,7 @@ export const isShadowRootNode = (node) => !!node &&
35
35
  node.nodeType === DOCUMENT_FRAGMENT_NODE &&
36
36
  node.host?.nodeType === ELEMENT_NODE;
37
37
  /** Toolbars, tool elements and debug panels: chrome around content, never content. */
38
- export const isToolkitChromeElement = (element) => {
38
+ const isToolkitChromeElement = (element) => {
39
39
  const tag = element.localName?.toLowerCase() || "";
40
40
  if (CHROME_TAGS.has(tag))
41
41
  return true;
@@ -143,7 +143,7 @@ export const flatQuerySelector = (root, selector) => {
143
143
  * `node`'s parent in the flat tree: the slot rendering it, else its parent, with
144
144
  * a shadow root standing for its host.
145
145
  */
146
- export const flatTreeParentNode = (node) => {
146
+ const flatTreeParentNode = (node) => {
147
147
  const slot = node.assignedSlot;
148
148
  if (slot)
149
149
  return slot;
@@ -3,8 +3,8 @@ import type { DomAnchor, GeneratedSpeechChunk } from "./types.js";
3
3
  export interface PlanToChunksOptions {
4
4
  /**
5
5
  * Playback format:
6
- * - "plain" (default): plain spoken text for every chunk (Phase A, the
7
- * stable generated-speech behavior).
6
+ * - "plain" (default): plain spoken text for every chunk (the stable
7
+ * generated-speech behavior).
8
8
  * - "ssml": math chunks with SRE SSML send that SSML to the provider (with
9
9
  * a plain fallback attached); prose chunks always stay plain.
10
10
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -67,11 +67,10 @@ const TEXT_NODE = 3;
67
67
  // the last token — painting it would be a likely false positive, and "false
68
68
  // positives are worse than coarse highlighting." A single-text-node prose range
69
69
  // is kept: it underlines one token cleanly and usefully fills in any token the
70
- // math resolver missed. (The highlight coordinator no longer escalates word
71
- // ranges to the whole <math> / <mjx-container>, so this is now a precision
72
- // policy rather than flash-suppression — see HighlightCoordinator
73
- // WORD_FALLBACK_SELECTOR.)
74
- const wouldEscalateInsideTokenMath = (planned, target, candidates) => {
70
+ // math resolver missed. The word layer never escalates a range to the enclosing
71
+ // <math>/<mjx-container> (HighlightCoordinator WORD_FALLBACK_SELECTOR), so this
72
+ // is a precision policy.
73
+ const isMultiNodeRangeInsideTokenMath = (planned, target, candidates) => {
75
74
  if (!target || target.type !== "range")
76
75
  return false;
77
76
  const { startContainer, endContainer } = target.range;
@@ -128,8 +127,7 @@ export const createTTSHighlightPlan = (args) => {
128
127
  const planned = plannedChunksById.get(chunkId);
129
128
  if (!planned)
130
129
  return missingChunkDecision();
131
- const alignments = planned.chunk.mathAlignments;
132
- const soleElement = alignments.length === 1 ? alignments[0].element : null;
130
+ const soleElement = soleEquationElement(planned.chunk.mathAlignments);
133
131
  const capability = soleElement
134
132
  ? planned.mathCapabilityByElement.get(soleElement)
135
133
  : null;
@@ -164,9 +162,9 @@ export const createTTSHighlightPlan = (args) => {
164
162
  const normalized = normalizeBoundaryEvent(chunk, event);
165
163
  const proseTarget = resolveProseBoundaryTarget(chunk, normalized);
166
164
  const mathCandidates = mathCandidatesForTarget(proseTarget, chunk);
167
- const resolvedMathTarget = (!proseTarget || mathCandidates.length > 0
165
+ const resolvedMathTarget = !proseTarget || mathCandidates.length > 0
168
166
  ? resolveMathBoundaryTarget(chunk, normalized, event, mathCandidates, planned.renderedMathTargetResolver)
169
- : null) || null;
167
+ : null;
170
168
  const mathTarget = resolvedMathTarget?.quality === "semantic-token"
171
169
  ? resolvedMathTarget
172
170
  : (renderHiddenProseTarget(planned, proseTarget) ??
@@ -195,15 +193,15 @@ export const createTTSHighlightPlan = (args) => {
195
193
  }
196
194
  // (2) A spoken word that maps to visible prose. The prose anchor is
197
195
  // precise; a coarse math expression fallback must never override it.
198
- // Exception: a multi-node prose range inside a token-mode equation
199
- // would escalate to the whole expression, so it is rejected here and
196
+ // Exception: a multi-node prose range inside a token-mode equation is
197
+ // an untrustworthy glyph-level match, so it is rejected here and
200
198
  // falls through to the token hold below. When per-token math
201
199
  // highlighting is disabled, any prose range that lands inside a
202
200
  // formula is likewise rejected so the formula stays a single block.
203
201
  if (proseTarget &&
204
202
  !isHiddenRange(proseTarget) &&
205
203
  (mathTokenHighlighting
206
- ? !wouldEscalateInsideTokenMath(planned, proseTarget, mathCandidates)
204
+ ? !isMultiNodeRangeInsideTokenMath(planned, proseTarget, mathCandidates)
207
205
  : mathCandidates.length === 0)) {
208
206
  return createHighlightDecision({
209
207
  semanticTarget: proseTarget,
@@ -1,3 +1,7 @@
1
+ /**
2
+ * What the host knows about the content being highlighted. `scopeElement`
3
+ * falls back to the content element being read.
4
+ */
1
5
  export interface TTSHighlightContext {
2
6
  scopeElement?: HTMLElement | null;
3
7
  itemId?: string;
@@ -6,12 +10,20 @@ export interface TTSHighlightContext {
6
10
  contentKind?: string;
7
11
  regionPolicy?: string;
8
12
  }
13
+ /**
14
+ * Host remap of TTS highlight targets. A null or undefined result keeps the
15
+ * native targets, and so does a result outside `scopeElement` or a throw. For
16
+ * sentences, one out-of-scope or non-Range/Element entry keeps all native
17
+ * ranges.
18
+ */
9
19
  export interface TTSHighlightTargetResolver {
10
20
  resolveWordRange?(range: Range, context: TTSHighlightContext): Range | null | undefined;
11
21
  resolveSentenceRanges?(ranges: Range[], context: TTSHighlightContext): Array<Range | HTMLElement> | null | undefined;
12
22
  }
23
+ /** A resolver with the context it is called with. */
13
24
  export interface TTSHighlightTargetResolverRuntime {
14
25
  context: TTSHighlightContext;
15
26
  resolver?: TTSHighlightTargetResolver | null;
16
27
  }
28
+ /** Read at every highlight; a throw counts as no resolver. */
17
29
  export type TTSHighlightTargetResolverProvider = () => TTSHighlightTargetResolverRuntime | null | undefined;
@@ -74,18 +74,16 @@ const TEXT_CHUNK_SOURCE_ROLES = new Set([
74
74
  ]);
75
75
  const resolveTextChunkSourceElement = (textNode, root) => {
76
76
  let current = flatTreeParentElement(textNode);
77
- let best = null;
78
77
  while (current && current !== root) {
79
78
  const role = (current.getAttribute("role") || "").toLowerCase();
80
79
  const tagName = current.tagName.toUpperCase();
81
80
  if (TEXT_CHUNK_SOURCE_TAGS.has(tagName) ||
82
81
  TEXT_CHUNK_SOURCE_ROLES.has(role)) {
83
- best = current;
84
- break;
82
+ return current;
85
83
  }
86
84
  current = flatTreeParentElement(current);
87
85
  }
88
- return best || root;
86
+ return root;
89
87
  };
90
88
  const hasMathCandidate = (element) => Boolean(typeof element.querySelector === "function" &&
91
89
  flatQuerySelector(element, "math, [data-mathml], .MathJax, mjx-container, mjx-assistive-mml")) ||
@@ -158,13 +156,12 @@ const appendCollectedText = (acc, collected) => {
158
156
  for (let i = 0; i < collected.text.length; i++) {
159
157
  const character = collected.text[i];
160
158
  const isWhitespace = /\s/.test(character);
161
- const mapping = collected.map.get(i) || null;
162
- const syntheticNode = mapping?.node || acc.lastMapped?.node || null;
163
- const syntheticOffset = mapping?.offset ?? acc.lastMapped?.offset ?? 0;
164
- const normalizedMapping = mapping
159
+ const mapping = collected.map.get(i);
160
+ // An unmapped character borrows the last mapped position.
161
+ const characterMapping = mapping
165
162
  ? { node: mapping.node, offset: mapping.offset }
166
- : syntheticNode
167
- ? { node: syntheticNode, offset: syntheticOffset }
163
+ : acc.lastMapped
164
+ ? { node: acc.lastMapped.node, offset: acc.lastMapped.offset }
168
165
  : null;
169
166
  if (acc.inLeadingWhitespace) {
170
167
  if (isWhitespace)
@@ -173,29 +170,16 @@ const appendCollectedText = (acc, collected) => {
173
170
  }
174
171
  if (isWhitespace) {
175
172
  if (!acc.lastCharWasWhitespace) {
176
- appendCharacter(acc, " ", normalizedMapping);
173
+ appendCharacter(acc, " ", characterMapping);
177
174
  }
178
175
  acc.lastCharWasWhitespace = true;
179
176
  continue;
180
177
  }
181
178
  if (i === 0 && shouldInsertBoundarySpace(acc, character)) {
182
- appendCharacter(acc, " ", normalizedMapping);
179
+ appendCharacter(acc, " ", characterMapping);
183
180
  }
184
- appendCharacter(acc, character, normalizedMapping);
181
+ appendCharacter(acc, character, characterMapping);
185
182
  acc.lastCharWasWhitespace = false;
186
- if (mapping) {
187
- acc.map.set(acc.position - 1, {
188
- node: mapping.node,
189
- offset: mapping.offset,
190
- });
191
- acc.lastMapped = { node: mapping.node, offset: mapping.offset };
192
- }
193
- else if (syntheticNode) {
194
- acc.map.set(acc.position - 1, {
195
- node: syntheticNode,
196
- offset: syntheticOffset,
197
- });
198
- }
199
183
  }
200
184
  };
201
185
  const textFallbackFromMathML = (mathml) => {
@@ -376,9 +360,6 @@ const collectMathAware = (root, options, range) => {
376
360
  trimTrailingWhitespace(acc);
377
361
  flushTextChunk();
378
362
  const visibleText = acc.chars.join("");
379
- while (acc.map.has(visibleText.length)) {
380
- acc.map.delete(visibleText.length);
381
- }
382
363
  return {
383
364
  visibleText,
384
365
  map: acc.map,
@@ -14,7 +14,7 @@ export interface ResolveMathSpeechOptions {
14
14
  mathSpeech?: SREMathSpeechOptions;
15
15
  /**
16
16
  * Also produce SRE's SSML rendering (markup: "ssml") for the equation, for
17
- * the runtime generated-SSML path (PIE-623). Only populated when the input
17
+ * the runtime generated-SSML path. Only populated when the input
18
18
  * is a single math chunk and SRE succeeds. The plain `speechText` is always
19
19
  * computed with markup: "none" so the plain path is unaffected.
20
20
  */
@@ -3,6 +3,9 @@ import { setSreStartupLocaleSource, sreLocaleSource } from "./sre-locales.js";
3
3
  import { normalizeTextForSpeech } from "./text-processing.js";
4
4
  // Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
5
5
  const logger = createPieLogger("tts-math-speech", isTtsDebugEnabled);
6
+ // SRE configures one global engine, so each setupEngine + toSpeech pair runs to
7
+ // completion before the next starts; concurrent reads would otherwise speak
8
+ // under each other's settings.
6
9
  let sreOperationQueue = Promise.resolve();
7
10
  const normalizeLocale = (language) => (language || "en").split("-")[0].toLowerCase() || "en";
8
11
  /**
@@ -1 +1,8 @@
1
+ /**
2
+ * MathML re-serialized from an allow-list, with a MathML xmlns on the root.
3
+ * Null for empty or unparseable input, no DOMParser, a non-<math> root, a
4
+ * disallowed element or a node other than element/text/comment, or an xmlns
5
+ * other than MathML's. Comments, attributes off the allow-list and values
6
+ * containing javascript: or data:text/html are dropped.
7
+ */
1
8
  export declare const canonicalizeMathML: (input: string) => string | null;
@@ -1,4 +1,5 @@
1
1
  const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
2
+ const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
2
3
  const ALLOWED_MATHML_ELEMENTS = new Set([
3
4
  "math",
4
5
  "maction",
@@ -109,7 +110,7 @@ const isAllowedMathElement = (element) => {
109
110
  const namespace = element.namespaceURI;
110
111
  return (!namespace ||
111
112
  namespace === MATHML_NAMESPACE ||
112
- namespace === "http://www.w3.org/1999/xhtml");
113
+ namespace === XHTML_NAMESPACE);
113
114
  };
114
115
  const serializeMathNode = (node) => {
115
116
  if (node.nodeType === Node.TEXT_NODE) {
@@ -153,6 +154,13 @@ const parseMathML = (input) => {
153
154
  }
154
155
  return null;
155
156
  };
157
+ /**
158
+ * MathML re-serialized from an allow-list, with a MathML xmlns on the root.
159
+ * Null for empty or unparseable input, no DOMParser, a non-<math> root, a
160
+ * disallowed element or a node other than element/text/comment, or an xmlns
161
+ * other than MathML's. Comments, attributes off the allow-list and values
162
+ * containing javascript: or data:text/html are dropped.
163
+ */
156
164
  export const canonicalizeMathML = (input) => {
157
165
  const trimmed = String(input || "").trim();
158
166
  if (!trimmed)