@pie-players/pie-assessment-toolkit 0.3.51 → 0.3.53

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.
@@ -74,6 +74,7 @@ export class TTSService {
74
74
  currentProvider = null;
75
75
  provider = null;
76
76
  highlightCoordinator = null;
77
+ highlightTargetResolverProvider = null;
77
78
  catalogResolver = null;
78
79
  state = PlaybackState.IDLE;
79
80
  ttsConfig = {};
@@ -86,6 +87,7 @@ export class TTSService {
86
87
  currentBoundaryOffset = 0;
87
88
  activeWordBoundaryOffset = 0;
88
89
  seekSegments = [];
90
+ playbackChunks = [];
89
91
  sentenceHighlightSegments = [];
90
92
  currentSeekSegmentIndex = 0;
91
93
  activeSentenceStartOffset = null;
@@ -210,6 +212,17 @@ export class TTSService {
210
212
  setHighlightCoordinator(coordinator) {
211
213
  this.highlightCoordinator = coordinator;
212
214
  }
215
+ /**
216
+ * Set a late-bound provider for optional host TTS highlight target remapping.
217
+ */
218
+ setHighlightTargetResolverProvider(provider) {
219
+ this.highlightTargetResolverProvider = provider;
220
+ return () => {
221
+ if (this.highlightTargetResolverProvider === provider) {
222
+ this.highlightTargetResolverProvider = null;
223
+ }
224
+ };
225
+ }
213
226
  /**
214
227
  * Set accessibility catalog resolver for spoken content
215
228
  *
@@ -221,6 +234,145 @@ export class TTSService {
221
234
  setCatalogResolver(resolver) {
222
235
  this.catalogResolver = resolver;
223
236
  }
237
+ getHighlightResolverRuntime() {
238
+ let provided = null;
239
+ try {
240
+ provided = this.highlightTargetResolverProvider?.() ?? null;
241
+ }
242
+ catch {
243
+ provided = null;
244
+ }
245
+ const providedContext = provided?.context ?? {};
246
+ const fallbackScope = typeof HTMLElement !== "undefined" &&
247
+ this.currentContentElement instanceof HTMLElement
248
+ ? this.currentContentElement
249
+ : null;
250
+ return {
251
+ context: {
252
+ ...providedContext,
253
+ scopeElement: providedContext.scopeElement ?? fallbackScope,
254
+ },
255
+ resolver: provided?.resolver ?? null,
256
+ };
257
+ }
258
+ getNodeElement(node) {
259
+ if (!node)
260
+ return null;
261
+ return node.nodeType === Node.ELEMENT_NODE
262
+ ? node
263
+ : node.parentElement;
264
+ }
265
+ isElementWithinScope(element, scope) {
266
+ if (!scope)
267
+ return true;
268
+ return element === scope || scope.contains(element);
269
+ }
270
+ isRangeWithinScope(range, scope) {
271
+ const startElement = this.getNodeElement(range.startContainer);
272
+ const endElement = this.getNodeElement(range.endContainer);
273
+ if (!startElement || !endElement)
274
+ return false;
275
+ return (this.isElementWithinScope(startElement, scope) &&
276
+ this.isElementWithinScope(endElement, scope));
277
+ }
278
+ resolveWordHighlightRange(nativeRange) {
279
+ const { context, resolver } = this.getHighlightResolverRuntime();
280
+ const scope = context.scopeElement ?? this.currentContentElement;
281
+ try {
282
+ const resolved = resolver?.resolveWordRange?.(nativeRange, context);
283
+ if (resolved && this.isRangeWithinScope(resolved, scope)) {
284
+ return { range: resolved, remapped: !sameRange(nativeRange, resolved) };
285
+ }
286
+ }
287
+ catch {
288
+ // Fail open: custom resolver failures must not interrupt TTS playback.
289
+ }
290
+ return { range: nativeRange, remapped: false };
291
+ }
292
+ resolveSentenceHighlightTargets(nativeRanges) {
293
+ const { context, resolver } = this.getHighlightResolverRuntime();
294
+ const scope = context.scopeElement ?? this.currentContentElement;
295
+ try {
296
+ const resolved = resolver?.resolveSentenceRanges?.(nativeRanges, context);
297
+ if (!resolved)
298
+ return { ranges: nativeRanges, elements: [] };
299
+ const ranges = [];
300
+ const elements = [];
301
+ for (const target of resolved) {
302
+ if (typeof Range !== "undefined" && target instanceof Range) {
303
+ if (!this.isRangeWithinScope(target, scope)) {
304
+ return { ranges: nativeRanges, elements: [] };
305
+ }
306
+ ranges.push(target);
307
+ continue;
308
+ }
309
+ if (typeof Element !== "undefined" && target instanceof Element) {
310
+ if (!this.isElementWithinScope(target, scope)) {
311
+ return { ranges: nativeRanges, elements: [] };
312
+ }
313
+ elements.push(target);
314
+ continue;
315
+ }
316
+ return { ranges: nativeRanges, elements: [] };
317
+ }
318
+ return { ranges, elements };
319
+ }
320
+ catch {
321
+ // Fail open: custom resolver failures must not interrupt TTS playback.
322
+ return { ranges: nativeRanges, elements: [] };
323
+ }
324
+ }
325
+ createTextRange(node, startOffset, endOffset) {
326
+ if (typeof document === "undefined")
327
+ return null;
328
+ if (typeof document.createRange !== "function")
329
+ return null;
330
+ try {
331
+ const range = document.createRange();
332
+ range.setStart(node, startOffset);
333
+ range.setEnd(node, endOffset);
334
+ if (range.startContainer !== node || range.endContainer !== node) {
335
+ return null;
336
+ }
337
+ return range;
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ }
343
+ paintTTSWordRange(range) {
344
+ if (!this.highlightCoordinator)
345
+ return;
346
+ try {
347
+ if (range.startContainer === range.endContainer &&
348
+ range.startContainer.nodeType === Node.TEXT_NODE) {
349
+ this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
350
+ return;
351
+ }
352
+ }
353
+ catch {
354
+ // Fall through to generic range painting for lightweight test doubles.
355
+ }
356
+ this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
357
+ }
358
+ paintTTSSentenceRanges(nativeRanges) {
359
+ if (!this.highlightCoordinator)
360
+ return;
361
+ const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
362
+ if (elements.length > 0 &&
363
+ ranges.length === 0 &&
364
+ this.highlightCoordinator.highlightTTSSentenceElements) {
365
+ this.highlightCoordinator.highlightTTSSentenceElements(elements);
366
+ return;
367
+ }
368
+ const paintedRanges = [...ranges];
369
+ for (const element of elements) {
370
+ const elementRange = document.createRange();
371
+ elementRange.selectNodeContents(element);
372
+ paintedRanges.push(elementRange);
373
+ }
374
+ this.highlightCoordinator.highlightTTSSentence(paintedRanges);
375
+ }
224
376
  /**
225
377
  * Get provider capabilities
226
378
  */
@@ -554,6 +706,24 @@ export class TTSService {
554
706
  .filter((offset) => offset > 0);
555
707
  return this.splitSegmentsAtBoundaries(grammaticalSegments, structuralBoundaries, args.contentToSpeak);
556
708
  }
709
+ createSeekSegmentsFromSpeechChunks(chunks) {
710
+ let offset = 0;
711
+ return chunks.map((chunk) => {
712
+ const speechText = chunk.plainFallback?.speechText ||
713
+ (this.hasExplicitBreakSemantics(chunk.speechText)
714
+ ? chunk.visibleText
715
+ : chunk.speechText) ||
716
+ chunk.visibleText;
717
+ const text = normalizeTextForSpeech(speechText) || chunk.visibleText || "";
718
+ const segment = {
719
+ text,
720
+ startOffset: offset,
721
+ pauseMsAfter: 0,
722
+ };
723
+ offset += Math.max(1, text.length + 1);
724
+ return segment;
725
+ });
726
+ }
557
727
  getCurrentSeekSegmentIndex() {
558
728
  if (this.seekSegments.length === 0)
559
729
  return 0;
@@ -629,7 +799,7 @@ export class TTSService {
629
799
  const range = document.createRange();
630
800
  range.setStart(start.node, start.offset);
631
801
  range.setEnd(end.node, end.offset + 1);
632
- this.highlightCoordinator.highlightTTSSentence([range]);
802
+ this.paintTTSSentenceRanges([range]);
633
803
  this.activeSentenceStartOffset = startOffset;
634
804
  }
635
805
  getSegmentIndexForOffset(segments, offset) {
@@ -743,8 +913,10 @@ export class TTSService {
743
913
  !usedCatalogSpoken &&
744
914
  !hasExplicitBreaks &&
745
915
  speechMatchesVisibleText;
746
- this.seekSegments =
747
- hasExplicitBreaks || !speechMatchesVisibleText
916
+ this.playbackChunks = speechChunks?.length ? speechChunks : [];
917
+ this.seekSegments = this.playbackChunks.length
918
+ ? this.createSeekSegmentsFromSpeechChunks(this.playbackChunks)
919
+ : hasExplicitBreaks || !speechMatchesVisibleText
748
920
  ? []
749
921
  : shouldUsePlan && this.currentContentElement
750
922
  ? this.createSpeechPlan(this.currentContentElement, normalizedText)
@@ -1155,6 +1327,7 @@ export class TTSService {
1155
1327
  this.currentContentElement = options?.contentElement || null;
1156
1328
  this.lastError = null;
1157
1329
  this.currentBoundaryOffset = 0;
1330
+ this.playbackChunks = [];
1158
1331
  this.currentSeekSegmentIndex = 0;
1159
1332
  this.activeSentenceStartOffset = null;
1160
1333
  }
@@ -1174,7 +1347,7 @@ export class TTSService {
1174
1347
  try {
1175
1348
  const range = document.createRange();
1176
1349
  range.selectNodeContents(this.currentContentElement);
1177
- this.highlightCoordinator.highlightTTSSentence([range]);
1350
+ this.paintTTSSentenceRanges([range]);
1178
1351
  }
1179
1352
  catch {
1180
1353
  // No-op fallback when DOM range creation fails.
@@ -1198,7 +1371,13 @@ export class TTSService {
1198
1371
  if (highlightRange && this.highlightCoordinator) {
1199
1372
  const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
1200
1373
  this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
1201
- this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1374
+ const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
1375
+ if (nativeRange) {
1376
+ this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1377
+ }
1378
+ else {
1379
+ this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
1380
+ }
1202
1381
  }
1203
1382
  else {
1204
1383
  console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
@@ -1218,7 +1397,7 @@ export class TTSService {
1218
1397
  this.lastRenderedRegionTarget = null;
1219
1398
  if (chunk.regionRange) {
1220
1399
  this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1221
- this.highlightCoordinator.highlightTTSSentence([chunk.regionRange]);
1400
+ this.paintTTSSentenceRanges([chunk.regionRange]);
1222
1401
  return;
1223
1402
  }
1224
1403
  if (!element)
@@ -1226,7 +1405,7 @@ export class TTSService {
1226
1405
  const range = document.createRange();
1227
1406
  range.selectNodeContents(element);
1228
1407
  this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
1229
- this.highlightCoordinator.highlightTTSSentence([range]);
1408
+ this.paintTTSSentenceRanges([range]);
1230
1409
  }
1231
1410
  catch {
1232
1411
  // Continue playback even when a detached node cannot be highlighted.
@@ -1235,12 +1414,7 @@ export class TTSService {
1235
1414
  highlightCatalogActiveRange(range) {
1236
1415
  if (!this.highlightCoordinator)
1237
1416
  return;
1238
- if (range.startContainer === range.endContainer &&
1239
- range.startContainer.nodeType === Node.TEXT_NODE) {
1240
- this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
1241
- return;
1242
- }
1243
- this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
1417
+ this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
1244
1418
  }
1245
1419
  highlightRenderableRegionTarget(target) {
1246
1420
  if (!this.highlightCoordinator ||
@@ -1248,7 +1422,8 @@ export class TTSService {
1248
1422
  typeof document === "undefined") {
1249
1423
  return;
1250
1424
  }
1251
- if (sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
1425
+ if (!this.highlightTargetResolverProvider &&
1426
+ sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
1252
1427
  return;
1253
1428
  }
1254
1429
  try {
@@ -1257,7 +1432,7 @@ export class TTSService {
1257
1432
  range.selectNodeContents(target.element);
1258
1433
  }
1259
1434
  else if (target.type === "range") {
1260
- this.highlightCoordinator.highlightTTSSentence([target.range]);
1435
+ this.paintTTSSentenceRanges([target.range]);
1261
1436
  this.lastRenderedRegionTarget = target;
1262
1437
  return;
1263
1438
  }
@@ -1265,7 +1440,7 @@ export class TTSService {
1265
1440
  range.setStart(target.node, target.startOffset);
1266
1441
  range.setEnd(target.node, target.endOffset);
1267
1442
  }
1268
- this.highlightCoordinator.highlightTTSSentence([range]);
1443
+ this.paintTTSSentenceRanges([range]);
1269
1444
  this.lastRenderedRegionTarget = target;
1270
1445
  }
1271
1446
  catch {
@@ -1286,13 +1461,26 @@ export class TTSService {
1286
1461
  return;
1287
1462
  }
1288
1463
  if (target.type === "text-range") {
1289
- this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1464
+ const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
1465
+ if (nativeRange) {
1466
+ this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
1467
+ }
1468
+ else {
1469
+ this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
1470
+ }
1290
1471
  return;
1291
1472
  }
1292
1473
  if (target.type === "range") {
1293
1474
  this.highlightCatalogActiveRange(target.range);
1294
1475
  return;
1295
1476
  }
1477
+ const elementNativeRange = document.createRange();
1478
+ elementNativeRange.selectNodeContents(target.element);
1479
+ const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
1480
+ if (resolvedElementRange.remapped) {
1481
+ this.paintTTSWordRange(resolvedElementRange.range);
1482
+ return;
1483
+ }
1296
1484
  // Element targets are atomic: a resolved math token, a whole-expression
1297
1485
  // fallback, or a replaced element (image/svg). Paint exactly the resolved
1298
1486
  // element. Native MathML / HTML tokens that expose a single text node keep
@@ -1413,9 +1601,15 @@ export class TTSService {
1413
1601
  }
1414
1602
  async executeSpeakPlayback(args) {
1415
1603
  if (args.speechChunks?.length && this.provider) {
1416
- for (const chunk of args.speechChunks) {
1604
+ for (let index = 0; index < args.speechChunks.length; index++) {
1417
1605
  if (args.runId !== this.speakRunId)
1418
1606
  return;
1607
+ this.currentSeekSegmentIndex = index;
1608
+ const segment = this.seekSegments[index];
1609
+ if (segment) {
1610
+ this.currentBoundaryOffset = segment.startOffset;
1611
+ }
1612
+ const chunk = args.speechChunks[index];
1419
1613
  await this.speakCatalogChunk(chunk, args.runId);
1420
1614
  }
1421
1615
  return;
@@ -1446,6 +1640,7 @@ export class TTSService {
1446
1640
  this.currentBoundaryOffset = 0;
1447
1641
  this.activeWordBoundaryOffset = 0;
1448
1642
  this.seekSegments = [];
1643
+ this.playbackChunks = [];
1449
1644
  this.sentenceHighlightSegments = [];
1450
1645
  this.currentSeekSegmentIndex = 0;
1451
1646
  this.activeSentenceStartOffset = null;
@@ -1530,6 +1725,9 @@ export class TTSService {
1530
1725
  if (!this.provider || this.seekSegments.length === 0)
1531
1726
  return;
1532
1727
  const safeTargetIndex = Math.max(0, Math.min(this.seekSegments.length - 1, targetIndex));
1728
+ const restartChunks = this.playbackChunks.length
1729
+ ? this.playbackChunks.slice(safeTargetIndex)
1730
+ : null;
1533
1731
  this.speakRunId += 1;
1534
1732
  this.provider.onWordBoundary = undefined;
1535
1733
  this.provider.stop();
@@ -1542,13 +1740,29 @@ export class TTSService {
1542
1740
  highlightMode: this.activeHighlightMode,
1543
1741
  wordBoundaryOffset: this.activeWordBoundaryOffset,
1544
1742
  });
1545
- await this.speakWithPlan(restartSegments, runId, {
1546
- highlightMode: this.activeHighlightMode,
1547
- });
1743
+ if (restartChunks) {
1744
+ for (let index = 0; index < restartChunks.length; index++) {
1745
+ if (runId !== this.speakRunId)
1746
+ return;
1747
+ const absoluteIndex = safeTargetIndex + index;
1748
+ this.currentSeekSegmentIndex = absoluteIndex;
1749
+ const segment = this.seekSegments[absoluteIndex];
1750
+ if (segment) {
1751
+ this.currentBoundaryOffset = segment.startOffset;
1752
+ }
1753
+ await this.speakCatalogChunk(restartChunks[index], runId);
1754
+ }
1755
+ }
1756
+ else {
1757
+ await this.speakWithPlan(restartSegments, runId, {
1758
+ highlightMode: this.activeHighlightMode,
1759
+ });
1760
+ }
1548
1761
  if (runId !== this.speakRunId)
1549
1762
  return;
1550
1763
  this.setState(PlaybackState.IDLE);
1551
1764
  this.clearHighlightsAndTracking();
1765
+ this.highlightTargetResolverProvider = null;
1552
1766
  }
1553
1767
  catch (error) {
1554
1768
  if (runId !== this.speakRunId)
@@ -1556,6 +1770,7 @@ export class TTSService {
1556
1770
  this.lastError = error instanceof Error ? error.message : String(error);
1557
1771
  this.setState(PlaybackState.ERROR);
1558
1772
  this.clearHighlightsAndTracking();
1773
+ this.highlightTargetResolverProvider = null;
1559
1774
  throw error;
1560
1775
  }
1561
1776
  }
@@ -1607,6 +1822,7 @@ export class TTSService {
1607
1822
  this.speakRunId += 1;
1608
1823
  this.provider.onWordBoundary = undefined;
1609
1824
  this.provider.stop();
1825
+ this.highlightTargetResolverProvider = null;
1610
1826
  this.setState(PlaybackState.IDLE);
1611
1827
  this.currentText = null;
1612
1828
  // Clear highlights
@@ -1620,6 +1836,7 @@ export class TTSService {
1620
1836
  this.currentBoundaryOffset = 0;
1621
1837
  this.activeWordBoundaryOffset = 0;
1622
1838
  this.seekSegments = [];
1839
+ this.playbackChunks = [];
1623
1840
  this.sentenceHighlightSegments = [];
1624
1841
  this.currentSeekSegmentIndex = 0;
1625
1842
  this.activeSentenceStartOffset = null;