@pie-players/pie-assessment-toolkit 0.3.65 → 0.3.67
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 +36 -16
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +9 -8
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/{ItemToolBar-cckwpz6c.js → ItemToolBar-8jgdz50p.js} +9 -9
- package/dist/components/chunks/ItemToolBar-cvs646j3.js +36 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/policy/core/PolicySource.d.ts +2 -2
- package/dist/policy/core/PolicySource.js +2 -2
- package/dist/policy/core/ToolPolicyEngine.d.ts +19 -3
- package/dist/policy/core/ToolPolicyEngine.js +39 -5
- package/dist/policy/core/compose-decision.d.ts +3 -2
- package/dist/policy/core/compose-decision.js +3 -2
- package/dist/policy/core/decision-types.d.ts +2 -2
- package/dist/policy/core/decision-types.js +2 -2
- package/dist/policy/core/feature-decision.d.ts +56 -3
- package/dist/policy/core/feature-decision.js +60 -3
- package/dist/policy/core/pnp-policy-inputs.d.ts +2 -2
- package/dist/policy/core/pnp-policy-inputs.js +2 -2
- package/dist/policy/core/provenance.d.ts +4 -1
- package/dist/policy/core/provenance.js +4 -1
- package/dist/policy/engine.d.ts +1 -1
- package/dist/policy/engine.js +1 -0
- package/dist/policy/sources/PnpPolicySource.d.ts +2 -1
- package/dist/policy/sources/PnpPolicySource.js +2 -1
- package/dist/runtime/core/engine-transition.js +3 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +49 -10
- package/dist/services/AccessibilityCatalogResolver.js +180 -11
- package/dist/services/TTSService.d.ts +11 -0
- package/dist/services/TTSService.js +220 -32
- package/dist/services/ToolRegistry.d.ts +55 -14
- package/dist/services/ToolRegistry.js +57 -3
- package/dist/services/ToolkitCoordinator.d.ts +18 -1
- package/dist/services/ToolkitCoordinator.js +24 -2
- package/dist/services/catalog-owner.d.ts +71 -0
- package/dist/services/catalog-owner.js +64 -0
- package/dist/services/framework-error.d.ts +1 -1
- package/dist/services/interfaces.d.ts +15 -3
- package/dist/services/tts/browser-provider.js +189 -23
- package/dist/tools/content-capability-resolution.d.ts +106 -0
- package/dist/tools/content-capability-resolution.js +136 -0
- package/dist/tools/internal.d.ts +3 -0
- package/dist/tools/internal.js +1 -0
- package/package.json +13 -9
- package/dist/components/chunks/ItemToolBar-pryf0rtz.js +0 -22
- package/dist/runtime/catalog-registration.d.ts +0 -67
- package/dist/runtime/catalog-registration.js +0 -86
|
@@ -97,6 +97,13 @@ export class TTSService {
|
|
|
97
97
|
sentenceHighlightSegments = [];
|
|
98
98
|
currentSeekSegmentIndex = 0;
|
|
99
99
|
activeSentenceStartOffset = null;
|
|
100
|
+
playbackStartDeferredRunId = null;
|
|
101
|
+
pendingPlaybackStartHighlights = [];
|
|
102
|
+
playbackStartBarrierCleanup = null;
|
|
103
|
+
playbackRateWriteQueue = Promise.resolve();
|
|
104
|
+
playbackRateRequestId = 0;
|
|
105
|
+
pendingPlaybackRateRequest = null;
|
|
106
|
+
activePlaybackRate = null;
|
|
100
107
|
activeHighlightMode = "word";
|
|
101
108
|
lastRenderedRegionTarget = null;
|
|
102
109
|
telemetryReporter = null;
|
|
@@ -130,6 +137,61 @@ export class TTSService {
|
|
|
130
137
|
console.debug(message, detail);
|
|
131
138
|
}
|
|
132
139
|
}
|
|
140
|
+
clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
|
|
141
|
+
cleanup?.();
|
|
142
|
+
if (this.playbackStartBarrierCleanup === cleanup) {
|
|
143
|
+
this.playbackStartBarrierCleanup = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
notifyPlaybackStarted(runId) {
|
|
147
|
+
if (this.playbackStartDeferredRunId !== runId ||
|
|
148
|
+
runId !== this.speakRunId) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.setState(PlaybackState.PLAYING);
|
|
152
|
+
const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
|
|
153
|
+
for (const applyHighlight of pendingHighlights) {
|
|
154
|
+
applyHighlight();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
|
|
158
|
+
this.clearPlaybackStartBarrier();
|
|
159
|
+
const provider = this.provider;
|
|
160
|
+
const startAwareProvider = provider && "onPlaybackStart" in provider ? provider : null;
|
|
161
|
+
if (!startAwareProvider && !hasMediaStartSignal)
|
|
162
|
+
return null;
|
|
163
|
+
const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
|
|
164
|
+
const onPlaybackStart = () => {
|
|
165
|
+
try {
|
|
166
|
+
this.notifyPlaybackStarted(runId);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
previousOnPlaybackStart?.();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
if (startAwareProvider) {
|
|
173
|
+
startAwareProvider.onPlaybackStart = onPlaybackStart;
|
|
174
|
+
}
|
|
175
|
+
this.playbackStartDeferredRunId = runId;
|
|
176
|
+
const cleanup = () => {
|
|
177
|
+
if (startAwareProvider?.onPlaybackStart === onPlaybackStart) {
|
|
178
|
+
startAwareProvider.onPlaybackStart = previousOnPlaybackStart;
|
|
179
|
+
}
|
|
180
|
+
if (this.playbackStartDeferredRunId === runId) {
|
|
181
|
+
this.playbackStartDeferredRunId = null;
|
|
182
|
+
this.pendingPlaybackStartHighlights = [];
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
this.playbackStartBarrierCleanup = cleanup;
|
|
186
|
+
return cleanup;
|
|
187
|
+
}
|
|
188
|
+
runWhenPlaybackStarts(runId, callback) {
|
|
189
|
+
if (this.playbackStartDeferredRunId === runId) {
|
|
190
|
+
this.pendingPlaybackStartHighlights.push(callback);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
callback();
|
|
194
|
+
}
|
|
133
195
|
async emitTelemetry(eventName, payload) {
|
|
134
196
|
try {
|
|
135
197
|
await this.telemetryReporter?.(eventName, payload);
|
|
@@ -810,7 +872,9 @@ export class TTSService {
|
|
|
810
872
|
}
|
|
811
873
|
this.currentBoundaryOffset = segment.startOffset;
|
|
812
874
|
if (shouldTrackSentenceProgress) {
|
|
813
|
-
this.
|
|
875
|
+
this.runWhenPlaybackStarts(runId, () => {
|
|
876
|
+
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
877
|
+
});
|
|
814
878
|
}
|
|
815
879
|
await this.provider.speak(segment.text);
|
|
816
880
|
const pauseMs = segment.pauseMsAfter ?? 0;
|
|
@@ -923,6 +987,8 @@ export class TTSService {
|
|
|
923
987
|
throw new Error("TTS service not initialized");
|
|
924
988
|
}
|
|
925
989
|
const runId = ++this.speakRunId;
|
|
990
|
+
this.clearPlaybackStartBarrier();
|
|
991
|
+
let playbackStartBarrier = null;
|
|
926
992
|
try {
|
|
927
993
|
await this.applyLanguageSettings(options);
|
|
928
994
|
if (runId !== this.speakRunId)
|
|
@@ -962,11 +1028,13 @@ export class TTSService {
|
|
|
962
1028
|
playbackSegments: this.seekSegments,
|
|
963
1029
|
});
|
|
964
1030
|
this.setState(PlaybackState.LOADING);
|
|
1031
|
+
playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
|
|
965
1032
|
this.prepareHighlightsForSpeak({
|
|
966
1033
|
contentToSpeak: highlightText,
|
|
967
1034
|
options,
|
|
968
1035
|
highlightMode,
|
|
969
1036
|
shouldUsePlan,
|
|
1037
|
+
runId,
|
|
970
1038
|
});
|
|
971
1039
|
if (speechMatchesVisibleText) {
|
|
972
1040
|
this.configureWordBoundaryHighlighting({
|
|
@@ -977,7 +1045,10 @@ export class TTSService {
|
|
|
977
1045
|
else {
|
|
978
1046
|
this.clearWordBoundaryHighlighting();
|
|
979
1047
|
}
|
|
980
|
-
|
|
1048
|
+
if (!playbackStartBarrier) {
|
|
1049
|
+
this.setState(PlaybackState.PLAYING);
|
|
1050
|
+
}
|
|
1051
|
+
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
981
1052
|
await this.executeSpeakPlayback({
|
|
982
1053
|
shouldUsePlan,
|
|
983
1054
|
runId,
|
|
@@ -1001,13 +1072,30 @@ export class TTSService {
|
|
|
1001
1072
|
this.clearHighlightsAndTracking();
|
|
1002
1073
|
throw finalError;
|
|
1003
1074
|
}
|
|
1075
|
+
finally {
|
|
1076
|
+
this.clearPlaybackStartBarrier(playbackStartBarrier);
|
|
1077
|
+
}
|
|
1004
1078
|
}
|
|
1005
1079
|
async switchProviderToBrowser(reason, context) {
|
|
1006
1080
|
if (!this.currentProvider)
|
|
1007
1081
|
return false;
|
|
1008
1082
|
const fallbackProvider = new BrowserTTSProvider();
|
|
1009
1083
|
const previousProviderId = this.currentProvider.providerId;
|
|
1010
|
-
|
|
1084
|
+
// Voice identifiers and provider extensions belong to the provider that
|
|
1085
|
+
// rejected initialization. Carry only settings whose meaning is portable
|
|
1086
|
+
// when crossing the provider boundary; otherwise a Polly/Google/SC voice
|
|
1087
|
+
// such as "Joanna" can incorrectly become an explicit Browser voice.
|
|
1088
|
+
const browserConfig = {};
|
|
1089
|
+
if (this.ttsConfig.rate !== undefined) {
|
|
1090
|
+
browserConfig.rate = this.ttsConfig.rate;
|
|
1091
|
+
}
|
|
1092
|
+
if (this.ttsConfig.pitch !== undefined) {
|
|
1093
|
+
browserConfig.pitch = this.ttsConfig.pitch;
|
|
1094
|
+
}
|
|
1095
|
+
if (this.ttsConfig.mathTokenHighlighting !== undefined) {
|
|
1096
|
+
browserConfig.mathTokenHighlighting =
|
|
1097
|
+
this.ttsConfig.mathTokenHighlighting;
|
|
1098
|
+
}
|
|
1011
1099
|
const operation = context?.operation || "tts-initialize";
|
|
1012
1100
|
const offendingText = context?.contentToSpeak ?? "";
|
|
1013
1101
|
const offendingPreview = offendingText
|
|
@@ -1431,20 +1519,22 @@ export class TTSService {
|
|
|
1431
1519
|
return;
|
|
1432
1520
|
}
|
|
1433
1521
|
const initialSegment = this.sentenceHighlightSegments[0] || this.seekSegments[0];
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
else {
|
|
1439
|
-
try {
|
|
1440
|
-
const range = document.createRange();
|
|
1441
|
-
range.selectNodeContents(this.currentContentElement);
|
|
1442
|
-
this.paintTTSSentenceRanges([range]);
|
|
1522
|
+
this.runWhenPlaybackStarts(args.runId, () => {
|
|
1523
|
+
if (initialSegment) {
|
|
1524
|
+
this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
|
|
1525
|
+
this.debugLog("[TTSService] Applied initial sentence highlighting");
|
|
1443
1526
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1527
|
+
else {
|
|
1528
|
+
try {
|
|
1529
|
+
const range = document.createRange();
|
|
1530
|
+
range.selectNodeContents(this.currentContentElement);
|
|
1531
|
+
this.paintTTSSentenceRanges([range]);
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
// No-op fallback when DOM range creation fails.
|
|
1535
|
+
}
|
|
1446
1536
|
}
|
|
1447
|
-
}
|
|
1537
|
+
});
|
|
1448
1538
|
}
|
|
1449
1539
|
configureWordBoundaryHighlighting(args) {
|
|
1450
1540
|
if (!this.provider ||
|
|
@@ -1605,6 +1695,16 @@ export class TTSService {
|
|
|
1605
1695
|
// fallback, so this cannot recurse.
|
|
1606
1696
|
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1607
1697
|
this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
|
|
1698
|
+
// A recorded-first run can own the start barrier even when its fallback
|
|
1699
|
+
// provider has no formal start signal. Drop the failed recording's queued
|
|
1700
|
+
// highlight and resume that non-start-aware provider's immediate-start lifecycle.
|
|
1701
|
+
if (this.playbackStartDeferredRunId === runId &&
|
|
1702
|
+
this.provider &&
|
|
1703
|
+
!("onPlaybackStart" in this.provider)) {
|
|
1704
|
+
this.pendingPlaybackStartHighlights = [];
|
|
1705
|
+
this.clearPlaybackStartBarrier();
|
|
1706
|
+
this.setState(PlaybackState.PLAYING);
|
|
1707
|
+
}
|
|
1608
1708
|
await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
|
|
1609
1709
|
return;
|
|
1610
1710
|
}
|
|
@@ -1621,7 +1721,7 @@ export class TTSService {
|
|
|
1621
1721
|
* would highlight the wrong words confidently rather than the right region
|
|
1622
1722
|
* vaguely.
|
|
1623
1723
|
*/
|
|
1624
|
-
async playRecordedAudio(media) {
|
|
1724
|
+
async playRecordedAudio(media, onPlaybackStart) {
|
|
1625
1725
|
if (typeof document === "undefined") {
|
|
1626
1726
|
throw new Error("[tts] no document available to play recorded audio");
|
|
1627
1727
|
}
|
|
@@ -1632,9 +1732,18 @@ export class TTSService {
|
|
|
1632
1732
|
element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
|
|
1633
1733
|
await new Promise((resolve, reject) => {
|
|
1634
1734
|
let endGuard;
|
|
1735
|
+
let didStart = false;
|
|
1736
|
+
let settled = false;
|
|
1737
|
+
const markStarted = () => {
|
|
1738
|
+
if (settled || didStart)
|
|
1739
|
+
return;
|
|
1740
|
+
didStart = true;
|
|
1741
|
+
onPlaybackStart?.();
|
|
1742
|
+
};
|
|
1635
1743
|
const cleanup = () => {
|
|
1636
1744
|
element.removeEventListener("ended", onEnded);
|
|
1637
1745
|
element.removeEventListener("error", onError);
|
|
1746
|
+
element.removeEventListener("playing", markStarted);
|
|
1638
1747
|
if (endGuard !== undefined)
|
|
1639
1748
|
clearInterval(endGuard);
|
|
1640
1749
|
if (this.activeRecordedAudio?.element === element) {
|
|
@@ -1642,10 +1751,16 @@ export class TTSService {
|
|
|
1642
1751
|
}
|
|
1643
1752
|
};
|
|
1644
1753
|
const onEnded = () => {
|
|
1754
|
+
if (settled)
|
|
1755
|
+
return;
|
|
1756
|
+
settled = true;
|
|
1645
1757
|
cleanup();
|
|
1646
1758
|
resolve();
|
|
1647
1759
|
};
|
|
1648
1760
|
const onError = () => {
|
|
1761
|
+
if (settled)
|
|
1762
|
+
return;
|
|
1763
|
+
settled = true;
|
|
1649
1764
|
cleanup();
|
|
1650
1765
|
reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
|
|
1651
1766
|
};
|
|
@@ -1655,6 +1770,9 @@ export class TTSService {
|
|
|
1655
1770
|
this.activeRecordedAudio = {
|
|
1656
1771
|
element,
|
|
1657
1772
|
cancel: () => {
|
|
1773
|
+
if (settled)
|
|
1774
|
+
return;
|
|
1775
|
+
settled = true;
|
|
1658
1776
|
cleanup();
|
|
1659
1777
|
element.pause();
|
|
1660
1778
|
resolve();
|
|
@@ -1662,6 +1780,7 @@ export class TTSService {
|
|
|
1662
1780
|
};
|
|
1663
1781
|
element.addEventListener("ended", onEnded);
|
|
1664
1782
|
element.addEventListener("error", onError);
|
|
1783
|
+
element.addEventListener("playing", markStarted);
|
|
1665
1784
|
// Browsers honour a Media Fragments start offset but are inconsistent
|
|
1666
1785
|
// about the end bound, so the end is enforced here — the same reason the
|
|
1667
1786
|
// signing region enforces its own.
|
|
@@ -1672,7 +1791,7 @@ export class TTSService {
|
|
|
1672
1791
|
onEnded();
|
|
1673
1792
|
}, 100);
|
|
1674
1793
|
}
|
|
1675
|
-
Promise.resolve(element.play()).catch(onError);
|
|
1794
|
+
Promise.resolve(element.play()).then(markStarted).catch(onError);
|
|
1676
1795
|
}).finally(() => {
|
|
1677
1796
|
if (this.activeRecordedAudio?.element === element) {
|
|
1678
1797
|
this.activeRecordedAudio = null;
|
|
@@ -1693,8 +1812,12 @@ export class TTSService {
|
|
|
1693
1812
|
return;
|
|
1694
1813
|
this.lastRenderedRegionTarget = null;
|
|
1695
1814
|
if (chunk.audio) {
|
|
1696
|
-
this.
|
|
1697
|
-
|
|
1815
|
+
this.runWhenPlaybackStarts(runId, () => {
|
|
1816
|
+
this.highlightCatalogRegion(chunk);
|
|
1817
|
+
});
|
|
1818
|
+
await this.playRecordedAudio(chunk.audio, () => {
|
|
1819
|
+
this.notifyPlaybackStarted(runId);
|
|
1820
|
+
});
|
|
1698
1821
|
return;
|
|
1699
1822
|
}
|
|
1700
1823
|
const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
|
|
@@ -1713,12 +1836,14 @@ export class TTSService {
|
|
|
1713
1836
|
mathTokenHighlighting,
|
|
1714
1837
|
})
|
|
1715
1838
|
: null;
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1839
|
+
this.runWhenPlaybackStarts(runId, () => {
|
|
1840
|
+
if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
|
|
1841
|
+
this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
|
|
1842
|
+
}
|
|
1843
|
+
else {
|
|
1844
|
+
this.highlightCatalogRegion(chunk);
|
|
1845
|
+
}
|
|
1846
|
+
});
|
|
1722
1847
|
const canUseChunkBoundaries = chunk.sourceElement &&
|
|
1723
1848
|
((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
|
|
1724
1849
|
(chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
|
|
@@ -1805,6 +1930,8 @@ export class TTSService {
|
|
|
1805
1930
|
}
|
|
1806
1931
|
}
|
|
1807
1932
|
clearHighlightsAndTracking() {
|
|
1933
|
+
this.pendingPlaybackStartHighlights = [];
|
|
1934
|
+
this.activePlaybackRate = null;
|
|
1808
1935
|
if (this.highlightCoordinator) {
|
|
1809
1936
|
this.highlightCoordinator.clearTTS();
|
|
1810
1937
|
}
|
|
@@ -1923,13 +2050,21 @@ export class TTSService {
|
|
|
1923
2050
|
? this.playbackChunks.slice(safeTargetIndex)
|
|
1924
2051
|
: null;
|
|
1925
2052
|
this.speakRunId += 1;
|
|
2053
|
+
this.clearPlaybackStartBarrier();
|
|
1926
2054
|
this.cancelRecordedAudio();
|
|
1927
2055
|
this.provider.onWordBoundary = undefined;
|
|
1928
2056
|
this.provider.stop();
|
|
1929
2057
|
this.currentSeekSegmentIndex = safeTargetIndex;
|
|
1930
2058
|
const runId = ++this.speakRunId;
|
|
1931
2059
|
const restartSegments = this.seekSegments.slice(safeTargetIndex);
|
|
1932
|
-
this.
|
|
2060
|
+
this.highlightCoordinator?.clearTTS();
|
|
2061
|
+
this.activeSentenceStartOffset = null;
|
|
2062
|
+
this.setState(PlaybackState.LOADING);
|
|
2063
|
+
const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
|
|
2064
|
+
if (!playbackStartBarrier) {
|
|
2065
|
+
this.setState(PlaybackState.PLAYING);
|
|
2066
|
+
}
|
|
2067
|
+
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1933
2068
|
try {
|
|
1934
2069
|
this.configureWordBoundaryHighlighting({
|
|
1935
2070
|
highlightMode: this.activeHighlightMode,
|
|
@@ -1968,17 +2103,68 @@ export class TTSService {
|
|
|
1968
2103
|
this.highlightTargetResolverProvider = null;
|
|
1969
2104
|
throw error;
|
|
1970
2105
|
}
|
|
2106
|
+
finally {
|
|
2107
|
+
this.clearPlaybackStartBarrier(playbackStartBarrier);
|
|
2108
|
+
}
|
|
1971
2109
|
}
|
|
1972
2110
|
async setPlaybackRate(rate) {
|
|
2111
|
+
if (!this.provider) {
|
|
2112
|
+
throw new Error("TTSService not initialized. Call initialize() first.");
|
|
2113
|
+
}
|
|
1973
2114
|
const nextRate = this.normalizePlaybackRate(rate);
|
|
1974
|
-
|
|
1975
|
-
if (
|
|
1976
|
-
|
|
1977
|
-
this.seekSegments.length === 0 ||
|
|
1978
|
-
this.hasExplicitBreakSemantics(this.currentText)) {
|
|
2115
|
+
const pendingRequest = this.pendingPlaybackRateRequest;
|
|
2116
|
+
if (pendingRequest?.rate === nextRate) {
|
|
2117
|
+
await pendingRequest.promise;
|
|
1979
2118
|
return;
|
|
1980
2119
|
}
|
|
1981
|
-
|
|
2120
|
+
const requestId = ++this.playbackRateRequestId;
|
|
2121
|
+
const settingsWrite = this.playbackRateWriteQueue.then(async () => {
|
|
2122
|
+
const playbackRunId = this.speakRunId;
|
|
2123
|
+
// A newer distinct target supersedes queued work before it mutates either
|
|
2124
|
+
// provider settings or playback. An already-running write cannot be
|
|
2125
|
+
// cancelled, so its post-write generation check still suppresses restart.
|
|
2126
|
+
if (requestId !== this.playbackRateRequestId) {
|
|
2127
|
+
return { didUpdate: false, playbackRunId };
|
|
2128
|
+
}
|
|
2129
|
+
// An omitted rate is the provider-default 1× rate. Re-check inside the
|
|
2130
|
+
// serialized operation so overlapping callers observe the preceding write.
|
|
2131
|
+
const currentRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
2132
|
+
if (nextRate === currentRate) {
|
|
2133
|
+
return { didUpdate: false, playbackRunId };
|
|
2134
|
+
}
|
|
2135
|
+
await this.updateSettings({ rate: nextRate });
|
|
2136
|
+
return { didUpdate: true, playbackRunId };
|
|
2137
|
+
});
|
|
2138
|
+
this.playbackRateWriteQueue = settingsWrite.then(() => undefined, () => undefined);
|
|
2139
|
+
const operation = settingsWrite.then(async (result) => {
|
|
2140
|
+
if (requestId !== this.playbackRateRequestId ||
|
|
2141
|
+
result.playbackRunId !== this.speakRunId) {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
const activePlaybackNeedsRate = this.activePlaybackRate !== null &&
|
|
2145
|
+
this.activePlaybackRate !== nextRate;
|
|
2146
|
+
if (!result.didUpdate && !activePlaybackNeedsRate)
|
|
2147
|
+
return;
|
|
2148
|
+
if ((this.state !== PlaybackState.PLAYING &&
|
|
2149
|
+
this.state !== PlaybackState.LOADING) ||
|
|
2150
|
+
!this.currentText ||
|
|
2151
|
+
this.seekSegments.length === 0 ||
|
|
2152
|
+
this.hasExplicitBreakSemantics(this.currentText)) {
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
await this.restartFromSeekIndex(this.getCurrentSeekSegmentIndex());
|
|
2156
|
+
});
|
|
2157
|
+
let trackedOperation;
|
|
2158
|
+
trackedOperation = operation.finally(() => {
|
|
2159
|
+
if (this.pendingPlaybackRateRequest?.promise === trackedOperation) {
|
|
2160
|
+
this.pendingPlaybackRateRequest = null;
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
this.pendingPlaybackRateRequest = {
|
|
2164
|
+
rate: nextRate,
|
|
2165
|
+
promise: trackedOperation,
|
|
2166
|
+
};
|
|
2167
|
+
await trackedOperation;
|
|
1982
2168
|
}
|
|
1983
2169
|
async seekBy(units) {
|
|
1984
2170
|
if (!this.provider || !this.currentText)
|
|
@@ -2015,6 +2201,7 @@ export class TTSService {
|
|
|
2015
2201
|
if (!this.provider)
|
|
2016
2202
|
return;
|
|
2017
2203
|
this.speakRunId += 1;
|
|
2204
|
+
this.clearPlaybackStartBarrier();
|
|
2018
2205
|
this.cancelRecordedAudio();
|
|
2019
2206
|
this.provider.onWordBoundary = undefined;
|
|
2020
2207
|
this.provider.stop();
|
|
@@ -2036,6 +2223,7 @@ export class TTSService {
|
|
|
2036
2223
|
this.sentenceHighlightSegments = [];
|
|
2037
2224
|
this.currentSeekSegmentIndex = 0;
|
|
2038
2225
|
this.activeSentenceStartOffset = null;
|
|
2226
|
+
this.activePlaybackRate = null;
|
|
2039
2227
|
}
|
|
2040
2228
|
/**
|
|
2041
2229
|
* Request UI-level TTS controls to hand off/deactivate.
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Central registry for all assessment tools. Manages tool metadata, visibility logic,
|
|
5
5
|
* and button/instance creation. Supports dynamic registration and override by integrators.
|
|
6
6
|
*/
|
|
7
|
-
import type {
|
|
8
|
-
import type { CatalogOwnerContext } from "./AccessibilityCatalogResolver.js";
|
|
7
|
+
import type { CatalogOwnerSnapshot } from "./AccessibilityCatalogResolver.js";
|
|
9
8
|
import type { ToolContext, ToolLevel } from "./tool-context.js";
|
|
10
9
|
import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
|
|
11
10
|
import type { AccessibilityCatalogResolverApi, ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
|
|
@@ -13,6 +12,13 @@ import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
|
|
|
13
12
|
import type { ToolProviderConfig as ToolRuntimeConfig } from "./tools-config-normalizer.js";
|
|
14
13
|
import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
|
|
15
14
|
export type ToolModuleLoader = () => Promise<unknown>;
|
|
15
|
+
export type ToolRegistryChangeKind = "register" | "override" | "unregister" | "clear" | "component-overrides" | "module-loaders";
|
|
16
|
+
/** A successful mutation to discoverable or rendering registry state. */
|
|
17
|
+
export interface ToolRegistryChangeEvent {
|
|
18
|
+
kind: ToolRegistryChangeKind;
|
|
19
|
+
toolIds: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export type ToolRegistryChangeListener = (event: ToolRegistryChangeEvent) => void;
|
|
16
22
|
export interface ToolToolbarButtonDefinition {
|
|
17
23
|
toolId: string;
|
|
18
24
|
label: string;
|
|
@@ -178,8 +184,8 @@ export interface ToolSurfaceServices {
|
|
|
178
184
|
* `surface` is a host-defined slot name. Core defines none and validates only
|
|
179
185
|
* that a region capability claims at least one, so a host can open a new surface
|
|
180
186
|
* without a change here and a capability can declare which of a host's surfaces
|
|
181
|
-
* it fits. Section-player ships `"content-
|
|
182
|
-
*
|
|
187
|
+
* it fits. Section-player ships `"content-lead"` and `"content-media"` on item
|
|
188
|
+
* and passage cards, plus `"section-overlay"` at section scope.
|
|
183
189
|
*
|
|
184
190
|
* `content` carries whatever the capability's own `requiresAuthoredContent`
|
|
185
191
|
* resolved, so the host neither inspects nor names it — it hands back what the
|
|
@@ -207,11 +213,23 @@ export interface ToolContentDependencyContext {
|
|
|
207
213
|
featureId: string;
|
|
208
214
|
/** Feature parameters from the policy decision, if any. */
|
|
209
215
|
parameters?: unknown;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Owner-scoped catalog cards, or `null` when no resolver is available.
|
|
218
|
+
*
|
|
219
|
+
* The catalog module has already applied item/passage/model traversal and
|
|
220
|
+
* registration precedence. A capability interprets its own card type without
|
|
221
|
+
* reconstructing the owner scope or reading the raw entity.
|
|
222
|
+
*/
|
|
223
|
+
catalogs: CatalogOwnerSnapshot | null;
|
|
224
|
+
/**
|
|
225
|
+
* Whether policy granted one of this capability's support ids.
|
|
226
|
+
*
|
|
227
|
+
* `false` reaches `resolve` only for a capability that declares
|
|
228
|
+
* {@link ToolRegistration.resolvesWithoutGrant}, and it is the signal that the
|
|
229
|
+
* capability must answer from the content alone. Everything else sees `true`,
|
|
230
|
+
* because the host asks about eligibility first and stops there.
|
|
231
|
+
*/
|
|
232
|
+
granted: boolean;
|
|
215
233
|
}
|
|
216
234
|
/**
|
|
217
235
|
* A capability's declaration that it needs authored content to have anything to
|
|
@@ -240,8 +258,8 @@ export interface ToolContentDependencyContext {
|
|
|
240
258
|
* keeps the resolver and the host from knowing which accommodation they are
|
|
241
259
|
* resolving.
|
|
242
260
|
*
|
|
243
|
-
* Resolvable only on a surface the host renders per item or per passage
|
|
244
|
-
*
|
|
261
|
+
* Resolvable only on a surface the host renders per item or per passage. The
|
|
262
|
+
* owner snapshot represents an item/model or passage, never a section, because a DRD
|
|
245
263
|
* resource pairs with a piece of content and not with a container. A capability
|
|
246
264
|
* declaring one and claiming a section-scoped surface is declined there rather
|
|
247
265
|
* than mounted with no content. `resolve` is also synchronous — a capability whose
|
|
@@ -255,9 +273,9 @@ export interface ToolContentDependency {
|
|
|
255
273
|
* Must be JSON-serializable. A host re-resolves on every policy and catalog
|
|
256
274
|
* signal and compares the answer structurally to decide whether anything moved,
|
|
257
275
|
* because every resolution builds fresh objects and identity would report a
|
|
258
|
-
* change each time. A `Map`,
|
|
259
|
-
*
|
|
260
|
-
*
|
|
276
|
+
* change each time. A `Map`, function, DOM node, cyclic value, or other
|
|
277
|
+
* non-serializable result is rejected for that capability and reported as a
|
|
278
|
+
* recoverable surface warning instead of escaping into the player.
|
|
261
279
|
*/
|
|
262
280
|
resolve(context: ToolContentDependencyContext): unknown | null;
|
|
263
281
|
/**
|
|
@@ -339,6 +357,22 @@ export interface ToolRegistration {
|
|
|
339
357
|
* {@link ToolContentDependency}.
|
|
340
358
|
*/
|
|
341
359
|
requiresAuthoredContent?: ToolContentDependency;
|
|
360
|
+
/**
|
|
361
|
+
* Ask this capability for its content even when policy granted nothing.
|
|
362
|
+
*
|
|
363
|
+
* Only meaningful together with {@link ToolRegistration.requiresAuthoredContent},
|
|
364
|
+
* and only correct for a capability whose authored content can declare itself
|
|
365
|
+
* *presentation* rather than an accommodation — content authored to be delivered
|
|
366
|
+
* that way to everyone, which no profile grants and none revokes. Such a
|
|
367
|
+
* capability must return `null` from `resolve` when its content is the
|
|
368
|
+
* accommodation kind and the grant is absent, and the `granted` flag on the
|
|
369
|
+
* context is how it tells the two apart.
|
|
370
|
+
*
|
|
371
|
+
* Without this, "no grant" ends the question before content is consulted, which
|
|
372
|
+
* is the right default: it is what keeps an accommodation off the item of a
|
|
373
|
+
* learner with no documented need.
|
|
374
|
+
*/
|
|
375
|
+
resolvesWithoutGrant?: boolean;
|
|
342
376
|
/**
|
|
343
377
|
* Optional provider registration metadata.
|
|
344
378
|
* When present, ToolkitCoordinator can register provider(s) generically
|
|
@@ -393,6 +427,13 @@ export declare class ToolRegistry {
|
|
|
393
427
|
private moduleLoaders;
|
|
394
428
|
private loadedToolModules;
|
|
395
429
|
private moduleLoadPromises;
|
|
430
|
+
private changeListeners;
|
|
431
|
+
private emitChange;
|
|
432
|
+
/**
|
|
433
|
+
* Observe successful registry mutations. Delivery is synchronous, listener
|
|
434
|
+
* failures are isolated, and the returned unsubscribe is idempotent.
|
|
435
|
+
*/
|
|
436
|
+
onRegistryChange(listener: ToolRegistryChangeListener): () => void;
|
|
396
437
|
/**
|
|
397
438
|
* Normalize a single tool alias to canonical toolId.
|
|
398
439
|
*/
|
|
@@ -93,6 +93,17 @@ function assertToolRegistrationShape(registration) {
|
|
|
93
93
|
if (isRegion && typeof registration.renderSurface !== "function") {
|
|
94
94
|
throw new Error(`Invalid tool registration "${registration.toolId}": region tools must implement "renderSurface".`);
|
|
95
95
|
}
|
|
96
|
+
if (registration.resolvesWithoutGrant !== undefined &&
|
|
97
|
+
typeof registration.resolvesWithoutGrant !== "boolean") {
|
|
98
|
+
throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" must be a boolean.`);
|
|
99
|
+
}
|
|
100
|
+
if (registration.resolvesWithoutGrant &&
|
|
101
|
+
!registration.requiresAuthoredContent) {
|
|
102
|
+
// The flag only decides whether content is consulted without a grant, so on a
|
|
103
|
+
// capability with no content dependency it reads as "granted to everyone" and
|
|
104
|
+
// does nothing at all.
|
|
105
|
+
throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" requires "requiresAuthoredContent".`);
|
|
106
|
+
}
|
|
96
107
|
if (registration.renderSurface !== undefined &&
|
|
97
108
|
typeof registration.renderSurface !== "function") {
|
|
98
109
|
throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" must be a function.`);
|
|
@@ -157,6 +168,31 @@ export class ToolRegistry {
|
|
|
157
168
|
moduleLoaders = new Map();
|
|
158
169
|
loadedToolModules = new Set();
|
|
159
170
|
moduleLoadPromises = new Map();
|
|
171
|
+
changeListeners = new Set();
|
|
172
|
+
emitChange(event) {
|
|
173
|
+
for (const listener of this.changeListeners) {
|
|
174
|
+
try {
|
|
175
|
+
listener(event);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.warn("[ToolRegistry] change listener failed:", error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Observe successful registry mutations. Delivery is synchronous, listener
|
|
184
|
+
* failures are isolated, and the returned unsubscribe is idempotent.
|
|
185
|
+
*/
|
|
186
|
+
onRegistryChange(listener) {
|
|
187
|
+
this.changeListeners.add(listener);
|
|
188
|
+
let subscribed = true;
|
|
189
|
+
return () => {
|
|
190
|
+
if (!subscribed)
|
|
191
|
+
return;
|
|
192
|
+
subscribed = false;
|
|
193
|
+
this.changeListeners.delete(listener);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
160
196
|
/**
|
|
161
197
|
* Normalize a single tool alias to canonical toolId.
|
|
162
198
|
*/
|
|
@@ -190,6 +226,7 @@ export class ToolRegistry {
|
|
|
190
226
|
this.pnpIndex.get(pnpId).add(registration.toolId);
|
|
191
227
|
}
|
|
192
228
|
}
|
|
229
|
+
this.emitChange({ kind: "register", toolIds: [registration.toolId] });
|
|
193
230
|
}
|
|
194
231
|
/**
|
|
195
232
|
* Override an existing tool registration
|
|
@@ -219,6 +256,7 @@ export class ToolRegistry {
|
|
|
219
256
|
this.pnpIndex.get(pnpId).add(registration.toolId);
|
|
220
257
|
}
|
|
221
258
|
}
|
|
259
|
+
this.emitChange({ kind: "override", toolIds: [registration.toolId] });
|
|
222
260
|
}
|
|
223
261
|
/**
|
|
224
262
|
* Unregister a tool
|
|
@@ -236,6 +274,7 @@ export class ToolRegistry {
|
|
|
236
274
|
}
|
|
237
275
|
}
|
|
238
276
|
this.tools.delete(toolId);
|
|
277
|
+
this.emitChange({ kind: "unregister", toolIds: [toolId] });
|
|
239
278
|
}
|
|
240
279
|
/**
|
|
241
280
|
* Get a tool registration by ID
|
|
@@ -416,28 +455,43 @@ export class ToolRegistry {
|
|
|
416
455
|
* Clear all registrations (useful for testing)
|
|
417
456
|
*/
|
|
418
457
|
clear() {
|
|
458
|
+
const toolIds = this.getAllToolIds();
|
|
459
|
+
if (toolIds.length === 0)
|
|
460
|
+
return;
|
|
419
461
|
this.tools.clear();
|
|
420
462
|
this.pnpIndex.clear();
|
|
463
|
+
this.emitChange({ kind: "clear", toolIds });
|
|
421
464
|
}
|
|
422
465
|
/**
|
|
423
466
|
* Configure global component overrides used by tool instance creation.
|
|
424
467
|
*/
|
|
425
468
|
setComponentOverrides(overrides) {
|
|
469
|
+
if (this.componentOverrides === overrides)
|
|
470
|
+
return;
|
|
426
471
|
this.componentOverrides = overrides;
|
|
472
|
+
this.emitChange({ kind: "component-overrides", toolIds: [] });
|
|
427
473
|
}
|
|
428
474
|
/**
|
|
429
475
|
* Register lazy module loaders by toolId.
|
|
430
476
|
* Toolbars call ensureToolModuleLoaded(toolId) before instance creation.
|
|
431
477
|
*/
|
|
432
478
|
setToolModuleLoaders(loaders) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
continue;
|
|
479
|
+
const entries = Object.entries(loaders).filter((entry) => entry[1] !== undefined);
|
|
480
|
+
for (const [toolId, loader] of entries) {
|
|
436
481
|
assertNonEmptyString(toolId, "tool module loader id");
|
|
437
482
|
if (typeof loader !== "function") {
|
|
438
483
|
throw new Error(`Invalid tool module loader for "${toolId}": expected a function.`);
|
|
439
484
|
}
|
|
485
|
+
}
|
|
486
|
+
const changedToolIds = [];
|
|
487
|
+
for (const [toolId, loader] of entries) {
|
|
488
|
+
if (this.moduleLoaders.get(toolId) === loader)
|
|
489
|
+
continue;
|
|
440
490
|
this.moduleLoaders.set(toolId, loader);
|
|
491
|
+
changedToolIds.push(toolId);
|
|
492
|
+
}
|
|
493
|
+
if (changedToolIds.length > 0) {
|
|
494
|
+
this.emitChange({ kind: "module-loaders", toolIds: changedToolIds });
|
|
441
495
|
}
|
|
442
496
|
}
|
|
443
497
|
/**
|