@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.65

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 (67) hide show
  1. package/README.md +92 -1
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/ItemToolBar-cckwpz6c.js +51 -0
  6. package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
  7. package/dist/index.d.ts +10 -6
  8. package/dist/index.js +12 -4
  9. package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
  10. package/dist/policy/core/ToolPolicyEngine.js +27 -0
  11. package/dist/policy/core/feature-decision.d.ts +57 -0
  12. package/dist/policy/core/feature-decision.js +40 -0
  13. package/dist/policy/engine.d.ts +1 -0
  14. package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
  15. package/dist/policy/sources/PnpPolicySource.js +41 -11
  16. package/dist/runtime/catalog-registration.d.ts +56 -1
  17. package/dist/runtime/catalog-registration.js +64 -31
  18. package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
  19. package/dist/runtime/composition-emit-scheduler.js +154 -0
  20. package/dist/runtime/core/engine-resolver.d.ts +1 -1
  21. package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
  22. package/dist/services/AccessibilityCatalogResolver.js +183 -58
  23. package/dist/services/SSMLExtractor.js +28 -18
  24. package/dist/services/TTSService.d.ts +25 -0
  25. package/dist/services/TTSService.js +241 -45
  26. package/dist/services/ToolRegistry.d.ts +218 -8
  27. package/dist/services/ToolRegistry.js +124 -8
  28. package/dist/services/ToolkitCoordinator.d.ts +25 -3
  29. package/dist/services/ToolkitCoordinator.js +47 -6
  30. package/dist/services/catalog-media.d.ts +25 -0
  31. package/dist/services/catalog-media.js +101 -0
  32. package/dist/services/createDefaultToolRegistry.d.ts +25 -58
  33. package/dist/services/createDefaultToolRegistry.js +24 -104
  34. package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
  35. package/dist/services/defaultPersonalNeedsProfile.js +21 -19
  36. package/dist/services/interfaces.d.ts +29 -2
  37. package/dist/services/pnp-standard-features.d.ts +1 -1
  38. package/dist/services/spoken-audio-cards.d.ts +54 -0
  39. package/dist/services/spoken-audio-cards.js +66 -0
  40. package/dist/services/tool-config-defaults.d.ts +7 -23
  41. package/dist/services/tool-config-defaults.js +7 -46
  42. package/dist/services/tool-config-validation.d.ts +1 -1
  43. package/dist/services/tool-config-validation.js +44 -4
  44. package/dist/services/tts/browser-provider.js +2 -1
  45. package/dist/services/tts/math-aware-text-processing.js +3 -3
  46. package/dist/services/tts/text-processing.d.ts +51 -0
  47. package/dist/services/tts/text-processing.js +117 -1
  48. package/dist/services/tts-runtime-config.js +7 -2
  49. package/dist/tools/internal.d.ts +34 -0
  50. package/dist/tools/internal.js +33 -0
  51. package/dist/tools/tool-tag-map.d.ts +15 -3
  52. package/dist/tools/tool-tag-map.js +21 -18
  53. package/package.json +14 -10
  54. package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
  55. package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
  56. package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
  57. package/dist/tools/registrations/accessibility-tools.js +0 -217
  58. package/dist/tools/registrations/calculator.d.ts +0 -20
  59. package/dist/tools/registrations/calculator.js +0 -228
  60. package/dist/tools/registrations/interaction-tools.d.ts +0 -27
  61. package/dist/tools/registrations/interaction-tools.js +0 -143
  62. package/dist/tools/registrations/measurement-tools.d.ts +0 -24
  63. package/dist/tools/registrations/measurement-tools.js +0 -130
  64. package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
  65. package/dist/tools/registrations/subject-specific-tools.js +0 -158
  66. package/dist/tools/registrations/tts.d.ts +0 -21
  67. package/dist/tools/registrations/tts.js +0 -184
@@ -15,9 +15,11 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
+ import { applyMediaFragment } from "./catalog-media.js";
18
19
  import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
20
+ import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
19
21
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
20
- import { collectVisibleTextAndMap, isElementHiddenForTTS, isNodeHiddenForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
22
+ import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
21
23
  import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
22
24
  import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
23
25
  import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
@@ -88,6 +90,10 @@ export class TTSService {
88
90
  activeWordBoundaryOffset = 0;
89
91
  seekSegments = [];
90
92
  playbackChunks = [];
93
+ // The recording currently playing, if any. `cancel` settles the pending play
94
+ // promise as well as stopping the element, so stop/seek cannot wedge the
95
+ // chunk loop on a run that has already been superseded.
96
+ activeRecordedAudio = null;
91
97
  sentenceHighlightSegments = [];
92
98
  currentSeekSegmentIndex = 0;
93
99
  activeSentenceStartOffset = null;
@@ -234,6 +240,31 @@ export class TTSService {
234
240
  setCatalogResolver(resolver) {
235
241
  this.catalogResolver = resolver;
236
242
  }
243
+ /**
244
+ * Whether a catalog holds spoken content this service could actually speak.
245
+ *
246
+ * Exists because `data-catalog-idref` is one attribute with several readers:
247
+ * a node can be docked to a catalog that carries only a signing card, and the
248
+ * nearest docked ancestor of a selection is therefore not necessarily the one
249
+ * holding its authored SSML. A caller resolving a selection climbs ancestors
250
+ * and asks this before settling on an id — see the TTS tool's
251
+ * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
252
+ * silently shadows authored speech on an outer one.
253
+ */
254
+ hasSpokenAlternate(catalogId, language = "en-US") {
255
+ if (!catalogId || !this.catalogResolver)
256
+ return false;
257
+ const resolved = this.catalogResolver.getAlternative(catalogId, {
258
+ type: "spoken",
259
+ language,
260
+ useFallback: true,
261
+ // The string form specifically. A node may also carry a recording of the
262
+ // same script; that one is not what this reports on, and card order must
263
+ // not decide the answer.
264
+ form: "content",
265
+ });
266
+ return resolved?.content !== undefined;
267
+ }
237
268
  getHighlightResolverRuntime() {
238
269
  let provided = null;
239
270
  try {
@@ -584,7 +615,9 @@ export class TTSService {
584
615
  while (currentNode) {
585
616
  const textNode = currentNode;
586
617
  const parent = textNode.parentElement;
587
- if (parent && !this.isElementHidden(parent)) {
618
+ if (parent &&
619
+ !this.isElementHidden(parent) &&
620
+ !isNodeSuppressedForTTS(textNode)) {
588
621
  const boundary = this.getBoundaryAnchor(textNode, contentElement);
589
622
  const boundaryPoint = nodeStartOffsets.get(textNode);
590
623
  if (boundary &&
@@ -1083,16 +1116,20 @@ export class TTSService {
1083
1116
  language: options.language || "en-US",
1084
1117
  useFallback: true,
1085
1118
  context: options.catalogContext,
1119
+ form: "content",
1086
1120
  });
1087
- if (catalogContent) {
1121
+ // A card with no string form has nothing to speak — a signing card, for
1122
+ // instance. Fall through to generated TTS rather than speaking "".
1123
+ const spokenText = catalogContent?.content;
1124
+ if (catalogContent && spokenText !== undefined) {
1088
1125
  const visibleText = options?.contentElement
1089
1126
  ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
1090
1127
  : normalizedInputText;
1091
- const normalizedCatalogText = normalizeTextForSpeech(catalogContent.content);
1128
+ const normalizedCatalogText = normalizeTextForSpeech(spokenText);
1092
1129
  this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1093
1130
  return {
1094
- contentToSpeak: catalogContent.content,
1095
- speechText: catalogContent.content,
1131
+ contentToSpeak: spokenText,
1132
+ speechText: spokenText,
1096
1133
  visibleText,
1097
1134
  highlightText: visibleText,
1098
1135
  usedCatalogSpoken: true,
@@ -1166,12 +1203,32 @@ export class TTSService {
1166
1203
  const catalogIdRef = element.getAttribute("data-catalog-idref");
1167
1204
  if (!catalogIdRef)
1168
1205
  return null;
1169
- return this.catalogResolver.getAlternative(catalogIdRef, {
1206
+ const lookup = {
1170
1207
  type: "spoken",
1171
1208
  language: options.language || "en-US",
1172
1209
  useFallback: true,
1173
1210
  context: options.catalogContext,
1211
+ };
1212
+ // Two lookups rather than one, because `form` is a preference: asking for
1213
+ // the payload form happily returns a script card when no recording
1214
+ // exists, so the answer has to be checked rather than assumed.
1215
+ const audioCard = this.catalogResolver.getAlternative(catalogIdRef, {
1216
+ ...lookup,
1217
+ form: "payload",
1174
1218
  });
1219
+ const audio = audioCard?.payload
1220
+ ? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
1221
+ : undefined;
1222
+ const scriptCard = this.catalogResolver.getAlternative(catalogIdRef, {
1223
+ ...lookup,
1224
+ form: "content",
1225
+ });
1226
+ // Only a card with a string form can contribute synthesized speech. The
1227
+ // same docking node may also carry a signing card; that one is not ours.
1228
+ const script = scriptCard?.content;
1229
+ if (audio === undefined && script === undefined)
1230
+ return null;
1231
+ return { script, audio };
1175
1232
  };
1176
1233
  const getSingleMathElementForAlignment = (element, visibleText) => {
1177
1234
  const mathElements = getMathElementsForAlignment(element);
@@ -1192,7 +1249,10 @@ export class TTSService {
1192
1249
  return mathElements;
1193
1250
  };
1194
1251
  const visit = (node) => {
1195
- if (isNodeHiddenForTTS(node, root))
1252
+ // Before `resolveCatalog`, so suppression beats an authored `spoken`
1253
+ // card on the same node: the card says how to speak this content, the
1254
+ // suppression says it must not be spoken at all.
1255
+ if (isNodeExcludedFromSpeech(node, root))
1196
1256
  return;
1197
1257
  if (node.nodeType === Node.TEXT_NODE) {
1198
1258
  textBuffer += ` ${node.textContent || ""}`;
@@ -1207,39 +1267,71 @@ export class TTSService {
1207
1267
  const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1208
1268
  const visibleText = collectedVisible.visibleText ||
1209
1269
  normalizeTextForSpeech(element.textContent || "");
1210
- const alignment = createCatalogSpanAlignment({
1211
- speechText: catalog.content,
1212
- visibleText,
1213
- });
1214
- const mathElement = getSingleMathElementForAlignment(element, visibleText);
1215
- const mathAlignment = mathElement
1216
- ? createMathAwareAlignment({
1217
- mathElement,
1218
- speechText: catalog.content,
1219
- })
1220
- : undefined;
1221
- const mathAlignments = mathAlignment
1222
- ? undefined
1223
- : getMathElementsForAlignment(element).map((candidate) => ({
1224
- element: candidate,
1225
- alignment: createMathAwareAlignment({
1226
- mathElement: candidate,
1227
- speechText: catalog.content,
1228
- }),
1229
- }));
1230
- const speechText = normalizeTextForSpeech(catalog.content);
1231
- chunks.push({
1232
- speechText: catalog.content,
1233
- visibleText,
1234
- sourceElement: element,
1235
- regionElement: resolveReadableRegion(element, root),
1236
- speechMatchesVisibleText: speechText === visibleText,
1237
- playbackMode: alignment.playbackMode,
1238
- alignment,
1239
- mathAlignment,
1240
- mathAlignments,
1241
- visibleMap: collectedVisible.map,
1242
- });
1270
+ const regionElement = resolveReadableRegion(element, root);
1271
+ // The script chunk, when there is a script. Built exactly as before,
1272
+ // and it doubles as the recording's fallback: word-level alignment is
1273
+ // only meaningful for the synthesized path, which is the one that runs
1274
+ // if the audio cannot play.
1275
+ const scriptChunk = catalog.script !== undefined
1276
+ ? (() => {
1277
+ const script = catalog.script;
1278
+ const alignment = createCatalogSpanAlignment({
1279
+ speechText: script,
1280
+ visibleText,
1281
+ });
1282
+ const mathElement = getSingleMathElementForAlignment(element, visibleText);
1283
+ const mathAlignment = mathElement
1284
+ ? createMathAwareAlignment({
1285
+ mathElement,
1286
+ speechText: script,
1287
+ })
1288
+ : undefined;
1289
+ const mathAlignments = mathAlignment
1290
+ ? undefined
1291
+ : getMathElementsForAlignment(element).map((candidate) => ({
1292
+ element: candidate,
1293
+ alignment: createMathAwareAlignment({
1294
+ mathElement: candidate,
1295
+ speechText: script,
1296
+ }),
1297
+ }));
1298
+ return {
1299
+ speechText: script,
1300
+ visibleText,
1301
+ sourceElement: element,
1302
+ regionElement,
1303
+ speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
1304
+ playbackMode: alignment.playbackMode,
1305
+ alignment,
1306
+ mathAlignment,
1307
+ mathAlignments,
1308
+ visibleMap: collectedVisible.map,
1309
+ };
1310
+ })()
1311
+ : null;
1312
+ if (catalog.audio) {
1313
+ chunks.push(scriptChunk
1314
+ ? {
1315
+ ...scriptChunk,
1316
+ audio: catalog.audio,
1317
+ plainFallback: scriptChunk,
1318
+ }
1319
+ : {
1320
+ // No script to fall back to, and nothing synthesizes the
1321
+ // recording's words, so `speechText` exists only to keep seek
1322
+ // and offset bookkeeping consistent with the visible text.
1323
+ speechText: visibleText,
1324
+ visibleText,
1325
+ sourceElement: element,
1326
+ regionElement,
1327
+ speechMatchesVisibleText: false,
1328
+ visibleMap: collectedVisible.map,
1329
+ audio: catalog.audio,
1330
+ });
1331
+ return;
1332
+ }
1333
+ if (scriptChunk)
1334
+ chunks.push(scriptChunk);
1243
1335
  return;
1244
1336
  }
1245
1337
  for (const child of Array.from(element.childNodes)) {
@@ -1519,10 +1611,92 @@ export class TTSService {
1519
1611
  throw error;
1520
1612
  }
1521
1613
  }
1614
+ /**
1615
+ * Play a recorded spoken alternate, resolving when it finishes.
1616
+ *
1617
+ * Rejects if the clip cannot play, which is what routes playback to the
1618
+ * chunk's `plainFallback` — the reading script — in `speakCatalogChunk`.
1619
+ * Highlighting is the docked node as a block for the clip's duration: a
1620
+ * recording emits no word boundaries, and inventing them from its duration
1621
+ * would highlight the wrong words confidently rather than the right region
1622
+ * vaguely.
1623
+ */
1624
+ async playRecordedAudio(media) {
1625
+ if (typeof document === "undefined") {
1626
+ throw new Error("[tts] no document available to play recorded audio");
1627
+ }
1628
+ const source = media.sources[0];
1629
+ const element = document.createElement("audio");
1630
+ element.preload = "auto";
1631
+ element.src = applyMediaFragment(source.src, media.fragment);
1632
+ element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
1633
+ await new Promise((resolve, reject) => {
1634
+ let endGuard;
1635
+ const cleanup = () => {
1636
+ element.removeEventListener("ended", onEnded);
1637
+ element.removeEventListener("error", onError);
1638
+ if (endGuard !== undefined)
1639
+ clearInterval(endGuard);
1640
+ if (this.activeRecordedAudio?.element === element) {
1641
+ this.activeRecordedAudio = null;
1642
+ }
1643
+ };
1644
+ const onEnded = () => {
1645
+ cleanup();
1646
+ resolve();
1647
+ };
1648
+ const onError = () => {
1649
+ cleanup();
1650
+ reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1651
+ };
1652
+ // Cancellation has to settle this promise, not just stop the element:
1653
+ // `stop()` bumps the run id, and a pending play that never resolves would
1654
+ // wedge the chunk loop on a run nobody is listening to any more.
1655
+ this.activeRecordedAudio = {
1656
+ element,
1657
+ cancel: () => {
1658
+ cleanup();
1659
+ element.pause();
1660
+ resolve();
1661
+ },
1662
+ };
1663
+ element.addEventListener("ended", onEnded);
1664
+ element.addEventListener("error", onError);
1665
+ // Browsers honour a Media Fragments start offset but are inconsistent
1666
+ // about the end bound, so the end is enforced here — the same reason the
1667
+ // signing region enforces its own.
1668
+ const endSeconds = media.fragment?.endSeconds;
1669
+ if (endSeconds !== undefined) {
1670
+ endGuard = setInterval(() => {
1671
+ if (element.currentTime >= endSeconds)
1672
+ onEnded();
1673
+ }, 100);
1674
+ }
1675
+ Promise.resolve(element.play()).catch(onError);
1676
+ }).finally(() => {
1677
+ if (this.activeRecordedAudio?.element === element) {
1678
+ this.activeRecordedAudio = null;
1679
+ }
1680
+ element.pause();
1681
+ element.removeAttribute("src");
1682
+ });
1683
+ }
1684
+ cancelRecordedAudio() {
1685
+ const active = this.activeRecordedAudio;
1686
+ if (!active)
1687
+ return;
1688
+ this.activeRecordedAudio = null;
1689
+ active.cancel();
1690
+ }
1522
1691
  async speakCatalogChunkOnce(chunk, runId) {
1523
1692
  if (!this.provider)
1524
1693
  return;
1525
1694
  this.lastRenderedRegionTarget = null;
1695
+ if (chunk.audio) {
1696
+ this.highlightCatalogRegion(chunk);
1697
+ await this.playRecordedAudio(chunk.audio);
1698
+ return;
1699
+ }
1526
1700
  const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
1527
1701
  const pipelineChunk = contentRoot
1528
1702
  ? normalizeSpeechChunks({
@@ -1657,9 +1831,13 @@ export class TTSService {
1657
1831
  if (!this.provider) {
1658
1832
  throw new Error("TTS service not initialized");
1659
1833
  }
1660
- const text = range.toString().trim();
1661
- if (!text)
1834
+ // Enforced from the live ancestors rather than from `root`, because the
1835
+ // element carrying the suppression may sit above whatever root the caller
1836
+ // passed or the range happens to resolve to.
1837
+ if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
1838
+ console.warn("[tts] selection lies inside content marked not-to-be-spoken; nothing was spoken. Selecting a node and pressing read-aloud must not become a way around suppression.");
1662
1839
  return;
1840
+ }
1663
1841
  // Use explicit content root when provided; otherwise keep highlighting scoped
1664
1842
  // to the selected range's nearest element ancestor.
1665
1843
  const fromOptions = options?.contentRoot || null;
@@ -1673,11 +1851,23 @@ export class TTSService {
1673
1851
  }
1674
1852
  if (!root)
1675
1853
  return;
1676
- // Calculate the offset of the range start within the root element
1854
+ const selected = collectRangeTextForSpeech(range, root);
1855
+ const text = selected.text.trim();
1856
+ if (!text) {
1857
+ if (selected.filtered) {
1858
+ console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1859
+ }
1860
+ return;
1861
+ }
1862
+ // Calculate the offset of the range start within the root element. Filtered
1863
+ // the same way as the speech itself: the offset indexes into the highlight
1864
+ // text, which comes from the exclusion-aware collectors, so counting
1865
+ // characters here that never reach that text would shift every highlight
1866
+ // after the excluded node.
1677
1867
  const beforeRange = document.createRange();
1678
1868
  beforeRange.selectNodeContents(root);
1679
1869
  beforeRange.setEnd(range.startContainer, range.startOffset);
1680
- const textBeforeRange = beforeRange.toString();
1870
+ const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
1681
1871
  const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
1682
1872
  const offset = normalizedTextBeforeRange.length +
1683
1873
  (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
@@ -1701,6 +1891,7 @@ export class TTSService {
1701
1891
  if (!this.provider)
1702
1892
  return;
1703
1893
  if (this.state === PlaybackState.PLAYING) {
1894
+ this.activeRecordedAudio?.element.pause();
1704
1895
  this.provider.pause();
1705
1896
  this.setState(PlaybackState.PAUSED);
1706
1897
  }
@@ -1712,6 +1903,9 @@ export class TTSService {
1712
1903
  if (!this.provider)
1713
1904
  return;
1714
1905
  if (this.state === PlaybackState.PAUSED) {
1906
+ const recorded = this.activeRecordedAudio?.element;
1907
+ if (recorded)
1908
+ void Promise.resolve(recorded.play()).catch(() => { });
1715
1909
  this.provider.resume();
1716
1910
  this.setState(PlaybackState.PLAYING);
1717
1911
  }
@@ -1729,6 +1923,7 @@ export class TTSService {
1729
1923
  ? this.playbackChunks.slice(safeTargetIndex)
1730
1924
  : null;
1731
1925
  this.speakRunId += 1;
1926
+ this.cancelRecordedAudio();
1732
1927
  this.provider.onWordBoundary = undefined;
1733
1928
  this.provider.stop();
1734
1929
  this.currentSeekSegmentIndex = safeTargetIndex;
@@ -1820,6 +2015,7 @@ export class TTSService {
1820
2015
  if (!this.provider)
1821
2016
  return;
1822
2017
  this.speakRunId += 1;
2018
+ this.cancelRecordedAudio();
1823
2019
  this.provider.onWordBoundary = undefined;
1824
2020
  this.provider.stop();
1825
2021
  this.highlightTargetResolverProvider = null;