@pie-players/pie-assessment-toolkit 0.3.74 → 0.3.75
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 +349 -313
- package/dist/attempt/TestSession.d.ts +1 -20
- package/dist/attempt/TestSession.js +8 -99
- package/dist/components/ItemScope.custom-element.d.ts +1 -0
- package/dist/components/ItemScope.custom-element.js +1 -0
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
- package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-97hx60z4.js} +1 -1
- package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
- package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
- package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
- package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
- package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
- package/dist/components/item-scope-element.d.ts +2 -0
- package/dist/components/item-scope-element.js +2 -0
- package/dist/context/assessment-toolkit-context.d.ts +27 -6
- package/dist/index.d.ts +22 -47
- package/dist/index.js +12 -36
- package/dist/policy/core/PolicySource.d.ts +1 -1
- package/dist/policy/core/ToolPolicyEngine.d.ts +77 -32
- package/dist/policy/core/ToolPolicyEngine.js +136 -46
- package/dist/policy/core/compose-decision.d.ts +28 -7
- package/dist/policy/core/compose-decision.js +75 -54
- package/dist/policy/core/decision-types.d.ts +65 -34
- package/dist/policy/core/feature-decision.d.ts +16 -9
- package/dist/policy/core/feature-decision.js +14 -8
- package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
- package/dist/policy/core/pnp-policy-inputs.js +28 -56
- package/dist/policy/core/provenance.d.ts +9 -4
- package/dist/policy/core/provenance.js +1 -1
- package/dist/policy/engine.d.ts +7 -23
- package/dist/policy/engine.js +5 -20
- package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
- package/dist/policy/sources/PnpPolicySource.js +117 -118
- package/dist/runtime/SectionControllerBinding.d.ts +279 -0
- package/dist/runtime/SectionControllerBinding.js +450 -0
- package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
- package/dist/runtime/SectionRuntimeEngine.js +30 -372
- package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
- package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
- package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
- package/dist/runtime/adapter/dom-event-bridge.js +17 -44
- package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
- package/dist/runtime/adapter/subscriber-fanout.js +1 -2
- package/dist/runtime/catalog-context.d.ts +19 -0
- package/dist/runtime/catalog-context.js +28 -0
- package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
- package/dist/runtime/composition-emit-scheduler.js +9 -15
- package/dist/runtime/content-language.d.ts +32 -0
- package/dist/runtime/content-language.js +46 -0
- package/dist/runtime/content-region.d.ts +17 -0
- package/dist/runtime/content-region.js +29 -0
- package/dist/runtime/content-registration.d.ts +13 -0
- package/dist/runtime/content-registration.js +25 -0
- package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
- package/dist/runtime/core/SectionEngineCore.js +2 -2
- package/dist/runtime/core/cohort.d.ts +1 -1
- package/dist/runtime/core/cohort.js +1 -1
- package/dist/runtime/core/engine-input.d.ts +18 -30
- package/dist/runtime/core/engine-input.js +11 -12
- package/dist/runtime/core/engine-output.d.ts +16 -40
- package/dist/runtime/core/engine-output.js +9 -21
- package/dist/runtime/core/engine-readiness.d.ts +5 -37
- package/dist/runtime/core/engine-readiness.js +8 -41
- package/dist/runtime/core/engine-resolver.d.ts +46 -74
- package/dist/runtime/core/engine-resolver.js +18 -43
- package/dist/runtime/core/engine-state.d.ts +28 -36
- package/dist/runtime/core/engine-state.js +18 -21
- package/dist/runtime/core/engine-transition.d.ts +14 -19
- package/dist/runtime/core/engine-transition.js +71 -51
- package/dist/runtime/engine.d.ts +16 -19
- package/dist/runtime/engine.js +12 -19
- package/dist/runtime/page-warnings.d.ts +13 -0
- package/dist/runtime/page-warnings.js +22 -0
- package/dist/runtime/policy-input-forwarding.d.ts +25 -0
- package/dist/runtime/policy-input-forwarding.js +25 -0
- package/dist/runtime/registration-events.d.ts +11 -0
- package/dist/runtime/runtime-event-claim.d.ts +8 -0
- package/dist/runtime/runtime-event-claim.js +15 -0
- package/dist/runtime/shell-event-bridge.d.ts +45 -0
- package/dist/runtime/shell-event-bridge.js +127 -0
- package/dist/runtime/shell-registration.d.ts +73 -0
- package/dist/runtime/shell-registration.js +95 -0
- package/dist/runtime/shell-scope.d.ts +68 -0
- package/dist/runtime/shell-scope.js +188 -0
- package/dist/runtime/tool-host-contract.d.ts +8 -10
- package/dist/runtime/tool-host-contract.js +11 -11
- package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
- package/dist/runtime/unclaimed-registration-watch.js +23 -0
- package/dist/services/ElementToolStateStore.d.ts +14 -10
- package/dist/services/ElementToolStateStore.js +37 -13
- package/dist/services/HighlightCoordinator.d.ts +21 -21
- package/dist/services/HighlightCoordinator.js +431 -325
- package/dist/services/RangeSerializer.d.ts +10 -1
- package/dist/services/RangeSerializer.js +87 -14
- package/dist/services/TTSService.d.ts +117 -70
- package/dist/services/TTSService.js +668 -578
- package/dist/services/ToolCoordinator.d.ts +43 -60
- package/dist/services/ToolCoordinator.js +164 -206
- package/dist/services/ToolRegistry.d.ts +74 -57
- package/dist/services/ToolRegistry.js +104 -114
- package/dist/services/ToolkitCoordinator.d.ts +183 -189
- package/dist/services/ToolkitCoordinator.js +477 -431
- package/dist/services/audio-handoff.d.ts +6 -7
- package/dist/services/audio-handoff.js +20 -14
- package/dist/services/catalog-owner.d.ts +0 -1
- package/dist/services/framework-error-bus.d.ts +3 -6
- package/dist/services/framework-error-bus.js +3 -6
- package/dist/services/framework-error.d.ts +22 -7
- package/dist/services/framework-error.js +9 -7
- package/dist/services/interfaces.d.ts +158 -129
- package/dist/services/section-session-assignment.d.ts +21 -0
- package/dist/services/section-session-assignment.js +82 -0
- package/dist/services/spoken-audio-cards.d.ts +0 -2
- package/dist/services/spoken-audio-cards.js +0 -2
- package/dist/services/tool-config-defaults.d.ts +3 -3
- package/dist/services/tool-config-defaults.js +3 -3
- package/dist/services/tool-config-validation.d.ts +4 -5
- package/dist/services/tool-config-validation.js +36 -61
- package/dist/services/tool-context.d.ts +0 -8
- package/dist/services/tool-context.js +0 -12
- package/dist/services/tool-icons.d.ts +4 -3
- package/dist/services/tool-icons.js +4 -3
- package/dist/services/tool-instance-id.d.ts +0 -1
- package/dist/services/tool-instance-id.js +0 -6
- package/dist/services/tool-providers/TTSToolProvider.d.ts +18 -19
- package/dist/services/tool-providers/TTSToolProvider.js +37 -34
- package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
- package/dist/services/tool-providers/ToolProviderRegistry.d.ts +30 -26
- package/dist/services/tool-providers/ToolProviderRegistry.js +119 -93
- package/dist/services/tool-request.d.ts +27 -12
- package/dist/services/tool-request.js +24 -14
- package/dist/services/toolbar-decided-tools.d.ts +15 -0
- package/dist/services/toolbar-decided-tools.js +28 -0
- package/dist/services/tools-config-normalizer.d.ts +17 -22
- package/dist/services/tools-config-normalizer.js +2 -10
- package/dist/services/tts/browser-provider.d.ts +38 -2
- package/dist/services/tts/browser-provider.js +276 -140
- package/dist/services/tts/flat-tree.d.ts +71 -0
- package/dist/services/tts/flat-tree.js +269 -0
- package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
- package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
- package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
- package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
- package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
- package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +12 -4
- package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
- package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
- package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
- package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
- package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
- package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
- package/dist/services/tts/math-aware-text-processing.js +32 -18
- package/dist/services/tts/math-control-names.d.ts +21 -0
- package/dist/services/tts/math-control-names.js +142 -0
- package/dist/services/tts/math-speech.d.ts +11 -0
- package/dist/services/tts/math-speech.js +51 -18
- package/dist/services/tts/provider-options.d.ts +41 -0
- package/dist/services/tts/provider-options.js +1 -0
- package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
- package/dist/services/tts/ssml/spoken-text.js +29 -0
- package/dist/services/tts/start-failure.d.ts +11 -0
- package/dist/services/tts/start-failure.js +19 -0
- package/dist/services/tts/text-processing.d.ts +9 -0
- package/dist/services/tts/text-processing.js +46 -33
- package/dist/services/tts/text-segmentation.d.ts +7 -0
- package/dist/services/tts/text-segmentation.js +47 -0
- package/dist/services/tts-runtime-config.d.ts +25 -17
- package/dist/services/tts-runtime-config.js +16 -95
- package/dist/tools/content-capability-resolution.d.ts +7 -9
- package/dist/tools/content-capability-resolution.js +8 -45
- package/dist/tools/registration.d.ts +58 -0
- package/dist/tools/registration.js +54 -0
- package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
- package/dist/tools/tool-surface-host.d.ts +6 -10
- package/dist/tools/tool-surface-host.js +44 -42
- package/dist/tools/tool-tag-map.d.ts +5 -15
- package/dist/tools/tool-tag-map.js +6 -20
- package/dist/utils/structural-equality.d.ts +11 -0
- package/dist/utils/structural-equality.js +47 -0
- package/package.json +13 -29
- package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
- package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
- package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
- package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
- package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
- package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
- package/dist/context/runtime-context-consumer.d.ts +0 -13
- package/dist/context/runtime-context-consumer.js +0 -18
- package/dist/item-loader.d.ts +0 -44
- package/dist/item-loader.js +0 -61
- package/dist/policy/core/engine-context.d.ts +0 -29
- package/dist/policy/core/engine-context.js +0 -27
- package/dist/policy/core/policy-source-tag.d.ts +0 -31
- package/dist/policy/core/policy-source-tag.js +0 -28
- package/dist/policy/internal.d.ts +0 -25
- package/dist/policy/internal.js +0 -25
- package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
- package/dist/runtime/adapter/coordinator-bridge.js +0 -126
- package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
- package/dist/runtime/adapter/framework-error-bridge.js +0 -46
- package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
- package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
- package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
- package/dist/runtime/core/engine-stage-derivation.js +0 -29
- package/dist/runtime/engine-context.d.ts +0 -39
- package/dist/runtime/engine-context.js +0 -31
- package/dist/runtime/internal.d.ts +0 -43
- package/dist/runtime/internal.js +0 -45
- package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
- package/dist/runtime/section-runtime-engine-host-context.js +0 -57
- package/dist/runtime/stage-emit-gate.d.ts +0 -92
- package/dist/runtime/stage-emit-gate.js +0 -82
- package/dist/services/I18nService.d.ts +0 -47
- package/dist/services/I18nService.js +0 -84
- package/dist/services/ThemeProvider.d.ts +0 -95
- package/dist/services/ThemeProvider.js +0 -334
- package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
- package/dist/services/defaultPersonalNeedsProfile.js +0 -26
- package/dist/services/pnp-standard-features.d.ts +0 -238
- package/dist/services/pnp-standard-features.js +0 -338
- package/dist/tools/client.d.ts +0 -10
- package/dist/tools/client.js +0 -10
- package/dist/tools/internal.d.ts +0 -44
- package/dist/tools/internal.js +0 -39
|
@@ -16,18 +16,25 @@
|
|
|
16
16
|
* Part of PIE Assessment Toolkit.
|
|
17
17
|
*/
|
|
18
18
|
import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
|
|
19
|
-
import {
|
|
19
|
+
import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
|
|
20
|
+
import { HighlightType } from "./HighlightCoordinator.js";
|
|
20
21
|
import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
22
|
+
import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
|
|
23
|
+
import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
|
|
24
|
+
import { composedContains, composedParentElement, flatQuerySelectorAll, flatTextContent, flatTreeChildNodes, flatTreeParentElement, flatTreeTextNodes, rangeHoldsTextPosition, rangeIntersectsComposedNode, } from "./tts/flat-tree.js";
|
|
25
|
+
import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, rangeHoldsSpeakableElement, textInRange, } from "./tts/text-processing.js";
|
|
23
26
|
import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
|
|
24
27
|
import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
|
|
25
28
|
import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
|
|
26
29
|
import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
|
|
27
30
|
import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
|
|
28
|
-
import {
|
|
31
|
+
import { toTTSStartFailure } from "./tts/start-failure.js";
|
|
32
|
+
import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
|
|
29
33
|
import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
|
|
34
|
+
import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.js";
|
|
30
35
|
import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
|
|
36
|
+
// Debug lines need `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
37
|
+
const logger = createPieLogger("tts-service", isTtsDebugEnabled);
|
|
31
38
|
/**
|
|
32
39
|
* Playback state
|
|
33
40
|
*/
|
|
@@ -39,6 +46,21 @@ export var PlaybackState;
|
|
|
39
46
|
PlaybackState["PAUSED"] = "paused";
|
|
40
47
|
PlaybackState["ERROR"] = "error";
|
|
41
48
|
})(PlaybackState || (PlaybackState = {}));
|
|
49
|
+
const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
50
|
+
const hostLocalesOf = (options, previous = {}) => {
|
|
51
|
+
if (!options)
|
|
52
|
+
return previous;
|
|
53
|
+
return {
|
|
54
|
+
locale: "locale" in options ? optionalString(options.locale) : previous.locale,
|
|
55
|
+
textNormalization: options.textNormalization && "locale" in options.textNormalization
|
|
56
|
+
? optionalString(options.textNormalization.locale)
|
|
57
|
+
: previous.textNormalization,
|
|
58
|
+
segmenter: options.segmenter && "locale" in options.segmenter
|
|
59
|
+
? optionalString(options.segmenter.locale)
|
|
60
|
+
: previous.segmenter,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const isRange = (target) => "commonAncestorContainer" in target;
|
|
42
64
|
const sameRange = (left, right) => left === right ||
|
|
43
65
|
(left.startContainer === right.startContainer &&
|
|
44
66
|
left.startOffset === right.startOffset &&
|
|
@@ -79,11 +101,22 @@ export class TTSService {
|
|
|
79
101
|
highlightTargetResolverProvider = null;
|
|
80
102
|
catalogResolver = null;
|
|
81
103
|
state = PlaybackState.IDLE;
|
|
104
|
+
// The state a resume returns to: loading when the pause came before the
|
|
105
|
+
// audio started sounding.
|
|
106
|
+
resumeState = PlaybackState.PLAYING;
|
|
82
107
|
ttsConfig = {};
|
|
108
|
+
// The locales the host configured. A read naming a language sets all three
|
|
109
|
+
// to it; a read naming none restores these.
|
|
110
|
+
hostLocales = {};
|
|
111
|
+
// The locale of the read in progress, which segmentation reads.
|
|
112
|
+
readLocale = DEFAULT_CONTENT_LANGUAGE;
|
|
113
|
+
disposed = false;
|
|
114
|
+
readinessGate = null;
|
|
115
|
+
mathSpeechSource = null;
|
|
83
116
|
currentText = null;
|
|
84
117
|
currentContentElement = null;
|
|
85
118
|
normalizedToDOM = new Map();
|
|
86
|
-
listeners = new
|
|
119
|
+
listeners = new Set();
|
|
87
120
|
lastError = null;
|
|
88
121
|
speakRunId = 0;
|
|
89
122
|
currentBoundaryOffset = 0;
|
|
@@ -92,13 +125,18 @@ export class TTSService {
|
|
|
92
125
|
playbackChunks = [];
|
|
93
126
|
// The recording currently playing, if any. `cancel` settles the pending play
|
|
94
127
|
// promise as well as stopping the element, so stop/seek cannot wedge the
|
|
95
|
-
// chunk loop on a run that has already been superseded.
|
|
128
|
+
// chunk loop on a run that has already been superseded. `play` starts it, or
|
|
129
|
+
// starts it again after a pause.
|
|
96
130
|
activeRecordedAudio = null;
|
|
97
131
|
sentenceHighlightSegments = [];
|
|
98
132
|
activeSentenceStartOffset = null;
|
|
99
133
|
playbackStartDeferredRunId = null;
|
|
100
134
|
pendingPlaybackStartHighlights = [];
|
|
101
135
|
playbackStartBarrierCleanup = null;
|
|
136
|
+
// A pause between two parts of a run, in a structural gap or while the next
|
|
137
|
+
// part loads, holds the run here until resume.
|
|
138
|
+
pauseHold = null;
|
|
139
|
+
structuralPause = null;
|
|
102
140
|
playbackRateWriteQueue = Promise.resolve();
|
|
103
141
|
playbackRateRequestId = 0;
|
|
104
142
|
pendingPlaybackRateRequest = null;
|
|
@@ -110,32 +148,7 @@ export class TTSService {
|
|
|
110
148
|
// Caches DOM-free spoken text keyed by SRE settings and MathML source across
|
|
111
149
|
// utterances within this service instance.
|
|
112
150
|
generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
|
|
113
|
-
// Verbose alignment/highlight tracing is opt-in: it is expensive (large
|
|
114
|
-
// objects, a full text diff per utterance) and noisy in production. Enable
|
|
115
|
-
// with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
116
|
-
static debugEnabled = (() => {
|
|
117
|
-
try {
|
|
118
|
-
if (typeof process !== "undefined" &&
|
|
119
|
-
process.env?.PIE_TTS_DEBUG === "1") {
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// `process` is not defined in the browser; fall through to the global.
|
|
125
|
-
}
|
|
126
|
-
return (globalThis.__PIE_TTS_DEBUG__ === true);
|
|
127
|
-
})();
|
|
128
151
|
constructor() { }
|
|
129
|
-
debugLog(message, detail) {
|
|
130
|
-
if (!TTSService.debugEnabled)
|
|
131
|
-
return;
|
|
132
|
-
if (detail === undefined) {
|
|
133
|
-
console.debug(message);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
console.debug(message, detail);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
152
|
clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
|
|
140
153
|
cleanup?.();
|
|
141
154
|
if (this.playbackStartBarrierCleanup === cleanup) {
|
|
@@ -147,7 +160,12 @@ export class TTSService {
|
|
|
147
160
|
runId !== this.speakRunId) {
|
|
148
161
|
return;
|
|
149
162
|
}
|
|
150
|
-
this.
|
|
163
|
+
if (this.state === PlaybackState.PAUSED) {
|
|
164
|
+
// The learner paused before this part began: it holds where it started.
|
|
165
|
+
this.activeRecordedAudio?.element.pause();
|
|
166
|
+
this.provider?.pause();
|
|
167
|
+
}
|
|
168
|
+
this.markPlaying();
|
|
151
169
|
const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
|
|
152
170
|
for (const applyHighlight of pendingHighlights) {
|
|
153
171
|
applyHighlight();
|
|
@@ -191,6 +209,82 @@ export class TTSService {
|
|
|
191
209
|
}
|
|
192
210
|
callback();
|
|
193
211
|
}
|
|
212
|
+
/** Resolves when run `runId` may start its next part: at once unless paused. */
|
|
213
|
+
async waitWhilePaused(runId) {
|
|
214
|
+
while (this.state === PlaybackState.PAUSED && runId === this.speakRunId) {
|
|
215
|
+
if (!this.pauseHold) {
|
|
216
|
+
let release = () => { };
|
|
217
|
+
const promise = new Promise((resolve) => {
|
|
218
|
+
release = resolve;
|
|
219
|
+
});
|
|
220
|
+
this.pauseHold = { promise, release };
|
|
221
|
+
}
|
|
222
|
+
await this.pauseHold.promise;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
releasePauseHold() {
|
|
226
|
+
const hold = this.pauseHold;
|
|
227
|
+
this.pauseHold = null;
|
|
228
|
+
hold?.release();
|
|
229
|
+
}
|
|
230
|
+
waitStructuralPause(pauseMs) {
|
|
231
|
+
this.cancelStructuralPause();
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const finish = () => {
|
|
234
|
+
if (this.structuralPause?.finish === finish) {
|
|
235
|
+
clearTimeout(this.structuralPause.timer);
|
|
236
|
+
this.structuralPause = null;
|
|
237
|
+
}
|
|
238
|
+
resolve();
|
|
239
|
+
};
|
|
240
|
+
this.structuralPause = { timer: setTimeout(finish, pauseMs), finish };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
cancelStructuralPause() {
|
|
244
|
+
this.structuralPause?.finish();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Release what a superseded run still holds: its start barrier, a recorded
|
|
248
|
+
* clip still playing, a structural gap and a pause hold. Its loops then see
|
|
249
|
+
* the new run id and return.
|
|
250
|
+
*/
|
|
251
|
+
abandonRun() {
|
|
252
|
+
this.clearPlaybackStartBarrier();
|
|
253
|
+
this.cancelRecordedAudio();
|
|
254
|
+
this.cancelStructuralPause();
|
|
255
|
+
this.releasePauseHold();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Start a run and enter loading. From here a pause or stop applies to the
|
|
259
|
+
* run, whatever it is still waiting for: the provider, its content or the
|
|
260
|
+
* provider's audio.
|
|
261
|
+
*/
|
|
262
|
+
beginRun() {
|
|
263
|
+
// A recorded clip that starts the new run would otherwise play over the
|
|
264
|
+
// previous run's synthesis, which only the provider's next speak stops.
|
|
265
|
+
const supersedesPlayback = this.state === PlaybackState.LOADING ||
|
|
266
|
+
this.state === PlaybackState.PLAYING ||
|
|
267
|
+
this.state === PlaybackState.PAUSED;
|
|
268
|
+
const runId = ++this.speakRunId;
|
|
269
|
+
this.abandonRun();
|
|
270
|
+
if (supersedesPlayback)
|
|
271
|
+
this.provider?.stop();
|
|
272
|
+
this.setState(PlaybackState.LOADING);
|
|
273
|
+
return runId;
|
|
274
|
+
}
|
|
275
|
+
/** The run's audio is sounding; under a pause, it sounds again on resume. */
|
|
276
|
+
markPlaying() {
|
|
277
|
+
if (this.state === PlaybackState.PAUSED) {
|
|
278
|
+
this.resumeState = PlaybackState.PLAYING;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.setState(PlaybackState.PLAYING);
|
|
282
|
+
}
|
|
283
|
+
assertNotDisposed() {
|
|
284
|
+
if (this.disposed) {
|
|
285
|
+
throw toTTSStartFailure(new Error("TTS service disposed"));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
194
288
|
async emitTelemetry(eventName, payload) {
|
|
195
289
|
try {
|
|
196
290
|
await this.telemetryReporter?.(eventName, payload);
|
|
@@ -206,72 +300,39 @@ export class TTSService {
|
|
|
206
300
|
* @param config Provider configuration
|
|
207
301
|
*/
|
|
208
302
|
async initialize(provider, config = {}) {
|
|
303
|
+
if (this.disposed)
|
|
304
|
+
throw new Error("TTS service disposed");
|
|
209
305
|
this.currentProvider = provider;
|
|
210
306
|
this.ttsConfig = { ...config };
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
: null;
|
|
218
|
-
// Initialize provider and get implementation. Browser fallback is only
|
|
219
|
-
// allowed here, before the configured provider has successfully become
|
|
220
|
-
// the active runtime. Per-call playback/synthesis errors must surface
|
|
221
|
-
// instead of silently replacing a user-selected provider and voice.
|
|
222
|
-
try {
|
|
223
|
-
this.provider = await provider.initialize(config);
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
if (provider.providerId.toLowerCase() === "browser") {
|
|
227
|
-
throw error;
|
|
228
|
-
}
|
|
229
|
-
if (!this.isBrowserSpeechFallbackAvailable()) {
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
|
-
const switched = await this.switchProviderToBrowser(error, {
|
|
233
|
-
operation: "tts-initialize",
|
|
234
|
-
});
|
|
235
|
-
if (!switched) {
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
307
|
+
this.hostLocales = hostLocalesOf(config.providerOptions);
|
|
308
|
+
const telemetry = this.providerOptions().__pieTelemetry;
|
|
309
|
+
this.telemetryReporter = typeof telemetry === "function" ? telemetry : null;
|
|
310
|
+
// A provider that fails to start throws to the caller, which owns any
|
|
311
|
+
// fallback. Playback and synthesis errors surface the same way.
|
|
312
|
+
this.provider = await provider.initialize(config);
|
|
239
313
|
}
|
|
240
314
|
/**
|
|
241
|
-
* Update
|
|
242
|
-
*
|
|
243
|
-
* This allows changing rate, pitch, and voice on the fly.
|
|
244
|
-
* Note: Some providers may require reinitialization for voice changes.
|
|
245
|
-
*
|
|
246
|
-
* @param settings Partial settings to update (rate, pitch, voice)
|
|
315
|
+
* Update rate, pitch, voice or provider options without reinitializing.
|
|
316
|
+
* `providerOptions` merge over the ones already configured.
|
|
247
317
|
*/
|
|
248
318
|
async updateSettings(settings) {
|
|
249
319
|
if (!this.provider) {
|
|
250
320
|
throw new Error("TTSService not initialized. Call initialize() first.");
|
|
251
321
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
await provider.updateSettings(mergedSettings);
|
|
267
|
-
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
// Fallback: Reinitialize with merged config
|
|
271
|
-
// This requires storing the original config, which we don't have
|
|
272
|
-
// So for now, just log a warning
|
|
273
|
-
console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
|
|
274
|
-
}
|
|
322
|
+
const mergedSettings = {
|
|
323
|
+
...settings,
|
|
324
|
+
...(settings.providerOptions
|
|
325
|
+
? {
|
|
326
|
+
providerOptions: {
|
|
327
|
+
...this.providerOptions(),
|
|
328
|
+
...settings.providerOptions,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
: {}),
|
|
332
|
+
};
|
|
333
|
+
await this.provider.updateSettings(mergedSettings);
|
|
334
|
+
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
335
|
+
this.hostLocales = hostLocalesOf(settings.providerOptions, this.hostLocales);
|
|
275
336
|
}
|
|
276
337
|
/**
|
|
277
338
|
* Set highlight coordinator for word highlighting
|
|
@@ -279,6 +340,29 @@ export class TTSService {
|
|
|
279
340
|
setHighlightCoordinator(coordinator) {
|
|
280
341
|
this.highlightCoordinator = coordinator;
|
|
281
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Starts the service when a caller speaks before it is initialized; a gate
|
|
345
|
+
* that rejects fails the speak with its error. The coordinator installs one
|
|
346
|
+
* that runs its own text-to-speech start.
|
|
347
|
+
*/
|
|
348
|
+
setReadinessGate(gate) {
|
|
349
|
+
this.readinessGate = gate;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Where the configured math speech options come from before, and apart from,
|
|
353
|
+
* initialization, so math names do not depend on when speech started.
|
|
354
|
+
*/
|
|
355
|
+
setMathSpeechSource(source) {
|
|
356
|
+
this.mathSpeechSource = source;
|
|
357
|
+
}
|
|
358
|
+
/** The start a speak must wait for, or null when it can speak now without yielding. */
|
|
359
|
+
pendingReadiness() {
|
|
360
|
+
if (this.provider || !this.readinessGate)
|
|
361
|
+
return null;
|
|
362
|
+
return this.readinessGate().catch((error) => {
|
|
363
|
+
throw toTTSStartFailure(error);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
282
366
|
/**
|
|
283
367
|
* Set a late-bound provider for optional host TTS highlight target remapping.
|
|
284
368
|
*
|
|
@@ -313,31 +397,6 @@ export class TTSService {
|
|
|
313
397
|
setCatalogResolver(resolver) {
|
|
314
398
|
this.catalogResolver = resolver;
|
|
315
399
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Whether a catalog holds spoken content this service could actually speak.
|
|
318
|
-
*
|
|
319
|
-
* Exists because `data-catalog-idref` is one attribute with several readers:
|
|
320
|
-
* a node can be docked to a catalog that carries only a signing card, and the
|
|
321
|
-
* nearest docked ancestor of a selection is therefore not necessarily the one
|
|
322
|
-
* holding its authored SSML. A caller resolving a selection climbs ancestors
|
|
323
|
-
* and asks this before settling on an id — see the TTS tool's
|
|
324
|
-
* `findSpokenCatalogId`. Without it, a signing card docked on an inner node
|
|
325
|
-
* silently shadows authored speech on an outer one.
|
|
326
|
-
*/
|
|
327
|
-
hasSpokenAlternate(catalogId, language = "en-US") {
|
|
328
|
-
if (!catalogId || !this.catalogResolver)
|
|
329
|
-
return false;
|
|
330
|
-
const resolved = this.catalogResolver.getAlternative(catalogId, {
|
|
331
|
-
type: "spoken",
|
|
332
|
-
language,
|
|
333
|
-
useFallback: true,
|
|
334
|
-
// The string form specifically. A node may also carry a recording of the
|
|
335
|
-
// same script; that one is not what this reports on, and card order must
|
|
336
|
-
// not decide the answer.
|
|
337
|
-
form: "content",
|
|
338
|
-
});
|
|
339
|
-
return resolved?.content !== undefined;
|
|
340
|
-
}
|
|
341
400
|
getHighlightResolverRuntime() {
|
|
342
401
|
let provided = null;
|
|
343
402
|
try {
|
|
@@ -364,12 +423,12 @@ export class TTSService {
|
|
|
364
423
|
return null;
|
|
365
424
|
return node.nodeType === Node.ELEMENT_NODE
|
|
366
425
|
? node
|
|
367
|
-
: node
|
|
426
|
+
: composedParentElement(node);
|
|
368
427
|
}
|
|
369
428
|
isElementWithinScope(element, scope) {
|
|
370
429
|
if (!scope)
|
|
371
430
|
return true;
|
|
372
|
-
return
|
|
431
|
+
return composedContains(scope, element);
|
|
373
432
|
}
|
|
374
433
|
isRangeWithinScope(range, scope) {
|
|
375
434
|
const startElement = this.getNodeElement(range.startContainer);
|
|
@@ -444,28 +503,21 @@ export class TTSService {
|
|
|
444
503
|
return null;
|
|
445
504
|
}
|
|
446
505
|
}
|
|
447
|
-
|
|
448
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Paint the spoken word, each range offered to the host's resolver. A word
|
|
508
|
+
* split across inline elements is one range over its text nodes; across
|
|
509
|
+
* shadow trees, one range per tree.
|
|
510
|
+
*/
|
|
511
|
+
paintTTSWord(nativeRanges) {
|
|
512
|
+
if (!this.highlightCoordinator || nativeRanges.length === 0)
|
|
449
513
|
return;
|
|
450
|
-
|
|
451
|
-
if (range.startContainer === range.endContainer &&
|
|
452
|
-
range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
453
|
-
this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
catch {
|
|
458
|
-
// Fall through to generic range painting for lightweight test doubles.
|
|
459
|
-
}
|
|
460
|
-
this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
|
|
514
|
+
this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
|
|
461
515
|
}
|
|
462
516
|
paintTTSSentenceRanges(nativeRanges) {
|
|
463
517
|
if (!this.highlightCoordinator)
|
|
464
518
|
return;
|
|
465
519
|
const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
|
|
466
|
-
if (elements.length > 0 &&
|
|
467
|
-
ranges.length === 0 &&
|
|
468
|
-
this.highlightCoordinator.highlightTTSSentenceElements) {
|
|
520
|
+
if (elements.length > 0 && ranges.length === 0) {
|
|
469
521
|
this.highlightCoordinator.highlightTTSSentenceElements(elements);
|
|
470
522
|
return;
|
|
471
523
|
}
|
|
@@ -483,36 +535,119 @@ export class TTSService {
|
|
|
483
535
|
getCapabilities() {
|
|
484
536
|
return this.currentProvider?.getCapabilities() || null;
|
|
485
537
|
}
|
|
538
|
+
providerOptions() {
|
|
539
|
+
const options = this.ttsConfig.providerOptions;
|
|
540
|
+
return options && typeof options === "object" ? options : {};
|
|
541
|
+
}
|
|
542
|
+
/** The longest text the provider accepts in one speak, when it declares one. */
|
|
543
|
+
maxTextLength() {
|
|
544
|
+
const limit = this.currentProvider?.getCapabilities?.().maxTextLength;
|
|
545
|
+
return typeof limit === "number" && limit > 0 ? limit : undefined;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Speak `text`, in pieces when it exceeds the provider's `maxTextLength`. Word
|
|
549
|
+
* boundaries keep indexing the whole text. An SSML document cannot be cut, so
|
|
550
|
+
* one over the limit fails and the chunk's plain fallback reads instead.
|
|
551
|
+
*/
|
|
552
|
+
async speakWithinLimit(text, runId) {
|
|
553
|
+
const provider = this.provider;
|
|
554
|
+
if (!provider)
|
|
555
|
+
return;
|
|
556
|
+
const limit = this.maxTextLength();
|
|
557
|
+
if (!limit || text.length <= limit) {
|
|
558
|
+
await this.waitWhilePaused(runId);
|
|
559
|
+
if (runId !== this.speakRunId || this.provider !== provider)
|
|
560
|
+
return;
|
|
561
|
+
await provider.speak(text);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (isSsmlDocument(text)) {
|
|
565
|
+
throw new Error(`[tts] SSML of ${text.length} characters exceeds the provider's limit of ${limit}`);
|
|
566
|
+
}
|
|
567
|
+
const onWordBoundary = provider.onWordBoundary;
|
|
568
|
+
try {
|
|
569
|
+
for (const piece of splitTextToLength(text, limit, {
|
|
570
|
+
locale: this.readLocale,
|
|
571
|
+
})) {
|
|
572
|
+
await this.waitWhilePaused(runId);
|
|
573
|
+
if (runId !== this.speakRunId || this.provider !== provider)
|
|
574
|
+
return;
|
|
575
|
+
provider.onWordBoundary = onWordBoundary
|
|
576
|
+
? (word, position, length) => onWordBoundary(word, position + piece.offset, length)
|
|
577
|
+
: undefined;
|
|
578
|
+
await provider.speak(piece.text);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
if (this.provider === provider && runId === this.speakRunId) {
|
|
583
|
+
provider.onWordBoundary = onWordBoundary;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Segments no longer than the provider's `maxTextLength`, offsets kept. */
|
|
588
|
+
segmentsWithinLimit(segments) {
|
|
589
|
+
const limit = this.maxTextLength();
|
|
590
|
+
if (!limit)
|
|
591
|
+
return segments;
|
|
592
|
+
return segments.flatMap((segment) => {
|
|
593
|
+
if (segment.text.length <= limit)
|
|
594
|
+
return [segment];
|
|
595
|
+
const pieces = splitTextToLength(segment.text, limit, {
|
|
596
|
+
locale: this.readLocale,
|
|
597
|
+
});
|
|
598
|
+
return pieces.map((piece, index) => ({
|
|
599
|
+
text: piece.text,
|
|
600
|
+
startOffset: segment.startOffset + piece.offset,
|
|
601
|
+
pauseMsAfter: index === pieces.length - 1 ? segment.pauseMsAfter : undefined,
|
|
602
|
+
}));
|
|
603
|
+
});
|
|
604
|
+
}
|
|
486
605
|
/**
|
|
487
606
|
* Build a character-by-character map from normalized text positions to DOM positions
|
|
488
607
|
* This handles the complexity of whitespace normalization
|
|
489
608
|
*/
|
|
490
|
-
getTextProcessingOptions(
|
|
491
|
-
const
|
|
492
|
-
const textNormalization = (providerOptions.textNormalization ||
|
|
493
|
-
{});
|
|
494
|
-
const mode = textNormalization.boundarySpacingMode;
|
|
609
|
+
getTextProcessingOptions(locale) {
|
|
610
|
+
const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
|
|
495
611
|
const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
|
|
496
612
|
? mode
|
|
497
613
|
: undefined;
|
|
498
|
-
const locale = language ||
|
|
499
|
-
(typeof textNormalization.locale === "string"
|
|
500
|
-
? textNormalization.locale
|
|
501
|
-
: undefined);
|
|
502
614
|
return { locale, boundarySpacingMode };
|
|
503
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* The language a read of `node` is in. Its content language is the nearest
|
|
618
|
+
* `lang` between it and its shell, else the language the speak names, else a
|
|
619
|
+
* pinned `lang_id`. Its locale is that content language, else the host's
|
|
620
|
+
* configured locale, else en-US.
|
|
621
|
+
*/
|
|
622
|
+
resolveReadLanguage(node, named) {
|
|
623
|
+
const contentLanguage = findContentLanguage(node, { contentLanguage: named }) ??
|
|
624
|
+
optionalString(this.providerOptions().lang_id);
|
|
625
|
+
return {
|
|
626
|
+
contentLanguage,
|
|
627
|
+
locale: contentLanguage ??
|
|
628
|
+
this.hostLocales.locale ??
|
|
629
|
+
this.hostLocales.textNormalization ??
|
|
630
|
+
DEFAULT_CONTENT_LANGUAGE,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The host's SRE options for math speech: from the installed source, else as
|
|
635
|
+
* this service was initialized with.
|
|
636
|
+
*/
|
|
504
637
|
getMathSpeechOptions() {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
638
|
+
if (this.mathSpeechSource) {
|
|
639
|
+
return normalizeSREMathSpeechOptions(this.mathSpeechSource());
|
|
640
|
+
}
|
|
641
|
+
return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
|
|
508
642
|
}
|
|
509
643
|
buildPositionMap(element, spokenText, language) {
|
|
510
644
|
this.normalizedToDOM.clear();
|
|
511
645
|
const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
|
|
512
646
|
this.normalizedToDOM = map;
|
|
513
|
-
|
|
647
|
+
// The comparison below diffs the whole text, so it runs only when traced.
|
|
648
|
+
if (!isTtsDebugEnabled())
|
|
514
649
|
return;
|
|
515
|
-
|
|
650
|
+
logger.debug("Text comparison:", {
|
|
516
651
|
spokenLength: spokenText.length,
|
|
517
652
|
normalizedDomLength: normalizedDomText.length,
|
|
518
653
|
match: spokenText === normalizedDomText,
|
|
@@ -535,7 +670,7 @@ export class TTSService {
|
|
|
535
670
|
return null;
|
|
536
671
|
})(),
|
|
537
672
|
});
|
|
538
|
-
|
|
673
|
+
logger.debug("Position map built:", {
|
|
539
674
|
entries: this.normalizedToDOM.size,
|
|
540
675
|
spokenTextLength: spokenText.length,
|
|
541
676
|
normalizedDomLength: normalizedDomText.length,
|
|
@@ -551,9 +686,8 @@ export class TTSService {
|
|
|
551
686
|
});
|
|
552
687
|
}
|
|
553
688
|
getStructuralPauseProfile() {
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
const customUnits = (custom.units || {});
|
|
689
|
+
const custom = this.providerOptions().structuralPauses || {};
|
|
690
|
+
const customUnits = custom.units || {};
|
|
557
691
|
const toNumber = (value, fallback) => {
|
|
558
692
|
if (typeof value === "number" && Number.isFinite(value))
|
|
559
693
|
return value;
|
|
@@ -581,17 +715,15 @@ export class TTSService {
|
|
|
581
715
|
return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
|
|
582
716
|
}
|
|
583
717
|
resolveHighlightMode() {
|
|
584
|
-
const
|
|
585
|
-
const configuredMode = providerOptions.highlightMode;
|
|
718
|
+
const configuredMode = this.providerOptions().highlightMode;
|
|
586
719
|
if (configuredMode === "word" || configuredMode === "sentence") {
|
|
587
720
|
return configuredMode;
|
|
588
721
|
}
|
|
589
|
-
const
|
|
590
|
-
if (
|
|
591
|
-
return
|
|
722
|
+
const capabilities = this.currentProvider?.getCapabilities();
|
|
723
|
+
if (capabilities?.defaultHighlightMode) {
|
|
724
|
+
return capabilities.defaultHighlightMode;
|
|
592
725
|
}
|
|
593
|
-
|
|
594
|
-
return supportsWordBoundary ? "word" : "sentence";
|
|
726
|
+
return capabilities?.supportsWordBoundary ? "word" : "sentence";
|
|
595
727
|
}
|
|
596
728
|
hasExplicitBreakSemantics(text) {
|
|
597
729
|
if (!text.includes("<"))
|
|
@@ -631,7 +763,7 @@ export class TTSService {
|
|
|
631
763
|
return "minor";
|
|
632
764
|
}
|
|
633
765
|
getBoundaryAnchor(textNode, root) {
|
|
634
|
-
let current = textNode
|
|
766
|
+
let current = flatTreeParentElement(textNode);
|
|
635
767
|
let best = null;
|
|
636
768
|
while (current && current !== root) {
|
|
637
769
|
if (this.isElementHidden(current))
|
|
@@ -664,7 +796,7 @@ export class TTSService {
|
|
|
664
796
|
}
|
|
665
797
|
}
|
|
666
798
|
}
|
|
667
|
-
current = current
|
|
799
|
+
current = flatTreeParentElement(current);
|
|
668
800
|
}
|
|
669
801
|
if (!best)
|
|
670
802
|
return null;
|
|
@@ -679,8 +811,8 @@ export class TTSService {
|
|
|
679
811
|
* boundaries are collected over the whole element, and a selection speaks only
|
|
680
812
|
* part of it.
|
|
681
813
|
*/
|
|
682
|
-
createSpeechPlan(contentElement, normalizedText, textOffset
|
|
683
|
-
const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText);
|
|
814
|
+
createSpeechPlan(contentElement, normalizedText, textOffset, language) {
|
|
815
|
+
const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText, language);
|
|
684
816
|
if (textOffset === 0) {
|
|
685
817
|
return this.createSpeechPlanSegments(normalizedText, boundaries);
|
|
686
818
|
}
|
|
@@ -690,16 +822,15 @@ export class TTSService {
|
|
|
690
822
|
}
|
|
691
823
|
return this.createSpeechPlanSegments(normalizedText, shifted);
|
|
692
824
|
}
|
|
693
|
-
collectSpeechPlanBoundaries(contentElement, normalizedText) {
|
|
825
|
+
collectSpeechPlanBoundaries(contentElement, normalizedText, language) {
|
|
694
826
|
const boundaries = new Map();
|
|
695
|
-
|
|
827
|
+
// The same locale the position map used, so the boundary offsets index the
|
|
828
|
+
// text the map built.
|
|
829
|
+
const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions(language));
|
|
696
830
|
const nodeStartOffsets = this.createNodeStartOffsets(map);
|
|
697
|
-
const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
|
|
698
|
-
let currentNode = walker.nextNode();
|
|
699
831
|
let previousBoundaryAnchor = null;
|
|
700
|
-
|
|
701
|
-
const
|
|
702
|
-
const parent = textNode.parentElement;
|
|
832
|
+
for (const textNode of flatTreeTextNodes(contentElement)) {
|
|
833
|
+
const parent = flatTreeParentElement(textNode);
|
|
703
834
|
if (parent &&
|
|
704
835
|
!this.isElementHidden(parent) &&
|
|
705
836
|
!isNodeSuppressedForTTS(textNode)) {
|
|
@@ -713,7 +844,6 @@ export class TTSService {
|
|
|
713
844
|
previousBoundaryAnchor = boundary.anchor;
|
|
714
845
|
}
|
|
715
846
|
}
|
|
716
|
-
currentNode = walker.nextNode();
|
|
717
847
|
}
|
|
718
848
|
return boundaries;
|
|
719
849
|
}
|
|
@@ -761,9 +891,7 @@ export class TTSService {
|
|
|
761
891
|
return segments.filter((segment) => segment.text.trim().length > 0);
|
|
762
892
|
}
|
|
763
893
|
segmentSentences(text) {
|
|
764
|
-
|
|
765
|
-
?.locale || undefined;
|
|
766
|
-
return segmentTextToSentences(text, { locale });
|
|
894
|
+
return segmentTextToSentences(text, { locale: this.readLocale });
|
|
767
895
|
}
|
|
768
896
|
createSeekSegmentsFromText(text) {
|
|
769
897
|
return this.segmentSentences(text)
|
|
@@ -863,6 +991,10 @@ export class TTSService {
|
|
|
863
991
|
const providerWithPlan = this.provider;
|
|
864
992
|
if (!shouldTrackSentenceProgress &&
|
|
865
993
|
typeof providerWithPlan.speakSegments === "function") {
|
|
994
|
+
await this.waitWhilePaused(runId);
|
|
995
|
+
if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
866
998
|
this.currentBoundaryOffset = 0;
|
|
867
999
|
const originalOnWordBoundary = this.provider.onWordBoundary;
|
|
868
1000
|
this.provider.onWordBoundary = (word, position, length) => {
|
|
@@ -872,7 +1004,7 @@ export class TTSService {
|
|
|
872
1004
|
}
|
|
873
1005
|
};
|
|
874
1006
|
try {
|
|
875
|
-
await providerWithPlan.speakSegments(segments);
|
|
1007
|
+
await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
|
|
876
1008
|
}
|
|
877
1009
|
finally {
|
|
878
1010
|
if (runId === this.speakRunId && this.provider === providerWithPlan) {
|
|
@@ -886,6 +1018,7 @@ export class TTSService {
|
|
|
886
1018
|
wordBoundaryOffset: this.activeWordBoundaryOffset,
|
|
887
1019
|
});
|
|
888
1020
|
for (const segment of segments) {
|
|
1021
|
+
await this.waitWhilePaused(runId);
|
|
889
1022
|
if (runId !== this.speakRunId)
|
|
890
1023
|
return;
|
|
891
1024
|
this.currentBoundaryOffset = segment.startOffset;
|
|
@@ -894,10 +1027,10 @@ export class TTSService {
|
|
|
894
1027
|
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
895
1028
|
});
|
|
896
1029
|
}
|
|
897
|
-
await this.
|
|
1030
|
+
await this.speakWithinLimit(segment.text, runId);
|
|
898
1031
|
const pauseMs = segment.pauseMsAfter ?? 0;
|
|
899
|
-
if (pauseMs > 0) {
|
|
900
|
-
await
|
|
1032
|
+
if (pauseMs > 0 && runId === this.speakRunId) {
|
|
1033
|
+
await this.waitStructuralPause(pauseMs);
|
|
901
1034
|
}
|
|
902
1035
|
}
|
|
903
1036
|
}
|
|
@@ -917,10 +1050,11 @@ export class TTSService {
|
|
|
917
1050
|
const end = this.normalizedToDOM.get(mapStart + length - 1);
|
|
918
1051
|
if (!start || !end)
|
|
919
1052
|
return;
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1053
|
+
// One range per tree: a sentence running into a shadow root paints in both.
|
|
1054
|
+
const ranges = createRangesFromVisibleMap(this.normalizedToDOM, mapStart, mapStart + length);
|
|
1055
|
+
if (ranges.length === 0)
|
|
1056
|
+
return;
|
|
1057
|
+
this.paintTTSSentenceRanges(ranges);
|
|
924
1058
|
this.activeSentenceStartOffset = startOffset;
|
|
925
1059
|
}
|
|
926
1060
|
getSegmentIndexForOffset(segments, offset) {
|
|
@@ -952,66 +1086,145 @@ export class TTSService {
|
|
|
952
1086
|
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
953
1087
|
}
|
|
954
1088
|
/**
|
|
955
|
-
*
|
|
1089
|
+
* Read `target` aloud: a range reads the text it selects, an element its
|
|
1090
|
+
* content. A node with a spoken card that the target holds whole reads its
|
|
1091
|
+
* card, `catalogId` names the card of the content root, and math reads as
|
|
1092
|
+
* math speech.
|
|
956
1093
|
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
1094
|
+
* Content marked not-to-be-spoken is never read, a named card included. When
|
|
1095
|
+
* the target holds nothing speakable, nothing is spoken and playback already
|
|
1096
|
+
* running continues.
|
|
960
1097
|
*/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1098
|
+
async speak(target, options = {}) {
|
|
1099
|
+
this.assertNotDisposed();
|
|
1100
|
+
const pendingReadiness = this.pendingReadiness();
|
|
1101
|
+
// A speak waiting on its provider is already the current run, so a pause
|
|
1102
|
+
// or stop issued meanwhile applies to it.
|
|
1103
|
+
const waitingRunId = pendingReadiness ? this.beginRun() : null;
|
|
1104
|
+
if (pendingReadiness) {
|
|
1105
|
+
try {
|
|
1106
|
+
await pendingReadiness;
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
if (waitingRunId === this.speakRunId)
|
|
1110
|
+
this.setState(PlaybackState.IDLE);
|
|
1111
|
+
throw error;
|
|
1112
|
+
}
|
|
1113
|
+
this.assertNotDisposed();
|
|
1114
|
+
if (waitingRunId !== this.speakRunId)
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (!this.provider) {
|
|
1118
|
+
if (waitingRunId !== null)
|
|
1119
|
+
this.setState(PlaybackState.IDLE);
|
|
1120
|
+
throw toTTSStartFailure(new Error("TTS service not initialized"));
|
|
1121
|
+
}
|
|
1122
|
+
const language = this.resolveReadLanguage(isRange(target) ? target.startContainer : target, options.language);
|
|
1123
|
+
const content = isRange(target)
|
|
1124
|
+
? this.resolveRangeTarget(target, options, language)
|
|
1125
|
+
: this.resolveElementTarget(target, options, language);
|
|
1126
|
+
if (!content) {
|
|
1127
|
+
if (waitingRunId !== null)
|
|
1128
|
+
this.setState(PlaybackState.IDLE);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
await this.speakContent(content.text, content.options, waitingRunId ?? this.beginRun());
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Suppression is read from the live ancestors of what is read, because the
|
|
1135
|
+
* element carrying it may sit above the content root.
|
|
1136
|
+
*/
|
|
1137
|
+
isSuppressedTarget(node) {
|
|
1138
|
+
if (!isNodeSuppressedForTTS(node))
|
|
1139
|
+
return false;
|
|
1140
|
+
console.warn("[tts] the content to read lies inside content marked not-to-be-spoken; nothing was spoken.");
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
resolveElementTarget(root, options, language) {
|
|
1144
|
+
if (this.isSuppressedTarget(root))
|
|
1145
|
+
return null;
|
|
1146
|
+
// Math-aware, so an element holding only an equation still reads.
|
|
1147
|
+
const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
|
|
1148
|
+
if (!text) {
|
|
1149
|
+
console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
|
|
974
1150
|
return null;
|
|
975
1151
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
}
|
|
1152
|
+
return {
|
|
1153
|
+
text,
|
|
1154
|
+
options: {
|
|
1155
|
+
catalogId: options.catalogId,
|
|
1156
|
+
catalogContext: options.catalogContext,
|
|
1157
|
+
language: language.locale,
|
|
1158
|
+
contentLanguage: language.contentLanguage,
|
|
1159
|
+
contentElement: root,
|
|
1160
|
+
},
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
resolveRangeTarget(range, options, language) {
|
|
1164
|
+
if (this.isSuppressedTarget(range.commonAncestorContainer))
|
|
1165
|
+
return null;
|
|
1166
|
+
// Highlighting stays scoped to the content root when one is named, else to
|
|
1167
|
+
// the selection's nearest element.
|
|
1168
|
+
const ancestor = range.commonAncestorContainer;
|
|
1169
|
+
const root = options.contentRoot ||
|
|
1170
|
+
(ancestor.nodeType === Node.ELEMENT_NODE
|
|
1171
|
+
? ancestor
|
|
1172
|
+
: composedParentElement(ancestor));
|
|
1173
|
+
if (!root)
|
|
1174
|
+
return null;
|
|
1175
|
+
const selected = collectRangeTextForSpeech(range, root);
|
|
1176
|
+
const selectedText = selected.text.trim();
|
|
1177
|
+
if (!selectedText) {
|
|
1178
|
+
if (selected.filtered) {
|
|
1179
|
+
console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
|
|
1180
|
+
}
|
|
985
1181
|
return null;
|
|
986
1182
|
}
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1183
|
+
// The spoken text and its offset come from the root's visible-text map, which
|
|
1184
|
+
// the highlight position map and the structural speech plan index too. Joined
|
|
1185
|
+
// from raw text nodes instead, a selection loses the space the map inserts
|
|
1186
|
+
// between blocks, and every offset after that boundary drifts.
|
|
1187
|
+
const mapped = this.selectMappedRangeText(range, root, language.locale);
|
|
1188
|
+
let text = selectedText;
|
|
1189
|
+
let offset = mapped?.offset ?? 0;
|
|
1190
|
+
if (mapped) {
|
|
1191
|
+
text = mapped.text;
|
|
1192
|
+
}
|
|
1193
|
+
else {
|
|
1194
|
+
// Filtered the same way as the speech itself: the offset indexes into the
|
|
1195
|
+
// highlight text, which comes from the exclusion-aware collectors.
|
|
1196
|
+
const beforeRange = document.createRange();
|
|
1197
|
+
beforeRange.selectNodeContents(root);
|
|
1198
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
1199
|
+
const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
|
|
1200
|
+
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
1201
|
+
offset =
|
|
1202
|
+
normalizedTextBeforeRange.length +
|
|
1203
|
+
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
996
1204
|
}
|
|
1205
|
+
logger.debug("selection offset calculation:", {
|
|
1206
|
+
selectedText: text,
|
|
1207
|
+
mapped: !!mapped,
|
|
1208
|
+
offset,
|
|
1209
|
+
rootTag: root.tagName,
|
|
1210
|
+
});
|
|
997
1211
|
return {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1212
|
+
text,
|
|
1213
|
+
options: {
|
|
1214
|
+
// The root's card reads only when the selection holds all of the root.
|
|
1215
|
+
catalogId: options.catalogId && rangeHoldsSpeakableElement(range, root, root)
|
|
1216
|
+
? options.catalogId
|
|
1217
|
+
: undefined,
|
|
1218
|
+
catalogContext: options.catalogContext,
|
|
1219
|
+
language: language.locale,
|
|
1220
|
+
contentLanguage: language.contentLanguage,
|
|
1221
|
+
contentElement: root,
|
|
1222
|
+
contentRange: range,
|
|
1223
|
+
wordBoundaryOffset: offset,
|
|
1224
|
+
},
|
|
1001
1225
|
};
|
|
1002
1226
|
}
|
|
1003
|
-
|
|
1004
|
-
* Speak text with optional catalog support
|
|
1005
|
-
*
|
|
1006
|
-
* @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
|
|
1007
|
-
* @param options Optional catalog ID, language, and content element for highlighting
|
|
1008
|
-
*/
|
|
1009
|
-
async speak(text, options) {
|
|
1010
|
-
if (!this.provider) {
|
|
1011
|
-
throw new Error("TTS service not initialized");
|
|
1012
|
-
}
|
|
1013
|
-
const runId = ++this.speakRunId;
|
|
1014
|
-
this.clearPlaybackStartBarrier();
|
|
1227
|
+
async speakContent(text, options, runId) {
|
|
1015
1228
|
let playbackStartBarrier = null;
|
|
1016
1229
|
try {
|
|
1017
1230
|
await this.applyLanguageSettings(options);
|
|
@@ -1027,8 +1240,9 @@ export class TTSService {
|
|
|
1027
1240
|
catalogId: options?.catalogId,
|
|
1028
1241
|
});
|
|
1029
1242
|
this.initializeSpeakTracking(highlightText, options);
|
|
1030
|
-
const highlightMode =
|
|
1031
|
-
|
|
1243
|
+
const highlightMode = speechMatchesVisibleText
|
|
1244
|
+
? this.resolveHighlightMode()
|
|
1245
|
+
: "sentence";
|
|
1032
1246
|
this.activeHighlightMode = highlightMode;
|
|
1033
1247
|
this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
|
|
1034
1248
|
const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
|
|
@@ -1042,7 +1256,7 @@ export class TTSService {
|
|
|
1042
1256
|
: hasExplicitBreaks || !speechMatchesVisibleText
|
|
1043
1257
|
? []
|
|
1044
1258
|
: shouldUsePlan && this.currentContentElement
|
|
1045
|
-
? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0)
|
|
1259
|
+
? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0, options?.language)
|
|
1046
1260
|
: this.createSeekSegmentsFromText(highlightText);
|
|
1047
1261
|
this.sentenceHighlightSegments = hasExplicitBreaks
|
|
1048
1262
|
? []
|
|
@@ -1051,7 +1265,6 @@ export class TTSService {
|
|
|
1051
1265
|
shouldUsePlan,
|
|
1052
1266
|
playbackSegments: this.seekSegments,
|
|
1053
1267
|
});
|
|
1054
|
-
this.setState(PlaybackState.LOADING);
|
|
1055
1268
|
playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
|
|
1056
1269
|
this.prepareHighlightsForSpeak({
|
|
1057
1270
|
contentToSpeak: highlightText,
|
|
@@ -1069,9 +1282,8 @@ export class TTSService {
|
|
|
1069
1282
|
else {
|
|
1070
1283
|
this.clearWordBoundaryHighlighting();
|
|
1071
1284
|
}
|
|
1072
|
-
if (!playbackStartBarrier)
|
|
1073
|
-
this.
|
|
1074
|
-
}
|
|
1285
|
+
if (!playbackStartBarrier)
|
|
1286
|
+
this.markPlaying();
|
|
1075
1287
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1076
1288
|
await this.executeSpeakPlayback({
|
|
1077
1289
|
shouldUsePlan,
|
|
@@ -1086,146 +1298,68 @@ export class TTSService {
|
|
|
1086
1298
|
this.clearHighlightsAndTracking();
|
|
1087
1299
|
}
|
|
1088
1300
|
catch (error) {
|
|
1089
|
-
|
|
1090
|
-
console.error("TTS error:", finalError);
|
|
1091
|
-
this.lastError =
|
|
1092
|
-
finalError instanceof Error ? finalError.message : String(finalError);
|
|
1301
|
+
// A superseded run's failure is the abort that superseded it.
|
|
1093
1302
|
if (runId !== this.speakRunId)
|
|
1094
1303
|
return;
|
|
1304
|
+
console.error("TTS error:", error);
|
|
1305
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1095
1306
|
this.setState(PlaybackState.ERROR);
|
|
1096
1307
|
this.clearHighlightsAndTracking();
|
|
1097
|
-
throw
|
|
1308
|
+
throw error;
|
|
1098
1309
|
}
|
|
1099
1310
|
finally {
|
|
1100
1311
|
this.clearPlaybackStartBarrier(playbackStartBarrier);
|
|
1101
1312
|
}
|
|
1102
1313
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
// rejected initialization. Carry only settings whose meaning is portable
|
|
1110
|
-
// when crossing the provider boundary; otherwise a Polly/Google/SC voice
|
|
1111
|
-
// such as "Joanna" can incorrectly become an explicit Browser voice.
|
|
1112
|
-
const browserConfig = {};
|
|
1113
|
-
if (this.ttsConfig.rate !== undefined) {
|
|
1114
|
-
browserConfig.rate = this.ttsConfig.rate;
|
|
1115
|
-
}
|
|
1116
|
-
if (this.ttsConfig.pitch !== undefined) {
|
|
1117
|
-
browserConfig.pitch = this.ttsConfig.pitch;
|
|
1118
|
-
}
|
|
1119
|
-
if (this.ttsConfig.mathTokenHighlighting !== undefined) {
|
|
1120
|
-
browserConfig.mathTokenHighlighting =
|
|
1121
|
-
this.ttsConfig.mathTokenHighlighting;
|
|
1122
|
-
}
|
|
1123
|
-
const operation = context?.operation || "tts-initialize";
|
|
1124
|
-
const offendingText = context?.contentToSpeak ?? "";
|
|
1125
|
-
const offendingPreview = offendingText
|
|
1126
|
-
.replace(/\s+/g, " ")
|
|
1127
|
-
.trim()
|
|
1128
|
-
.slice(0, 120);
|
|
1129
|
-
const offendingDiagnostic = offendingText.length > 0
|
|
1130
|
-
? {
|
|
1131
|
-
textLength: offendingText.length,
|
|
1132
|
-
textPreview: offendingPreview.length < offendingText.length
|
|
1133
|
-
? `${offendingPreview}…`
|
|
1134
|
-
: offendingPreview,
|
|
1135
|
-
}
|
|
1136
|
-
: {};
|
|
1137
|
-
try {
|
|
1138
|
-
this.provider?.stop();
|
|
1139
|
-
this.currentProvider.destroy();
|
|
1140
|
-
this.currentProvider = fallbackProvider;
|
|
1141
|
-
this.provider = await fallbackProvider.initialize(browserConfig);
|
|
1142
|
-
this.ttsConfig = browserConfig;
|
|
1143
|
-
await this.emitTelemetry("pie-tool-runtime-fallback", {
|
|
1144
|
-
toolId: "textToSpeech",
|
|
1145
|
-
operation,
|
|
1146
|
-
fromProvider: previousProviderId,
|
|
1147
|
-
toProvider: fallbackProvider.providerId,
|
|
1148
|
-
reason: reason instanceof Error ? reason.message : String(reason),
|
|
1149
|
-
...offendingDiagnostic,
|
|
1150
|
-
});
|
|
1151
|
-
console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
|
|
1152
|
-
fromProvider: previousProviderId,
|
|
1153
|
-
reason,
|
|
1154
|
-
...offendingDiagnostic,
|
|
1155
|
-
});
|
|
1156
|
-
return true;
|
|
1157
|
-
}
|
|
1158
|
-
catch (fallbackError) {
|
|
1159
|
-
await this.emitTelemetry("pie-tool-runtime-fallback-error", {
|
|
1160
|
-
toolId: "textToSpeech",
|
|
1161
|
-
operation,
|
|
1162
|
-
fromProvider: previousProviderId,
|
|
1163
|
-
toProvider: "browser",
|
|
1164
|
-
errorType: "TTSRuntimeFallbackError",
|
|
1165
|
-
message: fallbackError instanceof Error
|
|
1166
|
-
? fallbackError.message
|
|
1167
|
-
: String(fallbackError),
|
|
1168
|
-
});
|
|
1169
|
-
console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
|
|
1170
|
-
return false;
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
isBrowserSpeechFallbackAvailable() {
|
|
1174
|
-
if (typeof window === "undefined")
|
|
1175
|
-
return false;
|
|
1176
|
-
if (!("speechSynthesis" in window))
|
|
1177
|
-
return false;
|
|
1178
|
-
return (typeof globalThis
|
|
1179
|
-
.SpeechSynthesisUtterance === "function");
|
|
1180
|
-
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Hand the read's language to the provider. A read in a named content language
|
|
1316
|
+
* sets the provider's locales and `contentLanguage` to it, so a browser voice
|
|
1317
|
+
* follows it; a read naming none restores the host's locales and clears
|
|
1318
|
+
* `contentLanguage`, so the browser voice follows the browser's language.
|
|
1319
|
+
*/
|
|
1181
1320
|
async applyLanguageSettings(options) {
|
|
1182
|
-
if (!
|
|
1321
|
+
if (!this.provider)
|
|
1183
1322
|
return;
|
|
1184
|
-
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
const
|
|
1323
|
+
this.readLocale = options.language;
|
|
1324
|
+
const named = options.contentLanguage;
|
|
1325
|
+
const providerOptions = this.providerOptions();
|
|
1326
|
+
const next = {
|
|
1327
|
+
locale: named ?? this.hostLocales.locale,
|
|
1328
|
+
textNormalization: named ?? this.hostLocales.textNormalization,
|
|
1329
|
+
segmenter: named ?? this.hostLocales.segmenter,
|
|
1330
|
+
};
|
|
1331
|
+
if (providerOptions.contentLanguage === named &&
|
|
1332
|
+
optionalString(providerOptions.locale) === next.locale &&
|
|
1333
|
+
optionalString(providerOptions.textNormalization?.locale) ===
|
|
1334
|
+
next.textNormalization &&
|
|
1335
|
+
optionalString(providerOptions.segmenter?.locale) === next.segmenter) {
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// Every key is set, undefined included: providers merge options shallowly.
|
|
1188
1339
|
const mergedProviderOptions = {
|
|
1189
1340
|
...providerOptions,
|
|
1190
|
-
locale:
|
|
1341
|
+
locale: next.locale,
|
|
1191
1342
|
textNormalization: {
|
|
1192
|
-
...textNormalization,
|
|
1193
|
-
locale:
|
|
1194
|
-
},
|
|
1195
|
-
segmenter: {
|
|
1196
|
-
...segmenter,
|
|
1197
|
-
locale: options.language,
|
|
1343
|
+
...providerOptions.textNormalization,
|
|
1344
|
+
locale: next.textNormalization,
|
|
1198
1345
|
},
|
|
1346
|
+
segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
|
|
1347
|
+
contentLanguage: named,
|
|
1199
1348
|
};
|
|
1200
1349
|
this.ttsConfig = {
|
|
1201
1350
|
...this.ttsConfig,
|
|
1202
1351
|
providerOptions: mergedProviderOptions,
|
|
1203
1352
|
};
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
|
|
1208
|
-
}
|
|
1353
|
+
await this.provider.updateSettings({
|
|
1354
|
+
providerOptions: mergedProviderOptions,
|
|
1355
|
+
});
|
|
1209
1356
|
}
|
|
1210
1357
|
async resolveSpeechContent(text, options) {
|
|
1211
1358
|
const normalizedInputText = normalizeTextForSpeech(text);
|
|
1212
|
-
if (options
|
|
1213
|
-
return {
|
|
1214
|
-
contentToSpeak: normalizedInputText,
|
|
1215
|
-
speechText: normalizedInputText,
|
|
1216
|
-
visibleText: normalizedInputText,
|
|
1217
|
-
highlightText: normalizedInputText,
|
|
1218
|
-
usedCatalogSpoken: false,
|
|
1219
|
-
speechSource: "dom-or-input",
|
|
1220
|
-
normalizedText: normalizedInputText,
|
|
1221
|
-
containsMathMarkup: false,
|
|
1222
|
-
speechMatchesVisibleText: true,
|
|
1223
|
-
};
|
|
1224
|
-
}
|
|
1225
|
-
if (options?.catalogId && this.catalogResolver) {
|
|
1359
|
+
if (options.catalogId && this.catalogResolver) {
|
|
1226
1360
|
const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
|
|
1227
1361
|
type: "spoken",
|
|
1228
|
-
language: options.language
|
|
1362
|
+
language: options.language,
|
|
1229
1363
|
useFallback: true,
|
|
1230
1364
|
context: options.catalogContext,
|
|
1231
1365
|
form: "content",
|
|
@@ -1234,11 +1368,9 @@ export class TTSService {
|
|
|
1234
1368
|
// instance. Fall through to generated TTS rather than speaking "".
|
|
1235
1369
|
const spokenText = catalogContent?.content;
|
|
1236
1370
|
if (catalogContent && spokenText !== undefined) {
|
|
1237
|
-
const visibleText = options
|
|
1238
|
-
? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
|
|
1239
|
-
: normalizedInputText;
|
|
1371
|
+
const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
1240
1372
|
const normalizedCatalogText = normalizeTextForSpeech(spokenText);
|
|
1241
|
-
|
|
1373
|
+
logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
|
|
1242
1374
|
return {
|
|
1243
1375
|
contentToSpeak: spokenText,
|
|
1244
1376
|
speechText: spokenText,
|
|
@@ -1251,24 +1383,12 @@ export class TTSService {
|
|
|
1251
1383
|
speechMatchesVisibleText: normalizedCatalogText === visibleText,
|
|
1252
1384
|
};
|
|
1253
1385
|
}
|
|
1254
|
-
|
|
1386
|
+
logger.debug(`No catalog found for "${options.catalogId}", falling back to generated TTS`);
|
|
1255
1387
|
}
|
|
1256
|
-
const composed = options
|
|
1257
|
-
? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
|
|
1258
|
-
: null;
|
|
1388
|
+
const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
|
|
1259
1389
|
if (composed)
|
|
1260
1390
|
return composed;
|
|
1261
|
-
const generated = options
|
|
1262
|
-
? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
|
|
1263
|
-
: {
|
|
1264
|
-
contentToSpeak: normalizedInputText,
|
|
1265
|
-
speechText: normalizedInputText,
|
|
1266
|
-
visibleText: normalizedInputText,
|
|
1267
|
-
highlightText: normalizedInputText,
|
|
1268
|
-
normalizedText: normalizedInputText,
|
|
1269
|
-
containsMathMarkup: false,
|
|
1270
|
-
speechMatchesVisibleText: true,
|
|
1271
|
-
};
|
|
1391
|
+
const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
|
|
1272
1392
|
return {
|
|
1273
1393
|
...generated,
|
|
1274
1394
|
usedCatalogSpoken: false,
|
|
@@ -1282,7 +1402,11 @@ export class TTSService {
|
|
|
1282
1402
|
if (!chunks.some((chunk) => chunk.sourceElement))
|
|
1283
1403
|
return null;
|
|
1284
1404
|
const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
|
|
1285
|
-
|
|
1405
|
+
// A selection's visible text is the text it holds, which the caller passes:
|
|
1406
|
+
// the highlight offsets index it from `wordBoundaryOffset`.
|
|
1407
|
+
const visibleText = options.contentRange
|
|
1408
|
+
? normalizedInputText
|
|
1409
|
+
: collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
1286
1410
|
return {
|
|
1287
1411
|
contentToSpeak: speechText,
|
|
1288
1412
|
speechText,
|
|
@@ -1311,13 +1435,22 @@ export class TTSService {
|
|
|
1311
1435
|
speechMatchesVisibleText: true,
|
|
1312
1436
|
});
|
|
1313
1437
|
};
|
|
1438
|
+
const range = options.contentRange;
|
|
1314
1439
|
const resolveCatalog = (element) => {
|
|
1315
1440
|
const catalogIdRef = element.getAttribute("data-catalog-idref");
|
|
1316
1441
|
if (!catalogIdRef)
|
|
1317
1442
|
return null;
|
|
1443
|
+
// A card reads its whole node, so it stands in for a selection's part of
|
|
1444
|
+
// the node only when the selection holds all of it. A part reads as the
|
|
1445
|
+
// visible text selected.
|
|
1446
|
+
if (range && !rangeHoldsSpeakableElement(range, element, root)) {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1318
1449
|
const lookup = {
|
|
1319
1450
|
type: "spoken",
|
|
1320
|
-
language:
|
|
1451
|
+
// The node's own language first: a `lang` inside the read content
|
|
1452
|
+
// names the language of the part it marks.
|
|
1453
|
+
language: findLangAttribute(element, root) || options.language,
|
|
1321
1454
|
useFallback: true,
|
|
1322
1455
|
context: options.catalogContext,
|
|
1323
1456
|
};
|
|
@@ -1354,7 +1487,7 @@ export class TTSService {
|
|
|
1354
1487
|
: null;
|
|
1355
1488
|
};
|
|
1356
1489
|
const getMathElementsForAlignment = (element) => {
|
|
1357
|
-
const mathElements =
|
|
1490
|
+
const mathElements = flatQuerySelectorAll(element, "math");
|
|
1358
1491
|
if (element.localName?.toLowerCase() === "math") {
|
|
1359
1492
|
return [element, ...mathElements];
|
|
1360
1493
|
}
|
|
@@ -1367,7 +1500,12 @@ export class TTSService {
|
|
|
1367
1500
|
if (isNodeExcludedFromSpeech(node, root))
|
|
1368
1501
|
return;
|
|
1369
1502
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
1370
|
-
|
|
1503
|
+
if (!range) {
|
|
1504
|
+
textBuffer += ` ${node.textContent || ""}`;
|
|
1505
|
+
}
|
|
1506
|
+
else if (rangeIntersectsComposedNode(range, node)) {
|
|
1507
|
+
textBuffer += ` ${textInRange(node, range)}`;
|
|
1508
|
+
}
|
|
1371
1509
|
return;
|
|
1372
1510
|
}
|
|
1373
1511
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
@@ -1378,7 +1516,7 @@ export class TTSService {
|
|
|
1378
1516
|
flushTextBuffer();
|
|
1379
1517
|
const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
|
|
1380
1518
|
const visibleText = collectedVisible.visibleText ||
|
|
1381
|
-
normalizeTextForSpeech(element
|
|
1519
|
+
normalizeTextForSpeech(flatTextContent(element));
|
|
1382
1520
|
const regionElement = resolveReadableRegion(element, root);
|
|
1383
1521
|
// The script chunk, when there is a script. Built exactly as before,
|
|
1384
1522
|
// and it doubles as the recording's fallback: word-level alignment is
|
|
@@ -1446,7 +1584,7 @@ export class TTSService {
|
|
|
1446
1584
|
chunks.push(scriptChunk);
|
|
1447
1585
|
return;
|
|
1448
1586
|
}
|
|
1449
|
-
for (const child of
|
|
1587
|
+
for (const child of flatTreeChildNodes(element)) {
|
|
1450
1588
|
visit(child);
|
|
1451
1589
|
}
|
|
1452
1590
|
};
|
|
@@ -1468,12 +1606,13 @@ export class TTSService {
|
|
|
1468
1606
|
const provider = this.currentProvider;
|
|
1469
1607
|
if (!provider)
|
|
1470
1608
|
return "plain";
|
|
1471
|
-
// Belt-and-suspenders: the browser provider can never voice SSML.
|
|
1472
|
-
if (provider.providerId?.toLowerCase() === "browser")
|
|
1473
|
-
return "plain";
|
|
1474
1609
|
return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
|
|
1475
1610
|
}
|
|
1476
|
-
|
|
1611
|
+
/**
|
|
1612
|
+
* With `range`, the selection only: an equation it holds whole reads as math
|
|
1613
|
+
* speech, and a selection without one reads the selected text, `normalizedInputText`.
|
|
1614
|
+
*/
|
|
1615
|
+
async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language, range) {
|
|
1477
1616
|
// Delegate to the generated-speech module: a pure plan assembly + SRE
|
|
1478
1617
|
// memoization core, plus a DOM adapter that binds live-DOM anchors and
|
|
1479
1618
|
// emits playback chunks. The aggregate `contentToSpeak` stays plain text
|
|
@@ -1481,14 +1620,19 @@ export class TTSService {
|
|
|
1481
1620
|
const playbackFormat = this.resolveGeneratedPlaybackFormat();
|
|
1482
1621
|
const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
|
|
1483
1622
|
contentRoot: contentElement,
|
|
1623
|
+
range,
|
|
1484
1624
|
language,
|
|
1485
1625
|
mathSpeech: this.getMathSpeechOptions(),
|
|
1486
1626
|
textProcessingOptions: this.getTextProcessingOptions(language),
|
|
1487
1627
|
produceSsml: playbackFormat === "ssml",
|
|
1488
1628
|
resolveMathSpeech: this.generatedMathSpeechResolver,
|
|
1489
1629
|
});
|
|
1490
|
-
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1491
1630
|
if (!containsMathMarkup) {
|
|
1631
|
+
// The input text is the selection's text in the root's visible-text map,
|
|
1632
|
+
// which its highlight offsets index.
|
|
1633
|
+
const visibleText = range
|
|
1634
|
+
? normalizedInputText
|
|
1635
|
+
: collectedVisibleText || normalizedInputText;
|
|
1492
1636
|
return {
|
|
1493
1637
|
contentToSpeak: visibleText,
|
|
1494
1638
|
speechText: visibleText,
|
|
@@ -1499,6 +1643,7 @@ export class TTSService {
|
|
|
1499
1643
|
speechMatchesVisibleText: true,
|
|
1500
1644
|
};
|
|
1501
1645
|
}
|
|
1646
|
+
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1502
1647
|
const speechChunks = planToCompositionChunkInputs(plan, {
|
|
1503
1648
|
format: playbackFormat,
|
|
1504
1649
|
});
|
|
@@ -1519,7 +1664,7 @@ export class TTSService {
|
|
|
1519
1664
|
.replace(/\s+/g, " ")
|
|
1520
1665
|
.trim()
|
|
1521
1666
|
.slice(0, 200);
|
|
1522
|
-
|
|
1667
|
+
logger.debug("Speak resolved content", {
|
|
1523
1668
|
source: args.speechSource,
|
|
1524
1669
|
catalogId: args.catalogId || null,
|
|
1525
1670
|
length: args.contentToSpeak.length,
|
|
@@ -1528,7 +1673,7 @@ export class TTSService {
|
|
|
1528
1673
|
}
|
|
1529
1674
|
initializeSpeakTracking(contentToSpeak, options) {
|
|
1530
1675
|
this.currentText = contentToSpeak;
|
|
1531
|
-
this.currentContentElement = options
|
|
1676
|
+
this.currentContentElement = options.contentElement;
|
|
1532
1677
|
this.lastError = null;
|
|
1533
1678
|
this.currentBoundaryOffset = 0;
|
|
1534
1679
|
this.playbackChunks = [];
|
|
@@ -1537,7 +1682,7 @@ export class TTSService {
|
|
|
1537
1682
|
prepareHighlightsForSpeak(args) {
|
|
1538
1683
|
if (!this.currentContentElement || !this.highlightCoordinator)
|
|
1539
1684
|
return;
|
|
1540
|
-
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options
|
|
1685
|
+
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
|
|
1541
1686
|
if (args.highlightMode === "sentence" && args.shouldUsePlan) {
|
|
1542
1687
|
return;
|
|
1543
1688
|
}
|
|
@@ -1545,7 +1690,7 @@ export class TTSService {
|
|
|
1545
1690
|
this.runWhenPlaybackStarts(args.runId, () => {
|
|
1546
1691
|
if (initialSegment) {
|
|
1547
1692
|
this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
|
|
1548
|
-
|
|
1693
|
+
logger.debug("Applied initial sentence highlighting");
|
|
1549
1694
|
}
|
|
1550
1695
|
else {
|
|
1551
1696
|
try {
|
|
@@ -1560,34 +1705,29 @@ export class TTSService {
|
|
|
1560
1705
|
});
|
|
1561
1706
|
}
|
|
1562
1707
|
configureWordBoundaryHighlighting(args) {
|
|
1563
|
-
if (!this.provider
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1708
|
+
if (!this.provider)
|
|
1709
|
+
return;
|
|
1710
|
+
// A sentence-mode read paints no words, so it takes no word boundaries.
|
|
1711
|
+
if (args.highlightMode === "sentence") {
|
|
1712
|
+
this.provider.onWordBoundary = undefined;
|
|
1567
1713
|
return;
|
|
1568
1714
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1715
|
+
if (!this.highlightCoordinator || !this.currentContentElement)
|
|
1716
|
+
return;
|
|
1717
|
+
// Browser boundaries and the server provider's time-scaled boundaries index
|
|
1718
|
+
// the same spoken text.
|
|
1571
1719
|
this.provider.onWordBoundary = (word, charIndex, length) => {
|
|
1572
1720
|
const wordLength = length || word.length;
|
|
1573
1721
|
const spokenIndex = charIndex + this.currentBoundaryOffset;
|
|
1574
1722
|
const globalIndex = spokenIndex + args.wordBoundaryOffset;
|
|
1575
1723
|
this.highlightSentenceForOffset(spokenIndex);
|
|
1576
|
-
const
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
1581
|
-
if (nativeRange) {
|
|
1582
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
|
|
1583
|
-
}
|
|
1584
|
-
else {
|
|
1585
|
-
this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
else {
|
|
1589
|
-
console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
|
|
1724
|
+
const ranges = createRangesFromVisibleMap(this.normalizedToDOM, globalIndex, globalIndex + wordLength);
|
|
1725
|
+
if (ranges.length === 0) {
|
|
1726
|
+
logger.debug(`no text at position ${globalIndex}, length ${wordLength}, to highlight`);
|
|
1727
|
+
return;
|
|
1590
1728
|
}
|
|
1729
|
+
logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
|
|
1730
|
+
this.paintTTSWord(ranges);
|
|
1591
1731
|
};
|
|
1592
1732
|
}
|
|
1593
1733
|
clearWordBoundaryHighlighting() {
|
|
@@ -1602,26 +1742,22 @@ export class TTSService {
|
|
|
1602
1742
|
try {
|
|
1603
1743
|
this.lastRenderedRegionTarget = null;
|
|
1604
1744
|
if (chunk.regionRange) {
|
|
1605
|
-
|
|
1606
|
-
this.
|
|
1745
|
+
const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
|
|
1746
|
+
this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
|
|
1747
|
+
this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
|
|
1607
1748
|
return;
|
|
1608
1749
|
}
|
|
1609
1750
|
if (!element)
|
|
1610
1751
|
return;
|
|
1611
1752
|
const range = document.createRange();
|
|
1612
1753
|
range.selectNodeContents(element);
|
|
1613
|
-
this.highlightCoordinator.clearHighlights
|
|
1754
|
+
this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
|
|
1614
1755
|
this.paintTTSSentenceRanges([range]);
|
|
1615
1756
|
}
|
|
1616
1757
|
catch {
|
|
1617
1758
|
// Continue playback even when a detached node cannot be highlighted.
|
|
1618
1759
|
}
|
|
1619
1760
|
}
|
|
1620
|
-
highlightCatalogActiveRange(range) {
|
|
1621
|
-
if (!this.highlightCoordinator)
|
|
1622
|
-
return;
|
|
1623
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
|
|
1624
|
-
}
|
|
1625
1761
|
highlightRenderableRegionTarget(target) {
|
|
1626
1762
|
if (!this.highlightCoordinator ||
|
|
1627
1763
|
!target ||
|
|
@@ -1638,7 +1774,7 @@ export class TTSService {
|
|
|
1638
1774
|
range.selectNodeContents(target.element);
|
|
1639
1775
|
}
|
|
1640
1776
|
else if (target.type === "range") {
|
|
1641
|
-
this.paintTTSSentenceRanges([target.range]);
|
|
1777
|
+
this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
|
|
1642
1778
|
this.lastRenderedRegionTarget = target;
|
|
1643
1779
|
return;
|
|
1644
1780
|
}
|
|
@@ -1668,23 +1804,19 @@ export class TTSService {
|
|
|
1668
1804
|
}
|
|
1669
1805
|
if (target.type === "text-range") {
|
|
1670
1806
|
const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
|
|
1671
|
-
if (nativeRange)
|
|
1672
|
-
this.
|
|
1673
|
-
}
|
|
1674
|
-
else {
|
|
1675
|
-
this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
|
|
1676
|
-
}
|
|
1807
|
+
if (nativeRange)
|
|
1808
|
+
this.paintTTSWord([nativeRange]);
|
|
1677
1809
|
return;
|
|
1678
1810
|
}
|
|
1679
1811
|
if (target.type === "range") {
|
|
1680
|
-
this.
|
|
1812
|
+
this.paintTTSWord(target.ranges ?? [target.range]);
|
|
1681
1813
|
return;
|
|
1682
1814
|
}
|
|
1683
1815
|
const elementNativeRange = document.createRange();
|
|
1684
1816
|
elementNativeRange.selectNodeContents(target.element);
|
|
1685
1817
|
const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
|
|
1686
1818
|
if (resolvedElementRange.remapped) {
|
|
1687
|
-
this.
|
|
1819
|
+
this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
|
|
1688
1820
|
return;
|
|
1689
1821
|
}
|
|
1690
1822
|
// Element targets are atomic: a resolved math token, a whole-expression
|
|
@@ -1699,10 +1831,12 @@ export class TTSService {
|
|
|
1699
1831
|
const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
|
|
1700
1832
|
if (target.quality === "semantic-token" &&
|
|
1701
1833
|
onlyChild?.nodeType === Node.TEXT_NODE) {
|
|
1702
|
-
|
|
1834
|
+
const tokenRange = document.createRange();
|
|
1835
|
+
tokenRange.selectNodeContents(onlyChild);
|
|
1836
|
+
this.highlightCoordinator.highlightTTSWord([tokenRange]);
|
|
1703
1837
|
return;
|
|
1704
1838
|
}
|
|
1705
|
-
this.highlightCoordinator.highlightTTSWordElement
|
|
1839
|
+
this.highlightCoordinator.highlightTTSWordElement(target.element);
|
|
1706
1840
|
}
|
|
1707
1841
|
renderHighlightDecision(decision) {
|
|
1708
1842
|
this.highlightRenderableRegionTarget(decision.regionTarget);
|
|
@@ -1718,7 +1852,9 @@ export class TTSService {
|
|
|
1718
1852
|
// anchors, plain alignment). The fallback chunk carries no further
|
|
1719
1853
|
// fallback, so this cannot recurse.
|
|
1720
1854
|
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1721
|
-
|
|
1855
|
+
logger.debug("SSML chunk speak failed; retrying plain text", {
|
|
1856
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1857
|
+
});
|
|
1722
1858
|
// A recorded-first run can own the start barrier even when its fallback
|
|
1723
1859
|
// provider has no formal start signal. Drop the failed recording's queued
|
|
1724
1860
|
// highlight and resume that non-start-aware provider's immediate-start lifecycle.
|
|
@@ -1727,7 +1863,7 @@ export class TTSService {
|
|
|
1727
1863
|
!("onPlaybackStart" in this.provider)) {
|
|
1728
1864
|
this.pendingPlaybackStartHighlights = [];
|
|
1729
1865
|
this.clearPlaybackStartBarrier();
|
|
1730
|
-
this.
|
|
1866
|
+
this.markPlaying();
|
|
1731
1867
|
}
|
|
1732
1868
|
await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
|
|
1733
1869
|
return;
|
|
@@ -1787,6 +1923,18 @@ export class TTSService {
|
|
|
1787
1923
|
cleanup();
|
|
1788
1924
|
reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
|
|
1789
1925
|
};
|
|
1926
|
+
// A pause before the clip starts rejects its play with an AbortError;
|
|
1927
|
+
// resume plays it, so that rejection is no failure of the clip.
|
|
1928
|
+
const play = () => {
|
|
1929
|
+
Promise.resolve(element.play())
|
|
1930
|
+
.then(markStarted)
|
|
1931
|
+
.catch((error) => {
|
|
1932
|
+
const aborted = error?.name === "AbortError";
|
|
1933
|
+
if (aborted && this.state === PlaybackState.PAUSED)
|
|
1934
|
+
return;
|
|
1935
|
+
onError();
|
|
1936
|
+
});
|
|
1937
|
+
};
|
|
1790
1938
|
// Cancellation has to settle this promise, not just stop the element:
|
|
1791
1939
|
// `stop()` bumps the run id, and a pending play that never resolves would
|
|
1792
1940
|
// wedge the chunk loop on a run nobody is listening to any more.
|
|
@@ -1800,6 +1948,7 @@ export class TTSService {
|
|
|
1800
1948
|
element.pause();
|
|
1801
1949
|
resolve();
|
|
1802
1950
|
},
|
|
1951
|
+
play,
|
|
1803
1952
|
};
|
|
1804
1953
|
element.addEventListener("ended", onEnded);
|
|
1805
1954
|
element.addEventListener("error", onError);
|
|
@@ -1807,7 +1956,7 @@ export class TTSService {
|
|
|
1807
1956
|
// Reaching the slice's end is this clip finishing, so the chunk sequence
|
|
1808
1957
|
// advances rather than the element merely pausing.
|
|
1809
1958
|
disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
|
|
1810
|
-
|
|
1959
|
+
play();
|
|
1811
1960
|
}).finally(() => {
|
|
1812
1961
|
if (this.activeRecordedAudio?.element === element) {
|
|
1813
1962
|
this.activeRecordedAudio = null;
|
|
@@ -1860,7 +2009,9 @@ export class TTSService {
|
|
|
1860
2009
|
this.highlightCatalogRegion(chunk);
|
|
1861
2010
|
}
|
|
1862
2011
|
});
|
|
1863
|
-
|
|
2012
|
+
// A sentence-mode read takes no word boundaries, aligned chunk or not.
|
|
2013
|
+
const canUseChunkBoundaries = this.resolveHighlightMode() === "word" &&
|
|
2014
|
+
chunk.sourceElement &&
|
|
1864
2015
|
((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
|
|
1865
2016
|
(chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
|
|
1866
2017
|
(chunk.alignment &&
|
|
@@ -1872,7 +2023,7 @@ export class TTSService {
|
|
|
1872
2023
|
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1873
2024
|
provider.onWordBoundary = undefined;
|
|
1874
2025
|
try {
|
|
1875
|
-
await
|
|
2026
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1876
2027
|
}
|
|
1877
2028
|
finally {
|
|
1878
2029
|
if (this.provider === provider &&
|
|
@@ -1904,7 +2055,7 @@ export class TTSService {
|
|
|
1904
2055
|
};
|
|
1905
2056
|
provider.onWordBoundary = installedHandler;
|
|
1906
2057
|
try {
|
|
1907
|
-
await
|
|
2058
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1908
2059
|
}
|
|
1909
2060
|
finally {
|
|
1910
2061
|
if (this.provider === provider &&
|
|
@@ -1917,6 +2068,7 @@ export class TTSService {
|
|
|
1917
2068
|
async executeSpeakPlayback(args) {
|
|
1918
2069
|
if (args.speechChunks?.length && this.provider) {
|
|
1919
2070
|
for (let index = 0; index < args.speechChunks.length; index++) {
|
|
2071
|
+
await this.waitWhilePaused(args.runId);
|
|
1920
2072
|
if (args.runId !== this.speakRunId)
|
|
1921
2073
|
return;
|
|
1922
2074
|
const segment = this.seekSegments[index];
|
|
@@ -1935,16 +2087,16 @@ export class TTSService {
|
|
|
1935
2087
|
highlightMode: args.highlightMode,
|
|
1936
2088
|
});
|
|
1937
2089
|
}
|
|
1938
|
-
else
|
|
1939
|
-
await this.
|
|
2090
|
+
else {
|
|
2091
|
+
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
1940
2092
|
}
|
|
1941
2093
|
return;
|
|
1942
2094
|
}
|
|
1943
|
-
|
|
1944
|
-
await this.provider.speak(args.contentToSpeak);
|
|
1945
|
-
}
|
|
2095
|
+
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
1946
2096
|
}
|
|
1947
2097
|
clearHighlightsAndTracking() {
|
|
2098
|
+
// A finished run leaves no handler for the next read to inherit.
|
|
2099
|
+
this.clearWordBoundaryHighlighting();
|
|
1948
2100
|
this.pendingPlaybackStartHighlights = [];
|
|
1949
2101
|
this.activePlaybackRate = null;
|
|
1950
2102
|
if (this.highlightCoordinator) {
|
|
@@ -1960,37 +2112,20 @@ export class TTSService {
|
|
|
1960
2112
|
this.sentenceHighlightSegments = [];
|
|
1961
2113
|
this.activeSentenceStartOffset = null;
|
|
1962
2114
|
}
|
|
1963
|
-
/**
|
|
1964
|
-
* Speak a text range with accurate word highlighting
|
|
1965
|
-
*
|
|
1966
|
-
* This calculates the offset of the range within its parent element
|
|
1967
|
-
* to ensure word highlighting aligns correctly with the selected text.
|
|
1968
|
-
*
|
|
1969
|
-
* @param range DOM Range to speak
|
|
1970
|
-
*/
|
|
1971
2115
|
/**
|
|
1972
2116
|
* The selected part of `root`'s normalized visible text and where it starts, or
|
|
1973
2117
|
* null when the map is unavailable or holds no selected character.
|
|
1974
2118
|
*/
|
|
1975
|
-
selectMappedRangeText(range, root) {
|
|
2119
|
+
selectMappedRangeText(range, root, language) {
|
|
1976
2120
|
if (typeof range.comparePoint !== "function")
|
|
1977
2121
|
return null;
|
|
1978
|
-
const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions());
|
|
2122
|
+
const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
|
|
1979
2123
|
let start = -1;
|
|
1980
2124
|
let end = -1;
|
|
1981
2125
|
for (const [index, { node, offset }] of map) {
|
|
1982
2126
|
if (index >= rootText.length)
|
|
1983
2127
|
continue;
|
|
1984
|
-
|
|
1985
|
-
try {
|
|
1986
|
-
inside =
|
|
1987
|
-
range.comparePoint(node, offset) === 0 &&
|
|
1988
|
-
range.comparePoint(node, Math.min(offset + 1, node.length)) === 0;
|
|
1989
|
-
}
|
|
1990
|
-
catch {
|
|
1991
|
-
inside = false;
|
|
1992
|
-
}
|
|
1993
|
-
if (!inside)
|
|
2128
|
+
if (!rangeHoldsTextPosition(range, node, offset))
|
|
1994
2129
|
continue;
|
|
1995
2130
|
if (start === -1 || index < start)
|
|
1996
2131
|
start = index;
|
|
@@ -2004,98 +2139,34 @@ export class TTSService {
|
|
|
2004
2139
|
const text = raw.trim();
|
|
2005
2140
|
return text ? { text, offset: start + leading } : null;
|
|
2006
2141
|
}
|
|
2007
|
-
async speakRange(range, options) {
|
|
2008
|
-
if (!this.provider) {
|
|
2009
|
-
throw new Error("TTS service not initialized");
|
|
2010
|
-
}
|
|
2011
|
-
// Enforced from the live ancestors rather than from `root`, because the
|
|
2012
|
-
// element carrying the suppression may sit above whatever root the caller
|
|
2013
|
-
// passed or the range happens to resolve to.
|
|
2014
|
-
if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
|
|
2015
|
-
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.");
|
|
2016
|
-
return;
|
|
2017
|
-
}
|
|
2018
|
-
// Use explicit content root when provided; otherwise keep highlighting scoped
|
|
2019
|
-
// to the selected range's nearest element ancestor.
|
|
2020
|
-
const fromOptions = options?.contentRoot || null;
|
|
2021
|
-
let root = fromOptions;
|
|
2022
|
-
if (!root) {
|
|
2023
|
-
const ancestor = range.commonAncestorContainer;
|
|
2024
|
-
root =
|
|
2025
|
-
ancestor.nodeType === Node.ELEMENT_NODE
|
|
2026
|
-
? ancestor
|
|
2027
|
-
: ancestor.parentElement;
|
|
2028
|
-
}
|
|
2029
|
-
if (!root)
|
|
2030
|
-
return;
|
|
2031
|
-
const selected = collectRangeTextForSpeech(range, root);
|
|
2032
|
-
const selectedText = selected.text.trim();
|
|
2033
|
-
if (!selectedText) {
|
|
2034
|
-
if (selected.filtered) {
|
|
2035
|
-
console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
|
|
2036
|
-
}
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
// The spoken text and its offset come from the root's visible-text map, which
|
|
2040
|
-
// the highlight position map and the structural speech plan index too. Joined
|
|
2041
|
-
// from raw text nodes instead, a selection loses the space the map inserts
|
|
2042
|
-
// between blocks, and every offset after that boundary drifts.
|
|
2043
|
-
const mapped = this.selectMappedRangeText(range, root);
|
|
2044
|
-
let text = selectedText;
|
|
2045
|
-
let offset = mapped?.offset ?? 0;
|
|
2046
|
-
if (mapped) {
|
|
2047
|
-
text = mapped.text;
|
|
2048
|
-
}
|
|
2049
|
-
else {
|
|
2050
|
-
// Filtered the same way as the speech itself: the offset indexes into the
|
|
2051
|
-
// highlight text, which comes from the exclusion-aware collectors.
|
|
2052
|
-
const beforeRange = document.createRange();
|
|
2053
|
-
beforeRange.selectNodeContents(root);
|
|
2054
|
-
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
2055
|
-
const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
|
|
2056
|
-
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
2057
|
-
offset =
|
|
2058
|
-
normalizedTextBeforeRange.length +
|
|
2059
|
-
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
2060
|
-
}
|
|
2061
|
-
this.debugLog("[TTSService] speakRange offset calculation:", {
|
|
2062
|
-
selectedText: text,
|
|
2063
|
-
mapped: !!mapped,
|
|
2064
|
-
offset,
|
|
2065
|
-
rootTag: root.tagName,
|
|
2066
|
-
});
|
|
2067
|
-
// Speak the text with the root element as context
|
|
2068
|
-
await this.speak(text, {
|
|
2069
|
-
contentElement: root,
|
|
2070
|
-
ignoreCatalogs: true,
|
|
2071
|
-
wordBoundaryOffset: offset,
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
2142
|
/**
|
|
2075
|
-
* Pause playback
|
|
2143
|
+
* Pause playback. A pause while the read is loading holds it: its audio does
|
|
2144
|
+
* not start until {@link resume}.
|
|
2076
2145
|
*/
|
|
2077
2146
|
pause() {
|
|
2078
|
-
if (
|
|
2147
|
+
if (this.state !== PlaybackState.PLAYING &&
|
|
2148
|
+
this.state !== PlaybackState.LOADING) {
|
|
2079
2149
|
return;
|
|
2080
|
-
if (this.state === PlaybackState.PLAYING) {
|
|
2081
|
-
this.activeRecordedAudio?.element.pause();
|
|
2082
|
-
this.provider.pause();
|
|
2083
|
-
this.setState(PlaybackState.PAUSED);
|
|
2084
2150
|
}
|
|
2151
|
+
this.resumeState = this.state;
|
|
2152
|
+
this.activeRecordedAudio?.element.pause();
|
|
2153
|
+
this.provider?.pause();
|
|
2154
|
+
// Between two parts, nothing is playing to pause: the run holds before
|
|
2155
|
+
// its next part instead.
|
|
2156
|
+
this.setState(PlaybackState.PAUSED);
|
|
2085
2157
|
}
|
|
2086
2158
|
/**
|
|
2087
|
-
* Resume playback
|
|
2159
|
+
* Resume playback, back to loading when the pause came before the audio
|
|
2160
|
+
* started.
|
|
2088
2161
|
*/
|
|
2089
2162
|
resume() {
|
|
2090
|
-
if (
|
|
2163
|
+
if (this.state !== PlaybackState.PAUSED)
|
|
2091
2164
|
return;
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
this.setState(PlaybackState.PLAYING);
|
|
2098
|
-
}
|
|
2165
|
+
// State first: audio that starts during resume reports into it.
|
|
2166
|
+
this.setState(this.resumeState);
|
|
2167
|
+
this.activeRecordedAudio?.play();
|
|
2168
|
+
this.provider?.resume();
|
|
2169
|
+
this.releasePauseHold();
|
|
2099
2170
|
}
|
|
2100
2171
|
normalizePlaybackRate(rate) {
|
|
2101
2172
|
if (!Number.isFinite(rate))
|
|
@@ -2109,18 +2180,27 @@ export class TTSService {
|
|
|
2109
2180
|
const restartChunks = this.playbackChunks.length
|
|
2110
2181
|
? this.playbackChunks.slice(safeTargetIndex)
|
|
2111
2182
|
: null;
|
|
2183
|
+
// A seek under a pause moves the cursor and stays paused: the restarted
|
|
2184
|
+
// run holds before its first part until resume.
|
|
2185
|
+
const holdPaused = this.state === PlaybackState.PAUSED;
|
|
2112
2186
|
this.speakRunId += 1;
|
|
2113
|
-
this.
|
|
2114
|
-
this.cancelRecordedAudio();
|
|
2187
|
+
this.abandonRun();
|
|
2115
2188
|
this.provider.onWordBoundary = undefined;
|
|
2116
2189
|
this.provider.stop();
|
|
2117
2190
|
const runId = ++this.speakRunId;
|
|
2118
2191
|
const restartSegments = this.seekSegments.slice(safeTargetIndex);
|
|
2119
2192
|
this.highlightCoordinator?.clearTTS();
|
|
2120
2193
|
this.activeSentenceStartOffset = null;
|
|
2121
|
-
this.
|
|
2194
|
+
this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
|
|
2195
|
+
if (!holdPaused)
|
|
2196
|
+
this.setState(PlaybackState.LOADING);
|
|
2122
2197
|
const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
|
|
2123
|
-
if (
|
|
2198
|
+
if (holdPaused) {
|
|
2199
|
+
this.resumeState = playbackStartBarrier
|
|
2200
|
+
? PlaybackState.LOADING
|
|
2201
|
+
: PlaybackState.PLAYING;
|
|
2202
|
+
}
|
|
2203
|
+
else if (!playbackStartBarrier) {
|
|
2124
2204
|
this.setState(PlaybackState.PLAYING);
|
|
2125
2205
|
}
|
|
2126
2206
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
@@ -2131,6 +2211,7 @@ export class TTSService {
|
|
|
2131
2211
|
});
|
|
2132
2212
|
if (restartChunks) {
|
|
2133
2213
|
for (let index = 0; index < restartChunks.length; index++) {
|
|
2214
|
+
await this.waitWhilePaused(runId);
|
|
2134
2215
|
if (runId !== this.speakRunId)
|
|
2135
2216
|
return;
|
|
2136
2217
|
const absoluteIndex = safeTargetIndex + index;
|
|
@@ -2254,13 +2335,12 @@ export class TTSService {
|
|
|
2254
2335
|
* Stop playback
|
|
2255
2336
|
*/
|
|
2256
2337
|
stop() {
|
|
2257
|
-
if (!this.provider)
|
|
2258
|
-
return;
|
|
2259
2338
|
this.speakRunId += 1;
|
|
2260
|
-
this.
|
|
2261
|
-
this.
|
|
2262
|
-
|
|
2263
|
-
|
|
2339
|
+
this.abandonRun();
|
|
2340
|
+
if (this.provider) {
|
|
2341
|
+
this.provider.onWordBoundary = undefined;
|
|
2342
|
+
this.provider.stop();
|
|
2343
|
+
}
|
|
2264
2344
|
this.setState(PlaybackState.IDLE);
|
|
2265
2345
|
this.currentText = null;
|
|
2266
2346
|
// Clear highlights
|
|
@@ -2279,6 +2359,36 @@ export class TTSService {
|
|
|
2279
2359
|
this.activeSentenceStartOffset = null;
|
|
2280
2360
|
this.activePlaybackRate = null;
|
|
2281
2361
|
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Stop playback and drop the provider, whose owner destroys it. A later speak
|
|
2364
|
+
* waits on the readiness gate, as before the first {@link initialize}.
|
|
2365
|
+
*/
|
|
2366
|
+
releaseProvider() {
|
|
2367
|
+
this.stop();
|
|
2368
|
+
this.provider = null;
|
|
2369
|
+
this.currentProvider = null;
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Stop, release the provider and drop every listener, hook and pending timer.
|
|
2373
|
+
* A disposed service cannot speak or be initialized again.
|
|
2374
|
+
*/
|
|
2375
|
+
dispose() {
|
|
2376
|
+
if (this.disposed)
|
|
2377
|
+
return;
|
|
2378
|
+
this.releaseProvider();
|
|
2379
|
+
this.disposed = true;
|
|
2380
|
+
this.speakRunId += 1;
|
|
2381
|
+
this.abandonRun();
|
|
2382
|
+
this.playbackRateRequestId += 1;
|
|
2383
|
+
this.pendingPlaybackRateRequest = null;
|
|
2384
|
+
this.listeners.clear();
|
|
2385
|
+
this.readinessGate = null;
|
|
2386
|
+
this.mathSpeechSource = null;
|
|
2387
|
+
this.highlightTargetResolverProvider = null;
|
|
2388
|
+
this.highlightCoordinator = null;
|
|
2389
|
+
this.catalogResolver = null;
|
|
2390
|
+
this.telemetryReporter = null;
|
|
2391
|
+
}
|
|
2282
2392
|
/**
|
|
2283
2393
|
* Request UI-level TTS controls to hand off/deactivate.
|
|
2284
2394
|
*
|
|
@@ -2325,28 +2435,15 @@ export class TTSService {
|
|
|
2325
2435
|
return this.lastError;
|
|
2326
2436
|
}
|
|
2327
2437
|
/**
|
|
2328
|
-
* Subscribe to state changes
|
|
2329
|
-
*
|
|
2330
|
-
* @param id Unique listener ID
|
|
2331
|
-
* @param callback Function to call on state change
|
|
2332
|
-
*/
|
|
2333
|
-
onStateChange(id, callback) {
|
|
2334
|
-
if (!this.listeners.has(id)) {
|
|
2335
|
-
this.listeners.set(id, new Set());
|
|
2336
|
-
}
|
|
2337
|
-
this.listeners.get(id).add(callback);
|
|
2338
|
-
}
|
|
2339
|
-
/**
|
|
2340
|
-
* Unsubscribe from state changes
|
|
2438
|
+
* Subscribe to playback state changes. The returned function unsubscribes;
|
|
2439
|
+
* each subscription is its own, so the same callback can be subscribed twice.
|
|
2341
2440
|
*/
|
|
2342
|
-
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2441
|
+
onStateChange(callback) {
|
|
2442
|
+
const listener = (state) => callback(state);
|
|
2443
|
+
this.listeners.add(listener);
|
|
2444
|
+
return () => {
|
|
2445
|
+
this.listeners.delete(listener);
|
|
2446
|
+
};
|
|
2350
2447
|
}
|
|
2351
2448
|
/**
|
|
2352
2449
|
* Set state and notify listeners
|
|
@@ -2357,14 +2454,11 @@ export class TTSService {
|
|
|
2357
2454
|
const previousState = this.state;
|
|
2358
2455
|
this.state = newState;
|
|
2359
2456
|
// Notify all listeners
|
|
2360
|
-
for (const
|
|
2361
|
-
|
|
2362
|
-
callback(newState);
|
|
2363
|
-
}
|
|
2457
|
+
for (const listener of [...this.listeners]) {
|
|
2458
|
+
listener(newState);
|
|
2364
2459
|
}
|
|
2365
2460
|
void this.emitTelemetry("pie-tool-playback-state-changed", {
|
|
2366
2461
|
toolId: "textToSpeech",
|
|
2367
|
-
providerId: "tts",
|
|
2368
2462
|
previousState,
|
|
2369
2463
|
state: newState,
|
|
2370
2464
|
});
|
|
@@ -2374,7 +2468,6 @@ export class TTSService {
|
|
|
2374
2468
|
: "pie-tool-playback-start";
|
|
2375
2469
|
void this.emitTelemetry(eventName, {
|
|
2376
2470
|
toolId: "textToSpeech",
|
|
2377
|
-
providerId: "tts",
|
|
2378
2471
|
previousState,
|
|
2379
2472
|
state: newState,
|
|
2380
2473
|
});
|
|
@@ -2383,7 +2476,6 @@ export class TTSService {
|
|
|
2383
2476
|
if (newState === PlaybackState.PAUSED) {
|
|
2384
2477
|
void this.emitTelemetry("pie-tool-playback-pause", {
|
|
2385
2478
|
toolId: "textToSpeech",
|
|
2386
|
-
providerId: "tts",
|
|
2387
2479
|
previousState,
|
|
2388
2480
|
state: newState,
|
|
2389
2481
|
});
|
|
@@ -2392,7 +2484,6 @@ export class TTSService {
|
|
|
2392
2484
|
if (newState === PlaybackState.ERROR) {
|
|
2393
2485
|
void this.emitTelemetry("pie-tool-playback-error", {
|
|
2394
2486
|
toolId: "textToSpeech",
|
|
2395
|
-
providerId: "tts",
|
|
2396
2487
|
previousState,
|
|
2397
2488
|
state: newState,
|
|
2398
2489
|
message: this.lastError || undefined,
|
|
@@ -2405,7 +2496,6 @@ export class TTSService {
|
|
|
2405
2496
|
previousState === PlaybackState.LOADING)) {
|
|
2406
2497
|
void this.emitTelemetry("pie-tool-playback-stop", {
|
|
2407
2498
|
toolId: "textToSpeech",
|
|
2408
|
-
providerId: "tts",
|
|
2409
2499
|
previousState,
|
|
2410
2500
|
state: newState,
|
|
2411
2501
|
});
|