@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.64
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.
- package/README.md +63 -0
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/{ItemToolBar-843902tp.js → ItemToolBar-3cppre9r.js} +26 -26
- package/dist/components/chunks/{ItemToolBar-84nv78dy.js → ItemToolBar-7rq2gj8b.js} +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
- package/dist/policy/core/ToolPolicyEngine.js +27 -0
- package/dist/policy/core/feature-decision.d.ts +57 -0
- package/dist/policy/core/feature-decision.js +40 -0
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
- package/dist/policy/sources/PnpPolicySource.js +41 -11
- package/dist/runtime/catalog-registration.d.ts +56 -1
- package/dist/runtime/catalog-registration.js +64 -31
- package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
- package/dist/services/AccessibilityCatalogResolver.js +183 -58
- package/dist/services/SSMLExtractor.js +28 -18
- package/dist/services/TTSService.d.ts +25 -0
- package/dist/services/TTSService.js +241 -45
- package/dist/services/ToolkitCoordinator.d.ts +23 -2
- package/dist/services/ToolkitCoordinator.js +24 -0
- package/dist/services/catalog-media.d.ts +25 -0
- package/dist/services/catalog-media.js +101 -0
- package/dist/services/defaultPersonalNeedsProfile.d.ts +16 -0
- package/dist/services/defaultPersonalNeedsProfile.js +23 -0
- package/dist/services/interfaces.d.ts +29 -2
- package/dist/services/pnp-standard-features.d.ts +1 -1
- package/dist/services/sign-language-cards.d.ts +82 -0
- package/dist/services/sign-language-cards.js +133 -0
- package/dist/services/spoken-audio-cards.d.ts +54 -0
- package/dist/services/spoken-audio-cards.js +66 -0
- package/dist/services/tts/math-aware-text-processing.js +3 -3
- package/dist/services/tts/text-processing.d.ts +51 -0
- package/dist/services/tts/text-processing.js +117 -1
- package/package.json +9 -9
|
@@ -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,
|
|
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 &&
|
|
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
|
-
|
|
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(
|
|
1128
|
+
const normalizedCatalogText = normalizeTextForSpeech(spokenText);
|
|
1092
1129
|
this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
|
|
1093
1130
|
return {
|
|
1094
|
-
contentToSpeak:
|
|
1095
|
-
speechText:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const
|
|
1216
|
-
?
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
1661
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
|
@@ -17,6 +17,7 @@ import type { AccessibilityCatalog, AssessmentEntity, AssessmentItemRef } from "
|
|
|
17
17
|
import { type CanonicalToolsConfig, type ToolPlacementConfig, type ToolPlacementLevel, type ToolPolicyConfig, type ToolProviderConfig, type ToolProvidersConfig } from "./tools-config-normalizer.js";
|
|
18
18
|
import { type ToolConfigStrictness } from "./tool-config-validation.js";
|
|
19
19
|
import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
|
|
20
|
+
import type { CatalogChangeListener } from "./AccessibilityCatalogResolver.js";
|
|
20
21
|
import { ElementToolStateStore } from "./ElementToolStateStore.js";
|
|
21
22
|
import { type FrameworkErrorModel } from "./framework-error.js";
|
|
22
23
|
import { FrameworkErrorBus, type FrameworkErrorListener } from "./framework-error-bus.js";
|
|
@@ -29,10 +30,10 @@ import { ToolProviderRegistry } from "./tool-providers/index.js";
|
|
|
29
30
|
import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
|
|
30
31
|
import type { TTSToolProviderConfig } from "./tool-providers/index.js";
|
|
31
32
|
import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap, ToolRegistry } from "./ToolRegistry.js";
|
|
32
|
-
import { type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
|
|
33
|
+
import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
|
|
33
34
|
import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
|
|
34
35
|
export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";
|
|
35
|
-
export type { PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
|
|
36
|
+
export type { FeaturePolicyDecision, PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
|
|
36
37
|
/**
|
|
37
38
|
* Generic tool configuration
|
|
38
39
|
*/
|
|
@@ -659,6 +660,15 @@ export declare class ToolkitCoordinator {
|
|
|
659
660
|
* Thin shim over the owned tool-policy engine.
|
|
660
661
|
*/
|
|
661
662
|
decideToolPolicy(request: ToolPolicyDecisionRequest): ToolPolicyDecision;
|
|
663
|
+
/**
|
|
664
|
+
* Resolve eligibility for one PNP/AfA feature id, independent of toolbar
|
|
665
|
+
* placement.
|
|
666
|
+
*
|
|
667
|
+
* Thin shim over the owned tool-policy engine; see
|
|
668
|
+
* {@link ToolPolicyEngine.decideFeature} for the contract, including why
|
|
669
|
+
* `pnpEnforcement` is not consulted.
|
|
670
|
+
*/
|
|
671
|
+
decideFeaturePolicy(featureId: string): FeaturePolicyDecision;
|
|
662
672
|
/**
|
|
663
673
|
* Subscribe to policy-engine change events. Fires whenever the
|
|
664
674
|
* coordinator's bound inputs change (`updateToolConfig`,
|
|
@@ -678,6 +688,17 @@ export declare class ToolkitCoordinator {
|
|
|
678
688
|
* relying on a `disposed` event.
|
|
679
689
|
*/
|
|
680
690
|
onPolicyChange(listener: ToolPolicyChangeListener): () => void;
|
|
691
|
+
/**
|
|
692
|
+
* Subscribe to accessibility-catalog registrations and removals.
|
|
693
|
+
*
|
|
694
|
+
* Delegates to the owned resolver, the same way {@link onPolicyChange}
|
|
695
|
+
* delegates to the owned policy engine, so a consumer holding only the
|
|
696
|
+
* coordinator can react to both of the mutable inputs a catalog-backed
|
|
697
|
+
* capability depends on without reaching for the services directly.
|
|
698
|
+
*
|
|
699
|
+
* @returns Unsubscribe function
|
|
700
|
+
*/
|
|
701
|
+
onCatalogsChange(listener: CatalogChangeListener): () => void;
|
|
681
702
|
/**
|
|
682
703
|
* Bind (or clear) the active assessment for PNP/profile policy decisions.
|
|
683
704
|
*
|
|
@@ -1518,6 +1518,17 @@ export class ToolkitCoordinator {
|
|
|
1518
1518
|
decideToolPolicy(request) {
|
|
1519
1519
|
return this.policyEngine.decide(request);
|
|
1520
1520
|
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Resolve eligibility for one PNP/AfA feature id, independent of toolbar
|
|
1523
|
+
* placement.
|
|
1524
|
+
*
|
|
1525
|
+
* Thin shim over the owned tool-policy engine; see
|
|
1526
|
+
* {@link ToolPolicyEngine.decideFeature} for the contract, including why
|
|
1527
|
+
* `pnpEnforcement` is not consulted.
|
|
1528
|
+
*/
|
|
1529
|
+
decideFeaturePolicy(featureId) {
|
|
1530
|
+
return this.policyEngine.decideFeature(featureId);
|
|
1531
|
+
}
|
|
1521
1532
|
/**
|
|
1522
1533
|
* Subscribe to policy-engine change events. Fires whenever the
|
|
1523
1534
|
* coordinator's bound inputs change (`updateToolConfig`,
|
|
@@ -1539,6 +1550,19 @@ export class ToolkitCoordinator {
|
|
|
1539
1550
|
onPolicyChange(listener) {
|
|
1540
1551
|
return this.policyEngine.onPolicyChange(listener);
|
|
1541
1552
|
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Subscribe to accessibility-catalog registrations and removals.
|
|
1555
|
+
*
|
|
1556
|
+
* Delegates to the owned resolver, the same way {@link onPolicyChange}
|
|
1557
|
+
* delegates to the owned policy engine, so a consumer holding only the
|
|
1558
|
+
* coordinator can react to both of the mutable inputs a catalog-backed
|
|
1559
|
+
* capability depends on without reaching for the services directly.
|
|
1560
|
+
*
|
|
1561
|
+
* @returns Unsubscribe function
|
|
1562
|
+
*/
|
|
1563
|
+
onCatalogsChange(listener) {
|
|
1564
|
+
return this.catalogResolver.onCatalogsChange(listener);
|
|
1565
|
+
}
|
|
1542
1566
|
/**
|
|
1543
1567
|
* Bind (or clear) the active assessment for PNP/profile policy decisions.
|
|
1544
1568
|
*
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation primitives shared by every catalog card whose payload references
|
|
3
|
+
* media.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `sign-language-cards.ts` when `spoken` cards gained a recorded
|
|
6
|
+
* audio form: both card types take authored, wire-facing URLs and hand them to a
|
|
7
|
+
* media element in the learner's browser, so both need the same scheme
|
|
8
|
+
* allow-list and the same "treat as absent, never as partially valid" posture.
|
|
9
|
+
* Two copies of a URL allow-list is one copy that gets a fix and one that does
|
|
10
|
+
* not.
|
|
11
|
+
*
|
|
12
|
+
* Part of PIE Assessment Toolkit.
|
|
13
|
+
*/
|
|
14
|
+
import type { MediaFragmentRange, MediaSource } from "@pie-players/pie-players-shared/types";
|
|
15
|
+
export declare function isSafeMediaSrc(raw: unknown): raw is string;
|
|
16
|
+
export declare function normalizeMediaSources(raw: unknown): MediaSource[];
|
|
17
|
+
export declare function normalizeMediaFragment(raw: unknown): MediaFragmentRange | undefined;
|
|
18
|
+
export declare function trimmedOrUndefined(value: unknown): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Apply a fragment range to a source URL as a Media Fragments URI, so one
|
|
21
|
+
* recording can serve several content nodes. Browsers honour the start offset;
|
|
22
|
+
* the end offset is enforced by the caller, because support for the end bound is
|
|
23
|
+
* inconsistent.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyMediaFragment(src: string, fragment?: MediaFragmentRange): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation primitives shared by every catalog card whose payload references
|
|
3
|
+
* media.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `sign-language-cards.ts` when `spoken` cards gained a recorded
|
|
6
|
+
* audio form: both card types take authored, wire-facing URLs and hand them to a
|
|
7
|
+
* media element in the learner's browser, so both need the same scheme
|
|
8
|
+
* allow-list and the same "treat as absent, never as partially valid" posture.
|
|
9
|
+
* Two copies of a URL allow-list is one copy that gets a fix and one that does
|
|
10
|
+
* not.
|
|
11
|
+
*
|
|
12
|
+
* Part of PIE Assessment Toolkit.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Media source URLs are handed to a media element in the learner's browser. Only
|
|
16
|
+
* schemes such an element can actually fetch are allowed; anything else is
|
|
17
|
+
* dropped so an authored `javascript:` / `file:` URL cannot ride into the DOM.
|
|
18
|
+
* Relative and protocol-relative URLs are allowed — host content is commonly
|
|
19
|
+
* served from the same origin as the player.
|
|
20
|
+
*/
|
|
21
|
+
const DISALLOWED_SRC_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
22
|
+
const ALLOWED_SRC_SCHEMES = new Set(["http:", "https:", "data:", "blob:"]);
|
|
23
|
+
export function isSafeMediaSrc(raw) {
|
|
24
|
+
if (typeof raw !== "string")
|
|
25
|
+
return false;
|
|
26
|
+
const src = raw.trim();
|
|
27
|
+
if (!src)
|
|
28
|
+
return false;
|
|
29
|
+
// Relative ("/video.mp4", "video.mp4") and protocol-relative ("//cdn/x.mp4")
|
|
30
|
+
// forms carry no scheme to check and inherit the document's.
|
|
31
|
+
if (src.startsWith("//") || !DISALLOWED_SRC_SCHEME.test(src))
|
|
32
|
+
return true;
|
|
33
|
+
const scheme = src.slice(0, src.indexOf(":") + 1).toLowerCase();
|
|
34
|
+
return ALLOWED_SRC_SCHEMES.has(scheme);
|
|
35
|
+
}
|
|
36
|
+
export function normalizeMediaSources(raw) {
|
|
37
|
+
if (!Array.isArray(raw))
|
|
38
|
+
return [];
|
|
39
|
+
const sources = [];
|
|
40
|
+
for (const entry of raw) {
|
|
41
|
+
if (!entry || typeof entry !== "object")
|
|
42
|
+
continue;
|
|
43
|
+
const candidate = entry;
|
|
44
|
+
if (!isSafeMediaSrc(candidate.src))
|
|
45
|
+
continue;
|
|
46
|
+
const source = { src: candidate.src.trim() };
|
|
47
|
+
if (typeof candidate.type === "string" && candidate.type.trim()) {
|
|
48
|
+
source.type = candidate.type.trim();
|
|
49
|
+
}
|
|
50
|
+
if (Number.isFinite(candidate.width))
|
|
51
|
+
source.width = candidate.width;
|
|
52
|
+
if (Number.isFinite(candidate.height))
|
|
53
|
+
source.height = candidate.height;
|
|
54
|
+
// Deduplicated by `src`, because the signing region renders `<source>`
|
|
55
|
+
// elements in an `{#each}` keyed on exactly that: an authored card listing
|
|
56
|
+
// one URL twice — the same file under two MIME types is the plausible way —
|
|
57
|
+
// would otherwise throw Svelte's duplicate-key error and take the whole
|
|
58
|
+
// region down rather than degrade. The first entry wins, so authored order
|
|
59
|
+
// still decides which encoding the browser is offered first.
|
|
60
|
+
if (sources.some((existing) => existing.src === source.src))
|
|
61
|
+
continue;
|
|
62
|
+
sources.push(source);
|
|
63
|
+
}
|
|
64
|
+
return sources;
|
|
65
|
+
}
|
|
66
|
+
export function normalizeMediaFragment(raw) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
return undefined;
|
|
69
|
+
const candidate = raw;
|
|
70
|
+
const start = Number(candidate.startSeconds);
|
|
71
|
+
if (!Number.isFinite(start) || start < 0)
|
|
72
|
+
return undefined;
|
|
73
|
+
const end = Number(candidate.endSeconds);
|
|
74
|
+
// An end at or before the start would produce a zero/negative slice; treat
|
|
75
|
+
// it as "no end" rather than a range that can never play.
|
|
76
|
+
if (!Number.isFinite(end) || end <= start)
|
|
77
|
+
return { startSeconds: start };
|
|
78
|
+
return { startSeconds: start, endSeconds: end };
|
|
79
|
+
}
|
|
80
|
+
export function trimmedOrUndefined(value) {
|
|
81
|
+
if (typeof value !== "string")
|
|
82
|
+
return undefined;
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
return trimmed || undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Apply a fragment range to a source URL as a Media Fragments URI, so one
|
|
88
|
+
* recording can serve several content nodes. Browsers honour the start offset;
|
|
89
|
+
* the end offset is enforced by the caller, because support for the end bound is
|
|
90
|
+
* inconsistent.
|
|
91
|
+
*/
|
|
92
|
+
export function applyMediaFragment(src, fragment) {
|
|
93
|
+
if (!fragment)
|
|
94
|
+
return src;
|
|
95
|
+
// Never stack a second fragment onto a URL that already carries one — the
|
|
96
|
+
// authored value wins.
|
|
97
|
+
if (src.includes("#"))
|
|
98
|
+
return src;
|
|
99
|
+
const end = fragment.endSeconds !== undefined ? `,${fragment.endSeconds}` : "";
|
|
100
|
+
return `${src}#t=${fragment.startSeconds}${end}`;
|
|
101
|
+
}
|
|
@@ -1,3 +1,19 @@
|
|
|
1
1
|
import type { PersonalNeedsProfile } from "@pie-players/pie-players-shared/types";
|
|
2
|
+
/**
|
|
3
|
+
* PNP support ids that must never enter the computed default profile, however
|
|
4
|
+
* they reach the registry.
|
|
5
|
+
*
|
|
6
|
+
* `computeDefaultSupports()` derives the fallback profile from every registered
|
|
7
|
+
* tool's `pnpSupportIds`, which is right for universal features — a highlighter
|
|
8
|
+
* or a zoom control should be there for every student by default. It is wrong
|
|
9
|
+
* for an **accommodation**: signing requires a documented need (IEP / 504), so
|
|
10
|
+
* granting it to every host that does not supply its own profile would invert
|
|
11
|
+
* the eligibility tier. Registry membership is about being policy-addressable,
|
|
12
|
+
* not about who may enable it.
|
|
13
|
+
*
|
|
14
|
+
* Excluded here rather than by declining to register, so the guarantee holds
|
|
15
|
+
* even if a signing tool is later registered for some other reason.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ACCOMMODATION_ONLY_SUPPORT_IDS: readonly string[];
|
|
2
18
|
export declare const DEFAULT_PERSONAL_NEEDS_PROFILE: PersonalNeedsProfile;
|
|
3
19
|
export declare function createDefaultPersonalNeedsProfile(): PersonalNeedsProfile;
|