@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.0
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 +449 -541
- package/custom-elements.json +1315 -0
- package/dist/attempt/TestSession.d.ts +0 -1
- package/dist/attempt/TestSession.js +10 -5
- package/dist/components/ItemScope.custom-element.js +1 -1
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
- package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
- package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
- package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
- package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
- package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
- package/dist/context/assessment-toolkit-context.d.ts +6 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +4 -10
- package/dist/policy/core/PolicySource.d.ts +1 -1
- package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
- package/dist/policy/core/ToolPolicyEngine.js +73 -16
- package/dist/policy/core/compose-decision.d.ts +3 -3
- package/dist/policy/core/compose-decision.js +30 -30
- package/dist/policy/core/decision-types.d.ts +44 -32
- package/dist/policy/core/feature-decision.d.ts +17 -15
- package/dist/policy/core/feature-decision.js +11 -13
- package/dist/policy/core/pnp-policy-inputs.d.ts +5 -2
- package/dist/policy/core/pnp-policy-inputs.js +12 -32
- package/dist/policy/core/provenance.d.ts +1 -1
- package/dist/policy/core/tool-parameters.d.ts +11 -0
- package/dist/policy/core/tool-parameters.js +15 -0
- package/dist/policy/engine.d.ts +1 -1
- package/dist/policy/sources/PnpPolicySource.d.ts +14 -22
- package/dist/policy/sources/PnpPolicySource.js +30 -28
- package/dist/runtime/SectionControllerBinding.d.ts +6 -2
- package/dist/runtime/SectionControllerBinding.js +21 -15
- package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
- package/dist/runtime/adapter/subscriber-fanout.js +1 -4
- package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
- package/dist/runtime/composition-emit-scheduler.js +1 -1
- package/dist/runtime/content-region.d.ts +0 -4
- package/dist/runtime/content-region.js +2 -2
- package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
- package/dist/runtime/core/SectionEngineCore.js +0 -4
- package/dist/runtime/core/cohort.d.ts +0 -5
- package/dist/runtime/core/cohort.js +0 -9
- package/dist/runtime/core/engine-output.d.ts +3 -2
- package/dist/runtime/core/engine-transition.js +3 -2
- package/dist/runtime/registration-events.d.ts +11 -14
- package/dist/runtime/runtime-id.d.ts +1 -1
- package/dist/runtime/runtime-id.js +1 -1
- package/dist/runtime/shell-scope.d.ts +4 -4
- package/dist/runtime/shell-scope.js +15 -9
- package/dist/runtime/tool-host-contract.d.ts +0 -4
- package/dist/runtime/tool-host-contract.js +1 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
- package/dist/services/AccessibilityCatalogResolver.js +29 -42
- package/dist/services/ElementToolStateStore.d.ts +32 -61
- package/dist/services/ElementToolStateStore.js +37 -67
- package/dist/services/HighlightCoordinator.d.ts +9 -21
- package/dist/services/HighlightCoordinator.js +52 -80
- package/dist/services/RangeSerializer.d.ts +4 -35
- package/dist/services/RangeSerializer.js +4 -48
- package/dist/services/SSMLExtractor.d.ts +8 -31
- package/dist/services/SSMLExtractor.js +18 -40
- package/dist/services/TTSService.d.ts +49 -56
- package/dist/services/TTSService.js +308 -283
- package/dist/services/ToolCoordinator.d.ts +6 -49
- package/dist/services/ToolCoordinator.js +25 -61
- package/dist/services/ToolRegistry.d.ts +82 -80
- package/dist/services/ToolRegistry.js +91 -67
- package/dist/services/ToolkitCoordinator.d.ts +83 -81
- package/dist/services/ToolkitCoordinator.js +186 -174
- package/dist/services/framework-error.d.ts +30 -7
- package/dist/services/framework-error.js +10 -13
- package/dist/services/interfaces.d.ts +47 -71
- package/dist/services/section-controller-types.d.ts +7 -7
- package/dist/services/section-session-assignment.js +6 -8
- package/dist/services/spoken-audio-cards.d.ts +2 -4
- package/dist/services/spoken-audio-cards.js +2 -4
- package/dist/services/tool-config-validation.d.ts +5 -7
- package/dist/services/tool-config-validation.js +11 -26
- package/dist/services/tool-context.d.ts +7 -23
- package/dist/services/tool-context.js +99 -53
- package/dist/services/tool-failure.d.ts +30 -0
- package/dist/services/tool-failure.js +11 -0
- package/dist/services/tool-icons.d.ts +1 -2
- package/dist/services/tool-icons.js +2 -2
- package/dist/services/tool-instance-id.d.ts +8 -6
- package/dist/services/tool-instance-id.js +12 -18
- package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
- package/dist/services/tool-providers/TTSToolProvider.js +7 -63
- package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
- package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
- package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
- package/dist/services/tool-request.d.ts +4 -0
- package/dist/services/tool-request.js +7 -2
- package/dist/services/toolbar-decided-tools.d.ts +1 -0
- package/dist/services/toolbar-decided-tools.js +1 -0
- package/dist/services/toolbar-items.js +2 -5
- package/dist/services/tools-config-normalizer.d.ts +18 -9
- package/dist/services/tools-config-normalizer.js +25 -19
- package/dist/services/tts/browser-provider.d.ts +2 -5
- package/dist/services/tts/browser-provider.js +4 -20
- package/dist/services/tts/catalog-span-alignment.js +14 -7
- package/dist/services/tts/flat-tree.d.ts +0 -7
- package/dist/services/tts/flat-tree.js +2 -2
- package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
- package/dist/services/tts/generated-speech/index.d.ts +1 -1
- package/dist/services/tts/generated-speech/index.js +1 -1
- package/dist/services/tts/generated-speech/types.d.ts +1 -1
- package/dist/services/tts/generated-speech/types.js +1 -1
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
- package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
- package/dist/services/tts/math-aware-text-processing.js +10 -29
- package/dist/services/tts/math-speech.d.ts +1 -1
- package/dist/services/tts/math-speech.js +3 -0
- package/dist/services/tts/mathml-sanitization.d.ts +7 -0
- package/dist/services/tts/mathml-sanitization.js +9 -1
- package/dist/services/tts/provider-options.d.ts +12 -1
- package/dist/services/tts/ssml/spoken-text.js +1 -1
- package/dist/services/tts/start-failure.d.ts +0 -7
- package/dist/services/tts/start-failure.js +1 -1
- package/dist/services/tts/text-segmentation.d.ts +5 -0
- package/dist/services/tts/text-segmentation.js +13 -10
- package/dist/services/tts-runtime-config.d.ts +32 -12
- package/dist/services/tts-runtime-config.js +14 -20
- package/dist/tools/content-capability-resolution.d.ts +2 -2
- package/dist/tools/registration.d.ts +6 -5
- package/dist/tools/registration.js +10 -9
- package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
- package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
- package/dist/tools/tool-surface-host.js +1 -8
- package/package.json +8 -6
- package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
- package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
- package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
- package/dist/services/ContextVariableStore.d.ts +0 -113
- package/dist/services/ContextVariableStore.js +0 -206
- package/dist/services/tts-control-events.d.ts +0 -13
- package/dist/services/tts-control-events.js +0 -7
- package/dist/utils/logger.d.ts +0 -6
- package/dist/utils/logger.js +0 -10
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TTSService
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and integrates with HighlightCoordinator for word highlighting.
|
|
4
|
+
* Owns one read-aloud session: the installed provider, playback state, and the
|
|
5
|
+
* word and sentence highlights painted through HighlightCoordinator.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* - Provider and host wiring: initialize, setCatalogResolver, the highlight
|
|
9
|
+
* target resolver and math-speech sources.
|
|
10
|
+
* - Content resolution: catalog spoken card, then catalog-composed chunks
|
|
11
|
+
* (script and/or recording with a plain fallback), then generated speech.
|
|
12
|
+
* - Speech plan: structural pauses, seek segments, sentence segments and the
|
|
13
|
+
* normalized-to-DOM position map.
|
|
14
|
+
* - Playback: speakContent → executeSpeakPlayback, the run guard (speakRunId,
|
|
15
|
+
* runOwner), the pause hold and the playback-start barrier.
|
|
16
|
+
* - Highlight painting: boundary handling, the host resolver remap, catalog
|
|
17
|
+
* highlight plans.
|
|
18
|
+
* - Controls and state: pause/resume/stop/seek/rate, setState and listeners.
|
|
17
19
|
*/
|
|
18
20
|
import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
|
|
19
21
|
import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
|
|
20
|
-
import { HighlightType } from "./HighlightCoordinator.js";
|
|
21
22
|
import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
|
|
22
23
|
import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
|
|
23
24
|
import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
|
|
@@ -32,7 +33,6 @@ import { toTTSStartFailure } from "./tts/start-failure.js";
|
|
|
32
33
|
import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
|
|
33
34
|
import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
|
|
34
35
|
import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.js";
|
|
35
|
-
import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
|
|
36
36
|
// Debug lines need `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
37
37
|
const logger = createPieLogger("tts-service", isTtsDebugEnabled);
|
|
38
38
|
/**
|
|
@@ -46,6 +46,14 @@ export var PlaybackState;
|
|
|
46
46
|
PlaybackState["PAUSED"] = "paused";
|
|
47
47
|
PlaybackState["ERROR"] = "error";
|
|
48
48
|
})(PlaybackState || (PlaybackState = {}));
|
|
49
|
+
const DEFAULT_STRUCTURAL_PAUSE_PROFILE = {
|
|
50
|
+
baseMs: 280,
|
|
51
|
+
units: { minor: 0.65, section: 1.0, major: 1.35 },
|
|
52
|
+
minMs: 120,
|
|
53
|
+
maxMs: 900,
|
|
54
|
+
};
|
|
55
|
+
const MIN_PLAYBACK_RATE = 0.25;
|
|
56
|
+
const MAX_PLAYBACK_RATE = 4;
|
|
49
57
|
const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
50
58
|
const hostLocalesOf = (options, previous = {}) => {
|
|
51
59
|
if (!options)
|
|
@@ -89,12 +97,53 @@ const sameRenderableHighlightTarget = (left, right) => {
|
|
|
89
97
|
return false;
|
|
90
98
|
};
|
|
91
99
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
100
|
+
* Where two texts first differ, with 20 characters of context each side;
|
|
101
|
+
* debug tracing only.
|
|
102
|
+
*/
|
|
103
|
+
const firstDifference = (spoken, normalized) => {
|
|
104
|
+
for (let i = 0; i < Math.min(spoken.length, normalized.length); i++) {
|
|
105
|
+
if (spoken[i] !== normalized[i]) {
|
|
106
|
+
return {
|
|
107
|
+
index: i,
|
|
108
|
+
spokenChar: spoken[i],
|
|
109
|
+
normalizedChar: normalized[i],
|
|
110
|
+
spokenContext: spoken.substring(Math.max(0, i - 20), i + 20),
|
|
111
|
+
normalizedContext: normalized.substring(Math.max(0, i - 20), i + 20),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
/** The authored `data-tts-break-ms` pause, when it is a positive number. */
|
|
118
|
+
const explicitBreakMsOf = (element) => {
|
|
119
|
+
const ms = Number(element.getAttribute("data-tts-break-ms"));
|
|
120
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
121
|
+
};
|
|
122
|
+
const isBlockDisplay = (element) => {
|
|
123
|
+
if (typeof window === "undefined")
|
|
124
|
+
return false;
|
|
125
|
+
const display = window.getComputedStyle(element).display;
|
|
126
|
+
return (display === "block" ||
|
|
127
|
+
display === "list-item" ||
|
|
128
|
+
display.startsWith("table") ||
|
|
129
|
+
display === "flex" ||
|
|
130
|
+
display === "grid");
|
|
131
|
+
};
|
|
132
|
+
/** `[start, end)` of `text` with its surrounding whitespace excluded. */
|
|
133
|
+
const trimSpan = (text, start, end) => {
|
|
134
|
+
const raw = text.substring(start, end);
|
|
135
|
+
return {
|
|
136
|
+
start: start + (raw.match(/^\s*/)?.[0].length || 0),
|
|
137
|
+
end: end - (raw.match(/\s*$/)?.[0].length || 0),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
94
141
|
* Instantiable service for text-to-speech functionality.
|
|
95
142
|
* Each instance manages its own playback state.
|
|
96
143
|
*/
|
|
97
144
|
export class TTSService {
|
|
145
|
+
// currentProvider is the provider the host installed; provider is the
|
|
146
|
+
// implementation its initialize() returned, which does the speaking.
|
|
98
147
|
currentProvider = null;
|
|
99
148
|
provider = null;
|
|
100
149
|
highlightCoordinator = null;
|
|
@@ -115,10 +164,18 @@ export class TTSService {
|
|
|
115
164
|
mathSpeechSource = null;
|
|
116
165
|
currentText = null;
|
|
117
166
|
currentContentElement = null;
|
|
167
|
+
// Offsets in the normalized visible text of the element being read, mapped
|
|
168
|
+
// to DOM text positions; word boundaries resolve through it.
|
|
118
169
|
normalizedToDOM = new Map();
|
|
119
170
|
listeners = new Set();
|
|
120
171
|
lastError = null;
|
|
172
|
+
// Bumped by every new run and by stop, restart and dispose; an async step
|
|
173
|
+
// that resumes under an older id has been superseded and returns.
|
|
121
174
|
speakRunId = 0;
|
|
175
|
+
runOwner = null;
|
|
176
|
+
// currentBoundaryOffset is the spoken-text offset the run has reached, which
|
|
177
|
+
// seek and word boundaries count from; activeWordBoundaryOffset is where the
|
|
178
|
+
// spoken text starts in the element's visible text, nonzero for a selection.
|
|
122
179
|
currentBoundaryOffset = 0;
|
|
123
180
|
activeWordBoundaryOffset = 0;
|
|
124
181
|
seekSegments = [];
|
|
@@ -142,20 +199,21 @@ export class TTSService {
|
|
|
142
199
|
pendingPlaybackRateRequest = null;
|
|
143
200
|
activePlaybackRate = null;
|
|
144
201
|
activeHighlightMode = "word";
|
|
202
|
+
// What the plan part being spoken paints in sentence mode.
|
|
203
|
+
wordlessPartHighlight = null;
|
|
145
204
|
lastRenderedRegionTarget = null;
|
|
146
205
|
telemetryReporter = null;
|
|
147
|
-
// Memoized SRE resolver for the runtime generated-speech path
|
|
206
|
+
// Memoized SRE resolver for the runtime generated-speech path.
|
|
148
207
|
// Caches DOM-free spoken text keyed by SRE settings and MathML source across
|
|
149
208
|
// utterances within this service instance.
|
|
150
209
|
generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
|
|
151
|
-
constructor() { }
|
|
152
210
|
clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
|
|
153
211
|
cleanup?.();
|
|
154
212
|
if (this.playbackStartBarrierCleanup === cleanup) {
|
|
155
213
|
this.playbackStartBarrierCleanup = null;
|
|
156
214
|
}
|
|
157
215
|
}
|
|
158
|
-
notifyPlaybackStarted(runId) {
|
|
216
|
+
notifyPlaybackStarted(runId, playback) {
|
|
159
217
|
if (this.playbackStartDeferredRunId !== runId ||
|
|
160
218
|
runId !== this.speakRunId) {
|
|
161
219
|
return;
|
|
@@ -170,7 +228,27 @@ export class TTSService {
|
|
|
170
228
|
for (const applyHighlight of pendingHighlights) {
|
|
171
229
|
applyHighlight();
|
|
172
230
|
}
|
|
231
|
+
if (playback?.wordBoundaries === false)
|
|
232
|
+
this.highlightWordlessPlayback();
|
|
173
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* A word-mode part whose playback sends no boundaries, such as a server
|
|
236
|
+
* response without speech marks, highlights as a sentence-mode read does.
|
|
237
|
+
* Catalog chunks and a single speak highlight the same in both modes.
|
|
238
|
+
*/
|
|
239
|
+
highlightWordlessPlayback() {
|
|
240
|
+
const paint = this.wordlessPartHighlight;
|
|
241
|
+
if (!paint || this.activeHighlightMode !== "word")
|
|
242
|
+
return;
|
|
243
|
+
this.highlightCoordinator?.clearTTSWord();
|
|
244
|
+
paint();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Defers marking the run playing, and the highlights queued through
|
|
248
|
+
* runWhenPlaybackStarts, until the provider or the recording signals that
|
|
249
|
+
* audio started. Returns the barrier's cleanup, or null when nothing will
|
|
250
|
+
* signal a start, in which case the caller marks the run playing itself.
|
|
251
|
+
*/
|
|
174
252
|
installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
|
|
175
253
|
this.clearPlaybackStartBarrier();
|
|
176
254
|
const provider = this.provider;
|
|
@@ -178,12 +256,12 @@ export class TTSService {
|
|
|
178
256
|
if (!startAwareProvider && !hasMediaStartSignal)
|
|
179
257
|
return null;
|
|
180
258
|
const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
|
|
181
|
-
const onPlaybackStart = () => {
|
|
259
|
+
const onPlaybackStart = (playback) => {
|
|
182
260
|
try {
|
|
183
|
-
this.notifyPlaybackStarted(runId);
|
|
261
|
+
this.notifyPlaybackStarted(runId, playback);
|
|
184
262
|
}
|
|
185
263
|
finally {
|
|
186
|
-
previousOnPlaybackStart?.();
|
|
264
|
+
previousOnPlaybackStart?.(playback);
|
|
187
265
|
}
|
|
188
266
|
};
|
|
189
267
|
if (startAwareProvider) {
|
|
@@ -259,17 +337,19 @@ export class TTSService {
|
|
|
259
337
|
* run, whatever it is still waiting for: the provider, its content or the
|
|
260
338
|
* provider's audio.
|
|
261
339
|
*/
|
|
262
|
-
beginRun() {
|
|
340
|
+
beginRun(owner) {
|
|
263
341
|
// A recorded clip that starts the new run would otherwise play over the
|
|
264
342
|
// previous run's synthesis, which only the provider's next speak stops.
|
|
265
343
|
const supersedesPlayback = this.state === PlaybackState.LOADING ||
|
|
266
344
|
this.state === PlaybackState.PLAYING ||
|
|
267
345
|
this.state === PlaybackState.PAUSED;
|
|
268
346
|
const runId = ++this.speakRunId;
|
|
347
|
+
this.runOwner = owner;
|
|
269
348
|
this.abandonRun();
|
|
270
349
|
if (supersedesPlayback)
|
|
271
350
|
this.provider?.stop();
|
|
272
|
-
|
|
351
|
+
// Announced even from loading, so a listener sees the owner change.
|
|
352
|
+
this.setState(PlaybackState.LOADING, true);
|
|
273
353
|
return runId;
|
|
274
354
|
}
|
|
275
355
|
/** The run's audio is sounding; under a pause, it sounds again on resume. */
|
|
@@ -295,9 +375,6 @@ export class TTSService {
|
|
|
295
375
|
}
|
|
296
376
|
/**
|
|
297
377
|
* Initialize TTS service with a provider
|
|
298
|
-
*
|
|
299
|
-
* @param provider TTS provider instance
|
|
300
|
-
* @param config Provider configuration
|
|
301
378
|
*/
|
|
302
379
|
async initialize(provider, config = {}) {
|
|
303
380
|
if (this.disposed)
|
|
@@ -391,8 +468,6 @@ export class TTSService {
|
|
|
391
468
|
*
|
|
392
469
|
* When set, the speak() method will check for pre-authored spoken
|
|
393
470
|
* content in catalogs before falling back to generated TTS.
|
|
394
|
-
*
|
|
395
|
-
* @param resolver AccessibilityCatalogResolver instance
|
|
396
471
|
*/
|
|
397
472
|
setCatalogResolver(resolver) {
|
|
398
473
|
this.catalogResolver = resolver;
|
|
@@ -529,12 +604,6 @@ export class TTSService {
|
|
|
529
604
|
}
|
|
530
605
|
this.highlightCoordinator.highlightTTSSentence(paintedRanges);
|
|
531
606
|
}
|
|
532
|
-
/**
|
|
533
|
-
* Get provider capabilities
|
|
534
|
-
*/
|
|
535
|
-
getCapabilities() {
|
|
536
|
-
return this.currentProvider?.getCapabilities() || null;
|
|
537
|
-
}
|
|
538
607
|
providerOptions() {
|
|
539
608
|
const options = this.ttsConfig.providerOptions;
|
|
540
609
|
return options && typeof options === "object" ? options : {};
|
|
@@ -603,8 +672,8 @@ export class TTSService {
|
|
|
603
672
|
});
|
|
604
673
|
}
|
|
605
674
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
675
|
+
* Text-collection options for `locale`, with the provider's boundary-spacing
|
|
676
|
+
* mode when it names a known one.
|
|
608
677
|
*/
|
|
609
678
|
getTextProcessingOptions(locale) {
|
|
610
679
|
const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
|
|
@@ -640,6 +709,10 @@ export class TTSService {
|
|
|
640
709
|
}
|
|
641
710
|
return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
|
|
642
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Rebuilds normalizedToDOM from `element`'s visible text; traced runs also
|
|
714
|
+
* log how it differs from `spokenText`.
|
|
715
|
+
*/
|
|
643
716
|
buildPositionMap(element, spokenText, language) {
|
|
644
717
|
this.normalizedToDOM.clear();
|
|
645
718
|
const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
|
|
@@ -655,20 +728,7 @@ export class TTSService {
|
|
|
655
728
|
normalizedPreview: normalizedDomText.substring(0, 150),
|
|
656
729
|
differAtIndex: spokenText === normalizedDomText
|
|
657
730
|
? null
|
|
658
|
-
: (
|
|
659
|
-
for (let i = 0; i < Math.min(spokenText.length, normalizedDomText.length); i++) {
|
|
660
|
-
if (spokenText[i] !== normalizedDomText[i]) {
|
|
661
|
-
return {
|
|
662
|
-
index: i,
|
|
663
|
-
spokenChar: spokenText[i],
|
|
664
|
-
normalizedChar: normalizedDomText[i],
|
|
665
|
-
spokenContext: spokenText.substring(Math.max(0, i - 20), i + 20),
|
|
666
|
-
normalizedContext: normalizedDomText.substring(Math.max(0, i - 20), i + 20),
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return null;
|
|
671
|
-
})(),
|
|
731
|
+
: firstDifference(spokenText, normalizedDomText),
|
|
672
732
|
});
|
|
673
733
|
logger.debug("Position map built:", {
|
|
674
734
|
entries: this.normalizedToDOM.size,
|
|
@@ -697,20 +757,21 @@ export class TTSService {
|
|
|
697
757
|
}
|
|
698
758
|
return fallback;
|
|
699
759
|
};
|
|
760
|
+
const defaults = DEFAULT_STRUCTURAL_PAUSE_PROFILE;
|
|
700
761
|
return {
|
|
701
|
-
baseMs: toNumber(custom.baseMs,
|
|
762
|
+
baseMs: toNumber(custom.baseMs, defaults.baseMs),
|
|
702
763
|
units: {
|
|
703
|
-
minor: toNumber(customUnits.minor,
|
|
704
|
-
section: toNumber(customUnits.section,
|
|
705
|
-
major: toNumber(customUnits.major,
|
|
764
|
+
minor: toNumber(customUnits.minor, defaults.units.minor),
|
|
765
|
+
section: toNumber(customUnits.section, defaults.units.section),
|
|
766
|
+
major: toNumber(customUnits.major, defaults.units.major),
|
|
706
767
|
},
|
|
707
|
-
minMs: toNumber(custom.minMs,
|
|
708
|
-
maxMs: toNumber(custom.maxMs,
|
|
768
|
+
minMs: toNumber(custom.minMs, defaults.minMs),
|
|
769
|
+
maxMs: toNumber(custom.maxMs, defaults.maxMs),
|
|
709
770
|
};
|
|
710
771
|
}
|
|
711
772
|
resolvePauseMsFromUnits(units) {
|
|
712
773
|
const profile = this.getStructuralPauseProfile();
|
|
713
|
-
const rate = Math.max(
|
|
774
|
+
const rate = Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, Number(this.ttsConfig.rate || 1)));
|
|
714
775
|
const raw = (profile.baseMs * Math.max(0, units)) / rate;
|
|
715
776
|
return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
|
|
716
777
|
}
|
|
@@ -730,12 +791,8 @@ export class TTSService {
|
|
|
730
791
|
return false;
|
|
731
792
|
return /<\s*break\b|<\s*speak\b|<\s*prosody\b|<\s*p\b|<\s*s\b/i.test(text);
|
|
732
793
|
}
|
|
733
|
-
isElementHidden(element) {
|
|
734
|
-
return isElementHiddenForTTS(element);
|
|
735
|
-
}
|
|
736
794
|
getBoundaryStrength(element) {
|
|
737
|
-
|
|
738
|
-
if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
|
|
795
|
+
if (explicitBreakMsOf(element) !== null)
|
|
739
796
|
return "major";
|
|
740
797
|
const tagName = element.tagName.toUpperCase();
|
|
741
798
|
if (tagName.match(/^H[1-6]$/))
|
|
@@ -750,26 +807,16 @@ export class TTSService {
|
|
|
750
807
|
tagName === "LI") {
|
|
751
808
|
return "section";
|
|
752
809
|
}
|
|
753
|
-
if (typeof window !== "undefined") {
|
|
754
|
-
const display = window.getComputedStyle(element).display;
|
|
755
|
-
if (display === "block" ||
|
|
756
|
-
display === "list-item" ||
|
|
757
|
-
display.startsWith("table") ||
|
|
758
|
-
display === "flex" ||
|
|
759
|
-
display === "grid") {
|
|
760
|
-
return "minor";
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
810
|
return "minor";
|
|
764
811
|
}
|
|
765
812
|
getBoundaryAnchor(textNode, root) {
|
|
766
813
|
let current = flatTreeParentElement(textNode);
|
|
767
814
|
let best = null;
|
|
768
815
|
while (current && current !== root) {
|
|
769
|
-
if (
|
|
816
|
+
if (isElementHiddenForTTS(current))
|
|
770
817
|
return null;
|
|
771
|
-
const explicitBreakMs =
|
|
772
|
-
if (
|
|
818
|
+
const explicitBreakMs = explicitBreakMsOf(current);
|
|
819
|
+
if (explicitBreakMs !== null) {
|
|
773
820
|
return {
|
|
774
821
|
anchor: current,
|
|
775
822
|
units: explicitBreakMs / this.getStructuralPauseProfile().baseMs,
|
|
@@ -784,18 +831,8 @@ export class TTSService {
|
|
|
784
831
|
best = current;
|
|
785
832
|
break;
|
|
786
833
|
}
|
|
787
|
-
if (!best)
|
|
788
|
-
|
|
789
|
-
const display = window.getComputedStyle(current).display;
|
|
790
|
-
if (display === "block" ||
|
|
791
|
-
display === "list-item" ||
|
|
792
|
-
display.startsWith("table") ||
|
|
793
|
-
display === "flex" ||
|
|
794
|
-
display === "grid") {
|
|
795
|
-
best = current;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
834
|
+
if (!best && isBlockDisplay(current))
|
|
835
|
+
best = current;
|
|
799
836
|
current = flatTreeParentElement(current);
|
|
800
837
|
}
|
|
801
838
|
if (!best)
|
|
@@ -812,7 +849,7 @@ export class TTSService {
|
|
|
812
849
|
* part of it.
|
|
813
850
|
*/
|
|
814
851
|
createSpeechPlan(contentElement, normalizedText, textOffset, language) {
|
|
815
|
-
const boundaries = this.collectSpeechPlanBoundaries(contentElement,
|
|
852
|
+
const boundaries = this.collectSpeechPlanBoundaries(contentElement, language);
|
|
816
853
|
if (textOffset === 0) {
|
|
817
854
|
return this.createSpeechPlanSegments(normalizedText, boundaries);
|
|
818
855
|
}
|
|
@@ -822,7 +859,7 @@ export class TTSService {
|
|
|
822
859
|
}
|
|
823
860
|
return this.createSpeechPlanSegments(normalizedText, shifted);
|
|
824
861
|
}
|
|
825
|
-
collectSpeechPlanBoundaries(contentElement,
|
|
862
|
+
collectSpeechPlanBoundaries(contentElement, language) {
|
|
826
863
|
const boundaries = new Map();
|
|
827
864
|
// The same locale the position map used, so the boundary offsets index the
|
|
828
865
|
// text the map built.
|
|
@@ -832,7 +869,7 @@ export class TTSService {
|
|
|
832
869
|
for (const textNode of flatTreeTextNodes(contentElement)) {
|
|
833
870
|
const parent = flatTreeParentElement(textNode);
|
|
834
871
|
if (parent &&
|
|
835
|
-
!
|
|
872
|
+
!isElementHiddenForTTS(parent) &&
|
|
836
873
|
!isNodeSuppressedForTTS(textNode)) {
|
|
837
874
|
const boundary = this.getBoundaryAnchor(textNode, contentElement);
|
|
838
875
|
const boundaryPoint = nodeStartOffsets.get(textNode);
|
|
@@ -863,11 +900,7 @@ export class TTSService {
|
|
|
863
900
|
const segments = [];
|
|
864
901
|
let start = 0;
|
|
865
902
|
for (const point of points) {
|
|
866
|
-
const
|
|
867
|
-
const leading = raw.match(/^\s*/)?.[0].length || 0;
|
|
868
|
-
const trailing = raw.match(/\s*$/)?.[0].length || 0;
|
|
869
|
-
const segmentStart = start + leading;
|
|
870
|
-
const segmentEnd = point - trailing;
|
|
903
|
+
const { start: segmentStart, end: segmentEnd } = trimSpan(normalizedText, start, point);
|
|
871
904
|
if (segmentEnd > segmentStart) {
|
|
872
905
|
const text = normalizedText.substring(segmentStart, segmentEnd);
|
|
873
906
|
segments.push({
|
|
@@ -925,11 +958,7 @@ export class TTSService {
|
|
|
925
958
|
}
|
|
926
959
|
let cursor = segmentStart;
|
|
927
960
|
for (const point of [...splitPoints, segmentEnd]) {
|
|
928
|
-
const
|
|
929
|
-
const leadingWhitespace = raw.match(/^\s*/)?.[0].length || 0;
|
|
930
|
-
const trailingWhitespace = raw.match(/\s*$/)?.[0].length || 0;
|
|
931
|
-
const startOffset = cursor + leadingWhitespace;
|
|
932
|
-
const endOffset = point - trailingWhitespace;
|
|
961
|
+
const { start: startOffset, end: endOffset } = trimSpan(sourceText, cursor, point);
|
|
933
962
|
if (endOffset > startOffset) {
|
|
934
963
|
result.push({
|
|
935
964
|
text: sourceText.substring(startOffset, endOffset),
|
|
@@ -953,6 +982,9 @@ export class TTSService {
|
|
|
953
982
|
return this.splitSegmentsAtBoundaries(grammaticalSegments, structuralBoundaries, args.contentToSpeak);
|
|
954
983
|
}
|
|
955
984
|
createSeekSegmentsFromSpeechChunks(chunks) {
|
|
985
|
+
// One seek segment per chunk, at offsets into a virtual text that joins the
|
|
986
|
+
// chunks with one separator character. A chunk whose speech is SSML is
|
|
987
|
+
// measured by its plain fallback or visible text.
|
|
956
988
|
let offset = 0;
|
|
957
989
|
return chunks.map((chunk) => {
|
|
958
990
|
const speechText = chunk.plainFallback?.speechText ||
|
|
@@ -966,23 +998,12 @@ export class TTSService {
|
|
|
966
998
|
startOffset: offset,
|
|
967
999
|
pauseMsAfter: 0,
|
|
968
1000
|
};
|
|
969
|
-
offset +=
|
|
1001
|
+
offset += text.length + 1;
|
|
970
1002
|
return segment;
|
|
971
1003
|
});
|
|
972
1004
|
}
|
|
973
1005
|
getCurrentSeekSegmentIndex() {
|
|
974
|
-
|
|
975
|
-
return 0;
|
|
976
|
-
let index = 0;
|
|
977
|
-
for (let i = 0; i < this.seekSegments.length; i++) {
|
|
978
|
-
if (this.seekSegments[i].startOffset <= this.currentBoundaryOffset) {
|
|
979
|
-
index = i;
|
|
980
|
-
}
|
|
981
|
-
else {
|
|
982
|
-
break;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
return index;
|
|
1006
|
+
return Math.max(0, this.getSegmentIndexForOffset(this.seekSegments, this.currentBoundaryOffset));
|
|
986
1007
|
}
|
|
987
1008
|
async speakWithPlan(segments, runId, options) {
|
|
988
1009
|
if (!this.provider || segments.length === 0)
|
|
@@ -1022,12 +1043,21 @@ export class TTSService {
|
|
|
1022
1043
|
if (runId !== this.speakRunId)
|
|
1023
1044
|
return;
|
|
1024
1045
|
this.currentBoundaryOffset = segment.startOffset;
|
|
1046
|
+
const highlightSegment = () => {
|
|
1047
|
+
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
1048
|
+
};
|
|
1025
1049
|
if (shouldTrackSentenceProgress) {
|
|
1026
|
-
this.runWhenPlaybackStarts(runId,
|
|
1027
|
-
|
|
1028
|
-
|
|
1050
|
+
this.runWhenPlaybackStarts(runId, highlightSegment);
|
|
1051
|
+
}
|
|
1052
|
+
this.wordlessPartHighlight = highlightSegment;
|
|
1053
|
+
try {
|
|
1054
|
+
await this.speakWithinLimit(segment.text, runId);
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
if (this.wordlessPartHighlight === highlightSegment) {
|
|
1058
|
+
this.wordlessPartHighlight = null;
|
|
1059
|
+
}
|
|
1029
1060
|
}
|
|
1030
|
-
await this.speakWithinLimit(segment.text, runId);
|
|
1031
1061
|
const pauseMs = segment.pauseMsAfter ?? 0;
|
|
1032
1062
|
if (pauseMs > 0 && runId === this.speakRunId) {
|
|
1033
1063
|
await this.waitStructuralPause(pauseMs);
|
|
@@ -1100,7 +1130,8 @@ export class TTSService {
|
|
|
1100
1130
|
const pendingReadiness = this.pendingReadiness();
|
|
1101
1131
|
// A speak waiting on its provider is already the current run, so a pause
|
|
1102
1132
|
// or stop issued meanwhile applies to it.
|
|
1103
|
-
const
|
|
1133
|
+
const owner = options.ownerId ?? null;
|
|
1134
|
+
const waitingRunId = pendingReadiness ? this.beginRun(owner) : null;
|
|
1104
1135
|
if (pendingReadiness) {
|
|
1105
1136
|
try {
|
|
1106
1137
|
await pendingReadiness;
|
|
@@ -1128,7 +1159,25 @@ export class TTSService {
|
|
|
1128
1159
|
this.setState(PlaybackState.IDLE);
|
|
1129
1160
|
return;
|
|
1130
1161
|
}
|
|
1131
|
-
|
|
1162
|
+
const runId = waitingRunId ?? this.beginRun(owner);
|
|
1163
|
+
if (options.rate !== undefined) {
|
|
1164
|
+
await this.applyRunRate(options.rate);
|
|
1165
|
+
if (runId !== this.speakRunId)
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
await this.speakContent(content.text, content.options, runId);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Sets the rate a run starts at, after any rate write in flight. The run has
|
|
1172
|
+
* no playback yet, so nothing restarts.
|
|
1173
|
+
*/
|
|
1174
|
+
async applyRunRate(rate) {
|
|
1175
|
+
await this.playbackRateWriteQueue;
|
|
1176
|
+
const nextRate = this.normalizePlaybackRate(rate);
|
|
1177
|
+
const currentRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1178
|
+
if (nextRate === currentRate)
|
|
1179
|
+
return;
|
|
1180
|
+
await this.updateSettings({ rate: nextRate });
|
|
1132
1181
|
}
|
|
1133
1182
|
/**
|
|
1134
1183
|
* Suppression is read from the live ancestors of what is read, because the
|
|
@@ -1143,22 +1192,38 @@ export class TTSService {
|
|
|
1143
1192
|
resolveElementTarget(root, options, language) {
|
|
1144
1193
|
if (this.isSuppressedTarget(root))
|
|
1145
1194
|
return null;
|
|
1146
|
-
|
|
1195
|
+
const contentOptions = {
|
|
1196
|
+
catalogId: options.catalogId,
|
|
1197
|
+
catalogContext: options.catalogContext,
|
|
1198
|
+
language: language.locale,
|
|
1199
|
+
contentLanguage: language.contentLanguage,
|
|
1200
|
+
contentElement: root,
|
|
1201
|
+
};
|
|
1202
|
+
// Math-aware, so an element holding only an equation still reads; a spoken
|
|
1203
|
+
// card reads for content with no text, an image among them.
|
|
1147
1204
|
const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
|
|
1148
|
-
if (!text) {
|
|
1205
|
+
if (!text && !this.holdsSpokenCard(root, contentOptions)) {
|
|
1149
1206
|
console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
|
|
1150
1207
|
return null;
|
|
1151
1208
|
}
|
|
1152
|
-
return {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1209
|
+
return { text, options: contentOptions };
|
|
1210
|
+
}
|
|
1211
|
+
/** Whether the root's card, or a card of a node inside it, would be read. */
|
|
1212
|
+
holdsSpokenCard(root, options) {
|
|
1213
|
+
if (!this.catalogResolver)
|
|
1214
|
+
return false;
|
|
1215
|
+
if (options.catalogId) {
|
|
1216
|
+
const card = this.catalogResolver.getAlternative(options.catalogId, {
|
|
1217
|
+
type: "spoken",
|
|
1218
|
+
language: options.language,
|
|
1219
|
+
useFallback: true,
|
|
1220
|
+
context: options.catalogContext,
|
|
1221
|
+
form: "content",
|
|
1222
|
+
});
|
|
1223
|
+
if (card?.content !== undefined)
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
return this.collectCatalogSpeechChunks(root, options).some((chunk) => chunk.sourceElement);
|
|
1162
1227
|
}
|
|
1163
1228
|
resolveRangeTarget(range, options, language) {
|
|
1164
1229
|
if (this.isSuppressedTarget(range.commonAncestorContainer))
|
|
@@ -1237,27 +1302,35 @@ export class TTSService {
|
|
|
1237
1302
|
this.logResolvedSpeechContent({
|
|
1238
1303
|
contentToSpeak,
|
|
1239
1304
|
speechSource,
|
|
1240
|
-
catalogId: options
|
|
1305
|
+
catalogId: options.catalogId,
|
|
1241
1306
|
});
|
|
1242
1307
|
this.initializeSpeakTracking(highlightText, options);
|
|
1243
1308
|
const highlightMode = speechMatchesVisibleText
|
|
1244
1309
|
? this.resolveHighlightMode()
|
|
1245
1310
|
: "sentence";
|
|
1246
1311
|
this.activeHighlightMode = highlightMode;
|
|
1247
|
-
|
|
1312
|
+
const wordBoundaryOffset = options.wordBoundaryOffset || 0;
|
|
1313
|
+
this.activeWordBoundaryOffset = wordBoundaryOffset;
|
|
1248
1314
|
const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
|
|
1249
1315
|
const shouldUsePlan = !!this.currentContentElement &&
|
|
1250
1316
|
!usedCatalogSpoken &&
|
|
1251
1317
|
!hasExplicitBreaks &&
|
|
1252
1318
|
speechMatchesVisibleText;
|
|
1253
1319
|
this.playbackChunks = speechChunks?.length ? speechChunks : [];
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1320
|
+
if (this.playbackChunks.length) {
|
|
1321
|
+
this.seekSegments = this.createSeekSegmentsFromSpeechChunks(this.playbackChunks);
|
|
1322
|
+
}
|
|
1323
|
+
else if (hasExplicitBreaks || !speechMatchesVisibleText) {
|
|
1324
|
+
// Authored SSML breaks, and speech that differs from the visible text,
|
|
1325
|
+
// leave no visible-text offsets to seek to.
|
|
1326
|
+
this.seekSegments = [];
|
|
1327
|
+
}
|
|
1328
|
+
else if (shouldUsePlan && this.currentContentElement) {
|
|
1329
|
+
this.seekSegments = this.createSpeechPlan(this.currentContentElement, normalizedText, wordBoundaryOffset, options.language);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
this.seekSegments = this.createSeekSegmentsFromText(highlightText);
|
|
1333
|
+
}
|
|
1261
1334
|
this.sentenceHighlightSegments = hasExplicitBreaks
|
|
1262
1335
|
? []
|
|
1263
1336
|
: this.createSentenceHighlightSegments({
|
|
@@ -1276,7 +1349,7 @@ export class TTSService {
|
|
|
1276
1349
|
if (speechMatchesVisibleText) {
|
|
1277
1350
|
this.configureWordBoundaryHighlighting({
|
|
1278
1351
|
highlightMode,
|
|
1279
|
-
wordBoundaryOffset
|
|
1352
|
+
wordBoundaryOffset,
|
|
1280
1353
|
});
|
|
1281
1354
|
}
|
|
1282
1355
|
else {
|
|
@@ -1421,6 +1494,9 @@ export class TTSService {
|
|
|
1421
1494
|
};
|
|
1422
1495
|
}
|
|
1423
1496
|
collectCatalogSpeechChunks(root, options) {
|
|
1497
|
+
const catalogResolver = this.catalogResolver;
|
|
1498
|
+
if (!catalogResolver)
|
|
1499
|
+
return [];
|
|
1424
1500
|
const chunks = [];
|
|
1425
1501
|
let textBuffer = "";
|
|
1426
1502
|
const flushTextBuffer = () => {
|
|
@@ -1457,14 +1533,14 @@ export class TTSService {
|
|
|
1457
1533
|
// Two lookups rather than one, because `form` is a preference: asking for
|
|
1458
1534
|
// the payload form happily returns a script card when no recording
|
|
1459
1535
|
// exists, so the answer has to be checked rather than assumed.
|
|
1460
|
-
const audioCard =
|
|
1536
|
+
const audioCard = catalogResolver.getAlternative(catalogIdRef, {
|
|
1461
1537
|
...lookup,
|
|
1462
1538
|
form: "payload",
|
|
1463
1539
|
});
|
|
1464
1540
|
const audio = audioCard?.payload
|
|
1465
1541
|
? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
|
|
1466
1542
|
: undefined;
|
|
1467
|
-
const scriptCard =
|
|
1543
|
+
const scriptCard = catalogResolver.getAlternative(catalogIdRef, {
|
|
1468
1544
|
...lookup,
|
|
1469
1545
|
form: "content",
|
|
1470
1546
|
});
|
|
@@ -1518,47 +1594,45 @@ export class TTSService {
|
|
|
1518
1594
|
const visibleText = collectedVisible.visibleText ||
|
|
1519
1595
|
normalizeTextForSpeech(flatTextContent(element));
|
|
1520
1596
|
const regionElement = resolveReadableRegion(element, root);
|
|
1521
|
-
// The script chunk
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1597
|
+
// The script chunk doubles as the recording's fallback: word-level
|
|
1598
|
+
// alignment is meaningful only on the synthesized path, which is the one
|
|
1599
|
+
// that runs if the audio cannot play.
|
|
1600
|
+
let scriptChunk = null;
|
|
1601
|
+
if (catalog.script !== undefined) {
|
|
1602
|
+
const script = catalog.script;
|
|
1603
|
+
const alignment = createCatalogSpanAlignment({
|
|
1604
|
+
speechText: script,
|
|
1605
|
+
visibleText,
|
|
1606
|
+
});
|
|
1607
|
+
const mathElement = getSingleMathElementForAlignment(element, visibleText);
|
|
1608
|
+
const mathAlignment = mathElement
|
|
1609
|
+
? createMathAwareAlignment({
|
|
1610
|
+
mathElement,
|
|
1529
1611
|
speechText: script,
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1612
|
+
})
|
|
1613
|
+
: undefined;
|
|
1614
|
+
const mathAlignments = mathAlignment
|
|
1615
|
+
? undefined
|
|
1616
|
+
: getMathElementsForAlignment(element).map((candidate) => ({
|
|
1617
|
+
element: candidate,
|
|
1618
|
+
alignment: createMathAwareAlignment({
|
|
1619
|
+
mathElement: candidate,
|
|
1536
1620
|
speechText: script,
|
|
1537
|
-
})
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
regionElement,
|
|
1553
|
-
speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
|
|
1554
|
-
playbackMode: alignment.playbackMode,
|
|
1555
|
-
alignment,
|
|
1556
|
-
mathAlignment,
|
|
1557
|
-
mathAlignments,
|
|
1558
|
-
visibleMap: collectedVisible.map,
|
|
1559
|
-
};
|
|
1560
|
-
})()
|
|
1561
|
-
: null;
|
|
1621
|
+
}),
|
|
1622
|
+
}));
|
|
1623
|
+
scriptChunk = {
|
|
1624
|
+
speechText: script,
|
|
1625
|
+
visibleText,
|
|
1626
|
+
sourceElement: element,
|
|
1627
|
+
regionElement,
|
|
1628
|
+
speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
|
|
1629
|
+
playbackMode: alignment.playbackMode,
|
|
1630
|
+
alignment,
|
|
1631
|
+
mathAlignment,
|
|
1632
|
+
mathAlignments,
|
|
1633
|
+
visibleMap: collectedVisible.map,
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1562
1636
|
if (catalog.audio) {
|
|
1563
1637
|
chunks.push(scriptChunk
|
|
1564
1638
|
? {
|
|
@@ -1743,7 +1817,7 @@ export class TTSService {
|
|
|
1743
1817
|
this.lastRenderedRegionTarget = null;
|
|
1744
1818
|
if (chunk.regionRange) {
|
|
1745
1819
|
const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
|
|
1746
|
-
this.highlightCoordinator.
|
|
1820
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1747
1821
|
this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
|
|
1748
1822
|
return;
|
|
1749
1823
|
}
|
|
@@ -1751,7 +1825,7 @@ export class TTSService {
|
|
|
1751
1825
|
return;
|
|
1752
1826
|
const range = document.createRange();
|
|
1753
1827
|
range.selectNodeContents(element);
|
|
1754
|
-
this.highlightCoordinator.
|
|
1828
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1755
1829
|
this.paintTTSSentenceRanges([range]);
|
|
1756
1830
|
}
|
|
1757
1831
|
catch {
|
|
@@ -1799,7 +1873,7 @@ export class TTSService {
|
|
|
1799
1873
|
// Clear the word layer so the previously spoken word does not stay
|
|
1800
1874
|
// highlighted. (The "hold last token" behavior returns a non-null
|
|
1801
1875
|
// target, so it does not reach here.)
|
|
1802
|
-
this.highlightCoordinator.
|
|
1876
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1803
1877
|
return;
|
|
1804
1878
|
}
|
|
1805
1879
|
if (target.type === "text-range") {
|
|
@@ -1847,17 +1921,18 @@ export class TTSService {
|
|
|
1847
1921
|
await this.speakCatalogChunkOnce(chunk, runId);
|
|
1848
1922
|
}
|
|
1849
1923
|
catch (error) {
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1853
|
-
// fallback, so this cannot recurse.
|
|
1924
|
+
// A chunk with a plainFallback retries once with it when speaking fails:
|
|
1925
|
+
// a rejected SSML math chunk falls back to its plain-text variant, a
|
|
1926
|
+
// recording that cannot play to its script. The fallback carries no
|
|
1927
|
+
// fallback of its own, so this cannot recurse.
|
|
1854
1928
|
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1855
1929
|
logger.debug("SSML chunk speak failed; retrying plain text", {
|
|
1856
1930
|
message: error instanceof Error ? error.message : String(error),
|
|
1857
1931
|
});
|
|
1858
|
-
// A
|
|
1859
|
-
// provider
|
|
1860
|
-
//
|
|
1932
|
+
// A run that began with a recording holds the start barrier for the
|
|
1933
|
+
// recording's start signal. A provider with no start signal of its own
|
|
1934
|
+
// would never release it, so drop the recording's queued highlight and
|
|
1935
|
+
// mark the run playing now.
|
|
1861
1936
|
if (this.playbackStartDeferredRunId === runId &&
|
|
1862
1937
|
this.provider &&
|
|
1863
1938
|
!("onPlaybackStart" in this.provider)) {
|
|
@@ -2001,6 +2076,9 @@ export class TTSService {
|
|
|
2001
2076
|
mathTokenHighlighting,
|
|
2002
2077
|
})
|
|
2003
2078
|
: null;
|
|
2079
|
+
const providerOffsetSpace = !pipelineChunk || pipelineChunk.offsetSpace === "unsupported"
|
|
2080
|
+
? "unknown"
|
|
2081
|
+
: pipelineChunk.offsetSpace;
|
|
2004
2082
|
this.runWhenPlaybackStarts(runId, () => {
|
|
2005
2083
|
if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
|
|
2006
2084
|
this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
|
|
@@ -2037,21 +2115,15 @@ export class TTSService {
|
|
|
2037
2115
|
const provider = this.provider;
|
|
2038
2116
|
const previousOnWordBoundary = provider.onWordBoundary;
|
|
2039
2117
|
const installedHandler = (word, position, length) => {
|
|
2040
|
-
if (runId !== this.speakRunId)
|
|
2041
|
-
return;
|
|
2042
|
-
if (highlightPlan && pipelineChunk) {
|
|
2043
|
-
const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
|
|
2044
|
-
? "unknown"
|
|
2045
|
-
: pipelineChunk.offsetSpace;
|
|
2046
|
-
this.renderHighlightDecision(highlightPlan.resolveBoundary({
|
|
2047
|
-
chunkId: pipelineChunk.id,
|
|
2048
|
-
word,
|
|
2049
|
-
position,
|
|
2050
|
-
length,
|
|
2051
|
-
providerOffsetSpace,
|
|
2052
|
-
}));
|
|
2118
|
+
if (runId !== this.speakRunId || !highlightPlan || !pipelineChunk)
|
|
2053
2119
|
return;
|
|
2054
|
-
|
|
2120
|
+
this.renderHighlightDecision(highlightPlan.resolveBoundary({
|
|
2121
|
+
chunkId: pipelineChunk.id,
|
|
2122
|
+
word,
|
|
2123
|
+
position,
|
|
2124
|
+
length,
|
|
2125
|
+
providerOffsetSpace,
|
|
2126
|
+
}));
|
|
2055
2127
|
};
|
|
2056
2128
|
provider.onWordBoundary = installedHandler;
|
|
2057
2129
|
try {
|
|
@@ -2080,16 +2152,12 @@ export class TTSService {
|
|
|
2080
2152
|
}
|
|
2081
2153
|
return;
|
|
2082
2154
|
}
|
|
2083
|
-
if (args.shouldUsePlan &&
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
}
|
|
2090
|
-
else {
|
|
2091
|
-
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
2092
|
-
}
|
|
2155
|
+
if (args.shouldUsePlan &&
|
|
2156
|
+
this.currentContentElement &&
|
|
2157
|
+
this.seekSegments.length > 0) {
|
|
2158
|
+
await this.speakWithPlan(this.seekSegments, args.runId, {
|
|
2159
|
+
highlightMode: args.highlightMode,
|
|
2160
|
+
});
|
|
2093
2161
|
return;
|
|
2094
2162
|
}
|
|
2095
2163
|
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
@@ -2111,6 +2179,7 @@ export class TTSService {
|
|
|
2111
2179
|
this.playbackChunks = [];
|
|
2112
2180
|
this.sentenceHighlightSegments = [];
|
|
2113
2181
|
this.activeSentenceStartOffset = null;
|
|
2182
|
+
this.wordlessPartHighlight = null;
|
|
2114
2183
|
}
|
|
2115
2184
|
/**
|
|
2116
2185
|
* The selected part of `root`'s normalized visible text and where it starts, or
|
|
@@ -2171,7 +2240,7 @@ export class TTSService {
|
|
|
2171
2240
|
normalizePlaybackRate(rate) {
|
|
2172
2241
|
if (!Number.isFinite(rate))
|
|
2173
2242
|
return 1;
|
|
2174
|
-
return Math.max(
|
|
2243
|
+
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
|
|
2175
2244
|
}
|
|
2176
2245
|
async restartFromSeekIndex(targetIndex) {
|
|
2177
2246
|
if (!this.provider || this.seekSegments.length === 0)
|
|
@@ -2233,8 +2302,11 @@ export class TTSService {
|
|
|
2233
2302
|
this.clearHighlightsAndTracking();
|
|
2234
2303
|
}
|
|
2235
2304
|
catch (error) {
|
|
2236
|
-
|
|
2305
|
+
// A superseded run's failure is the abort that superseded it.
|
|
2306
|
+
if (runId !== this.speakRunId) {
|
|
2307
|
+
logger.debug("Superseded seek restart failed:", error);
|
|
2237
2308
|
return;
|
|
2309
|
+
}
|
|
2238
2310
|
this.lastError = error instanceof Error ? error.message : String(error);
|
|
2239
2311
|
this.setState(PlaybackState.ERROR);
|
|
2240
2312
|
this.clearHighlightsAndTracking();
|
|
@@ -2332,32 +2404,19 @@ export class TTSService {
|
|
|
2332
2404
|
await this.seekBy(-step);
|
|
2333
2405
|
}
|
|
2334
2406
|
/**
|
|
2335
|
-
* Stop playback
|
|
2407
|
+
* Stop playback and release the run owner. A run that ends on its own keeps
|
|
2408
|
+
* its owner, so the owning control can replay it.
|
|
2336
2409
|
*/
|
|
2337
2410
|
stop() {
|
|
2338
2411
|
this.speakRunId += 1;
|
|
2339
2412
|
this.abandonRun();
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
this.provider.stop();
|
|
2343
|
-
}
|
|
2344
|
-
this.setState(PlaybackState.IDLE);
|
|
2413
|
+
this.provider?.stop();
|
|
2414
|
+
this.clearHighlightsAndTracking();
|
|
2345
2415
|
this.currentText = null;
|
|
2346
|
-
//
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
// Clear tracking
|
|
2351
|
-
this.lastRenderedRegionTarget = null;
|
|
2352
|
-
this.currentContentElement = null;
|
|
2353
|
-
this.normalizedToDOM.clear();
|
|
2354
|
-
this.currentBoundaryOffset = 0;
|
|
2355
|
-
this.activeWordBoundaryOffset = 0;
|
|
2356
|
-
this.seekSegments = [];
|
|
2357
|
-
this.playbackChunks = [];
|
|
2358
|
-
this.sentenceHighlightSegments = [];
|
|
2359
|
-
this.activeSentenceStartOffset = null;
|
|
2360
|
-
this.activePlaybackRate = null;
|
|
2416
|
+
// Announced even from idle, so a listener sees the owner released.
|
|
2417
|
+
const releasedOwner = this.runOwner !== null;
|
|
2418
|
+
this.runOwner = null;
|
|
2419
|
+
this.setState(PlaybackState.IDLE, releasedOwner);
|
|
2361
2420
|
}
|
|
2362
2421
|
/**
|
|
2363
2422
|
* Stop playback and drop the provider, whose owner destroys it. A later speak
|
|
@@ -2389,50 +2448,14 @@ export class TTSService {
|
|
|
2389
2448
|
this.catalogResolver = null;
|
|
2390
2449
|
this.telemetryReporter = null;
|
|
2391
2450
|
}
|
|
2392
|
-
/**
|
|
2393
|
-
* Request UI-level TTS controls to hand off/deactivate.
|
|
2394
|
-
*
|
|
2395
|
-
* This is intentionally separate from playback controls so hosts can orchestrate
|
|
2396
|
-
* control handoff explicitly (for example: stop playback, then dismiss controls).
|
|
2397
|
-
*/
|
|
2398
|
-
requestControlHandoff() {
|
|
2399
|
-
if (typeof window === "undefined")
|
|
2400
|
-
return;
|
|
2401
|
-
const detail = { source: "host" };
|
|
2402
|
-
window.dispatchEvent(new CustomEvent(PIE_TTS_CONTROL_HANDOFF_EVENT, {
|
|
2403
|
-
detail,
|
|
2404
|
-
}));
|
|
2405
|
-
}
|
|
2406
|
-
/**
|
|
2407
|
-
* Check if currently playing
|
|
2408
|
-
*/
|
|
2409
|
-
isPlaying() {
|
|
2410
|
-
return this.state === PlaybackState.PLAYING;
|
|
2411
|
-
}
|
|
2412
|
-
/**
|
|
2413
|
-
* Check if paused
|
|
2414
|
-
*/
|
|
2415
|
-
isPaused() {
|
|
2416
|
-
return this.state === PlaybackState.PAUSED;
|
|
2417
|
-
}
|
|
2418
2451
|
/**
|
|
2419
2452
|
* Get current state
|
|
2420
2453
|
*/
|
|
2421
2454
|
getState() {
|
|
2422
2455
|
return this.state;
|
|
2423
2456
|
}
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
*/
|
|
2427
|
-
getCurrentText() {
|
|
2428
|
-
return this.currentText;
|
|
2429
|
-
}
|
|
2430
|
-
/**
|
|
2431
|
-
* Get the last error message
|
|
2432
|
-
* Returns null if no error has occurred
|
|
2433
|
-
*/
|
|
2434
|
-
getLastError() {
|
|
2435
|
-
return this.lastError;
|
|
2457
|
+
getRunOwner() {
|
|
2458
|
+
return this.runOwner;
|
|
2436
2459
|
}
|
|
2437
2460
|
/**
|
|
2438
2461
|
* Subscribe to playback state changes. The returned function unsubscribes;
|
|
@@ -2446,17 +2469,19 @@ export class TTSService {
|
|
|
2446
2469
|
};
|
|
2447
2470
|
}
|
|
2448
2471
|
/**
|
|
2449
|
-
* Set state and notify listeners
|
|
2472
|
+
* Set state and notify listeners. `announceUnchanged` notifies listeners even
|
|
2473
|
+
* when the state does not change; telemetry fires only on a change.
|
|
2450
2474
|
*/
|
|
2451
|
-
setState(newState) {
|
|
2452
|
-
if (this.state === newState)
|
|
2453
|
-
return;
|
|
2475
|
+
setState(newState, announceUnchanged = false) {
|
|
2454
2476
|
const previousState = this.state;
|
|
2477
|
+
if (previousState === newState && !announceUnchanged)
|
|
2478
|
+
return;
|
|
2455
2479
|
this.state = newState;
|
|
2456
|
-
// Notify all listeners
|
|
2457
2480
|
for (const listener of [...this.listeners]) {
|
|
2458
2481
|
listener(newState);
|
|
2459
2482
|
}
|
|
2483
|
+
if (previousState === newState)
|
|
2484
|
+
return;
|
|
2460
2485
|
void this.emitTelemetry("pie-tool-playback-state-changed", {
|
|
2461
2486
|
toolId: "textToSpeech",
|
|
2462
2487
|
previousState,
|