@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.
- package/README.md +92 -1
- 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-cckwpz6c.js +51 -0
- package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +12 -4
- 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/runtime/composition-emit-scheduler.d.ts +78 -0
- package/dist/runtime/composition-emit-scheduler.js +154 -0
- package/dist/runtime/core/engine-resolver.d.ts +1 -1
- 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/ToolRegistry.d.ts +218 -8
- package/dist/services/ToolRegistry.js +124 -8
- package/dist/services/ToolkitCoordinator.d.ts +25 -3
- package/dist/services/ToolkitCoordinator.js +47 -6
- package/dist/services/catalog-media.d.ts +25 -0
- package/dist/services/catalog-media.js +101 -0
- package/dist/services/createDefaultToolRegistry.d.ts +25 -58
- package/dist/services/createDefaultToolRegistry.js +24 -104
- package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
- package/dist/services/defaultPersonalNeedsProfile.js +21 -19
- package/dist/services/interfaces.d.ts +29 -2
- package/dist/services/pnp-standard-features.d.ts +1 -1
- package/dist/services/spoken-audio-cards.d.ts +54 -0
- package/dist/services/spoken-audio-cards.js +66 -0
- package/dist/services/tool-config-defaults.d.ts +7 -23
- package/dist/services/tool-config-defaults.js +7 -46
- package/dist/services/tool-config-validation.d.ts +1 -1
- package/dist/services/tool-config-validation.js +44 -4
- package/dist/services/tts/browser-provider.js +2 -1
- 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/dist/services/tts-runtime-config.js +7 -2
- package/dist/tools/internal.d.ts +34 -0
- package/dist/tools/internal.js +33 -0
- package/dist/tools/tool-tag-map.d.ts +15 -3
- package/dist/tools/tool-tag-map.js +21 -18
- package/package.json +14 -10
- package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
- package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
- package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
- package/dist/tools/registrations/accessibility-tools.js +0 -217
- package/dist/tools/registrations/calculator.d.ts +0 -20
- package/dist/tools/registrations/calculator.js +0 -228
- package/dist/tools/registrations/interaction-tools.d.ts +0 -27
- package/dist/tools/registrations/interaction-tools.js +0 -143
- package/dist/tools/registrations/measurement-tools.d.ts +0 -24
- package/dist/tools/registrations/measurement-tools.js +0 -130
- package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
- package/dist/tools/registrations/subject-specific-tools.js +0 -158
- package/dist/tools/registrations/tts.d.ts +0 -21
- 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,
|
|
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;
|