@pie-players/pie-assessment-toolkit 0.3.73 → 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 +469 -318
- package/dist/attempt/AssessmentSession.d.ts +1 -5
- package/dist/attempt/AssessmentSession.js +0 -29
- 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.d.ts +1 -0
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
- package/dist/components/PieAssessmentToolkit.custom-element.js +13 -13
- package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
- package/dist/components/chunks/ItemToolBar-97hx60z4.js +2 -0
- 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 +32 -6
- package/dist/index.d.ts +23 -48
- package/dist/index.js +12 -37
- package/dist/policy/core/PolicySource.d.ts +1 -1
- package/dist/policy/core/ToolPolicyEngine.d.ts +85 -34
- package/dist/policy/core/ToolPolicyEngine.js +148 -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 +14 -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 +28 -10
- package/dist/runtime/tool-host-contract.js +31 -10
- 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 +22 -28
- package/dist/services/HighlightCoordinator.js +431 -327
- package/dist/services/RangeSerializer.d.ts +10 -1
- package/dist/services/RangeSerializer.js +87 -14
- package/dist/services/TTSService.d.ts +130 -69
- package/dist/services/TTSService.js +713 -570
- package/dist/services/ToolCoordinator.d.ts +43 -66
- package/dist/services/ToolCoordinator.js +164 -208
- package/dist/services/ToolRegistry.d.ts +78 -54
- package/dist/services/ToolRegistry.js +107 -105
- package/dist/services/ToolkitCoordinator.d.ts +261 -175
- package/dist/services/ToolkitCoordinator.js +667 -433
- package/dist/services/audio-handoff.d.ts +6 -7
- package/dist/services/audio-handoff.js +20 -14
- package/dist/services/catalog-owner.d.ts +11 -1
- package/dist/services/catalog-owner.js +25 -0
- package/dist/services/create-tools-config.d.ts +2 -2
- 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 +30 -8
- package/dist/services/framework-error.js +9 -7
- package/dist/services/interfaces.d.ts +158 -129
- package/dist/services/section-controller-types.d.ts +15 -0
- 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 +1 -3
- 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 +14 -4
- package/dist/services/tool-config-validation.js +63 -71
- package/dist/services/tool-context.d.ts +0 -8
- package/dist/services/tool-context.js +29 -25
- 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 +41 -80
- package/dist/services/tool-providers/TTSToolProvider.js +81 -40
- package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
- package/dist/services/tool-providers/ToolProviderRegistry.d.ts +32 -28
- package/dist/services/tool-providers/ToolProviderRegistry.js +121 -107
- package/dist/services/tool-providers/index.d.ts +0 -9
- package/dist/services/tool-providers/index.js +2 -4
- 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 +37 -23
- 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/catalog-span-alignment.d.ts +2 -17
- package/dist/services/tts/catalog-span-alignment.js +17 -142
- 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/generated-speech/types.d.ts +0 -12
- package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +38 -5
- package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
- 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-alignment/index.d.ts +0 -5
- package/dist/services/tts/math-alignment/index.js +5 -25
- package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
- package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
- package/dist/services/tts/math-alignment/types.d.ts +4 -2
- package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
- package/dist/services/tts/math-aware-text-processing.js +74 -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 +16 -1
- package/dist/services/tts/math-speech.js +55 -19
- package/dist/services/tts/provider-options.d.ts +41 -0
- package/dist/services/tts/provider-options.js +1 -0
- package/dist/services/tts/sre-engine.d.ts +7 -0
- package/dist/services/tts/sre-engine.js +11 -0
- package/dist/services/tts/sre-locales.d.ts +14 -0
- package/dist/services/tts/sre-locales.js +57 -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 -1
- package/dist/services/tts/text-processing.js +46 -34
- 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 +44 -18
- package/dist/services/tts-runtime-config.js +22 -93
- 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 +24 -59
- 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-byq80z42.js +0 -40
- package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
- package/dist/context/runtime-context-consumer.d.ts +0 -13
- package/dist/context/runtime-context-consumer.js +0 -51
- 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 -80
- package/dist/runtime/section-runtime-engine-host-context.js +0 -95
- 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/ToolConfigResolver.d.ts +0 -161
- package/dist/services/ToolConfigResolver.js +0 -229
- package/dist/services/catalog-media.d.ts +0 -56
- package/dist/services/catalog-media.js +0 -190
- package/dist/services/createDefaultToolRegistry.d.ts +0 -53
- package/dist/services/createDefaultToolRegistry.js +0 -45
- 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/services/tool-providers/CortexToolProvider.d.ts +0 -13
- package/dist/services/tool-providers/CortexToolProvider.js +0 -32
- package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
- package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
- package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
- package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
- package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
- package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
- package/dist/services/tts/visual-line-ranges.d.ts +0 -18
- package/dist/services/tts/visual-line-ranges.js +0 -226
- package/dist/tools/client.d.ts +0 -12
- package/dist/tools/client.js +0 -14
- package/dist/tools/index.d.ts +0 -8
- package/dist/tools/index.js +0 -10
- package/dist/tools/internal.d.ts +0 -43
- package/dist/tools/internal.js +0 -41
- package/dist/tools/response-discovery.d.ts +0 -61
- package/dist/tools/response-discovery.js +0 -113
- package/dist/tools/types.d.ts +0 -337
- package/dist/tools/types.js +0 -12
- package/dist/tools/variant-resolver.d.ts +0 -41
- package/dist/tools/variant-resolver.js +0 -207
|
@@ -15,19 +15,26 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Part of PIE Assessment Toolkit.
|
|
17
17
|
*/
|
|
18
|
-
import { applyMediaFragment, enforceMediaFragment } from "
|
|
19
|
-
import {
|
|
18
|
+
import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
|
|
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,14 +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
|
-
currentSeekSegmentIndex = 0;
|
|
99
132
|
activeSentenceStartOffset = null;
|
|
100
133
|
playbackStartDeferredRunId = null;
|
|
101
134
|
pendingPlaybackStartHighlights = [];
|
|
102
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;
|
|
103
140
|
playbackRateWriteQueue = Promise.resolve();
|
|
104
141
|
playbackRateRequestId = 0;
|
|
105
142
|
pendingPlaybackRateRequest = null;
|
|
@@ -111,32 +148,7 @@ export class TTSService {
|
|
|
111
148
|
// Caches DOM-free spoken text keyed by SRE settings and MathML source across
|
|
112
149
|
// utterances within this service instance.
|
|
113
150
|
generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
|
|
114
|
-
// Verbose alignment/highlight tracing is opt-in: it is expensive (large
|
|
115
|
-
// objects, a full text diff per utterance) and noisy in production. Enable
|
|
116
|
-
// with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
117
|
-
static debugEnabled = (() => {
|
|
118
|
-
try {
|
|
119
|
-
if (typeof process !== "undefined" &&
|
|
120
|
-
process.env?.PIE_TTS_DEBUG === "1") {
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
// `process` is not defined in the browser; fall through to the global.
|
|
126
|
-
}
|
|
127
|
-
return (globalThis.__PIE_TTS_DEBUG__ === true);
|
|
128
|
-
})();
|
|
129
151
|
constructor() { }
|
|
130
|
-
debugLog(message, detail) {
|
|
131
|
-
if (!TTSService.debugEnabled)
|
|
132
|
-
return;
|
|
133
|
-
if (detail === undefined) {
|
|
134
|
-
console.debug(message);
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
console.debug(message, detail);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
152
|
clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
|
|
141
153
|
cleanup?.();
|
|
142
154
|
if (this.playbackStartBarrierCleanup === cleanup) {
|
|
@@ -148,7 +160,12 @@ export class TTSService {
|
|
|
148
160
|
runId !== this.speakRunId) {
|
|
149
161
|
return;
|
|
150
162
|
}
|
|
151
|
-
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();
|
|
152
169
|
const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
|
|
153
170
|
for (const applyHighlight of pendingHighlights) {
|
|
154
171
|
applyHighlight();
|
|
@@ -192,6 +209,82 @@ export class TTSService {
|
|
|
192
209
|
}
|
|
193
210
|
callback();
|
|
194
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
|
+
}
|
|
195
288
|
async emitTelemetry(eventName, payload) {
|
|
196
289
|
try {
|
|
197
290
|
await this.telemetryReporter?.(eventName, payload);
|
|
@@ -207,72 +300,39 @@ export class TTSService {
|
|
|
207
300
|
* @param config Provider configuration
|
|
208
301
|
*/
|
|
209
302
|
async initialize(provider, config = {}) {
|
|
303
|
+
if (this.disposed)
|
|
304
|
+
throw new Error("TTS service disposed");
|
|
210
305
|
this.currentProvider = provider;
|
|
211
306
|
this.ttsConfig = { ...config };
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
: null;
|
|
219
|
-
// Initialize provider and get implementation. Browser fallback is only
|
|
220
|
-
// allowed here, before the configured provider has successfully become
|
|
221
|
-
// the active runtime. Per-call playback/synthesis errors must surface
|
|
222
|
-
// instead of silently replacing a user-selected provider and voice.
|
|
223
|
-
try {
|
|
224
|
-
this.provider = await provider.initialize(config);
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
if (provider.providerId.toLowerCase() === "browser") {
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
if (!this.isBrowserSpeechFallbackAvailable()) {
|
|
231
|
-
throw error;
|
|
232
|
-
}
|
|
233
|
-
const switched = await this.switchProviderToBrowser(error, {
|
|
234
|
-
operation: "tts-initialize",
|
|
235
|
-
});
|
|
236
|
-
if (!switched) {
|
|
237
|
-
throw error;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
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);
|
|
240
313
|
}
|
|
241
314
|
/**
|
|
242
|
-
* Update
|
|
243
|
-
*
|
|
244
|
-
* This allows changing rate, pitch, and voice on the fly.
|
|
245
|
-
* Note: Some providers may require reinitialization for voice changes.
|
|
246
|
-
*
|
|
247
|
-
* @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.
|
|
248
317
|
*/
|
|
249
318
|
async updateSettings(settings) {
|
|
250
319
|
if (!this.provider) {
|
|
251
320
|
throw new Error("TTSService not initialized. Call initialize() first.");
|
|
252
321
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
await provider.updateSettings(mergedSettings);
|
|
268
|
-
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
// Fallback: Reinitialize with merged config
|
|
272
|
-
// This requires storing the original config, which we don't have
|
|
273
|
-
// So for now, just log a warning
|
|
274
|
-
console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
|
|
275
|
-
}
|
|
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);
|
|
276
336
|
}
|
|
277
337
|
/**
|
|
278
338
|
* Set highlight coordinator for word highlighting
|
|
@@ -280,6 +340,29 @@ export class TTSService {
|
|
|
280
340
|
setHighlightCoordinator(coordinator) {
|
|
281
341
|
this.highlightCoordinator = coordinator;
|
|
282
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
|
+
}
|
|
283
366
|
/**
|
|
284
367
|
* Set a late-bound provider for optional host TTS highlight target remapping.
|
|
285
368
|
*
|
|
@@ -314,31 +397,6 @@ export class TTSService {
|
|
|
314
397
|
setCatalogResolver(resolver) {
|
|
315
398
|
this.catalogResolver = resolver;
|
|
316
399
|
}
|
|
317
|
-
/**
|
|
318
|
-
* Whether a catalog holds spoken content this service could actually speak.
|
|
319
|
-
*
|
|
320
|
-
* Exists because `data-catalog-idref` is one attribute with several readers:
|
|
321
|
-
* a node can be docked to a catalog that carries only a signing card, and the
|
|
322
|
-
* nearest docked ancestor of a selection is therefore not necessarily the one
|
|
323
|
-
* holding its authored SSML. A caller resolving a selection climbs ancestors
|
|
324
|
-
* and asks this before settling on an id — see the TTS tool's
|
|
325
|
-
* `findSpokenCatalogId`. Without it, a signing card docked on an inner node
|
|
326
|
-
* silently shadows authored speech on an outer one.
|
|
327
|
-
*/
|
|
328
|
-
hasSpokenAlternate(catalogId, language = "en-US") {
|
|
329
|
-
if (!catalogId || !this.catalogResolver)
|
|
330
|
-
return false;
|
|
331
|
-
const resolved = this.catalogResolver.getAlternative(catalogId, {
|
|
332
|
-
type: "spoken",
|
|
333
|
-
language,
|
|
334
|
-
useFallback: true,
|
|
335
|
-
// The string form specifically. A node may also carry a recording of the
|
|
336
|
-
// same script; that one is not what this reports on, and card order must
|
|
337
|
-
// not decide the answer.
|
|
338
|
-
form: "content",
|
|
339
|
-
});
|
|
340
|
-
return resolved?.content !== undefined;
|
|
341
|
-
}
|
|
342
400
|
getHighlightResolverRuntime() {
|
|
343
401
|
let provided = null;
|
|
344
402
|
try {
|
|
@@ -365,12 +423,12 @@ export class TTSService {
|
|
|
365
423
|
return null;
|
|
366
424
|
return node.nodeType === Node.ELEMENT_NODE
|
|
367
425
|
? node
|
|
368
|
-
: node
|
|
426
|
+
: composedParentElement(node);
|
|
369
427
|
}
|
|
370
428
|
isElementWithinScope(element, scope) {
|
|
371
429
|
if (!scope)
|
|
372
430
|
return true;
|
|
373
|
-
return
|
|
431
|
+
return composedContains(scope, element);
|
|
374
432
|
}
|
|
375
433
|
isRangeWithinScope(range, scope) {
|
|
376
434
|
const startElement = this.getNodeElement(range.startContainer);
|
|
@@ -445,28 +503,21 @@ export class TTSService {
|
|
|
445
503
|
return null;
|
|
446
504
|
}
|
|
447
505
|
}
|
|
448
|
-
|
|
449
|
-
|
|
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)
|
|
450
513
|
return;
|
|
451
|
-
|
|
452
|
-
if (range.startContainer === range.endContainer &&
|
|
453
|
-
range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
454
|
-
this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
catch {
|
|
459
|
-
// Fall through to generic range painting for lightweight test doubles.
|
|
460
|
-
}
|
|
461
|
-
this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
|
|
514
|
+
this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
|
|
462
515
|
}
|
|
463
516
|
paintTTSSentenceRanges(nativeRanges) {
|
|
464
517
|
if (!this.highlightCoordinator)
|
|
465
518
|
return;
|
|
466
519
|
const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
|
|
467
|
-
if (elements.length > 0 &&
|
|
468
|
-
ranges.length === 0 &&
|
|
469
|
-
this.highlightCoordinator.highlightTTSSentenceElements) {
|
|
520
|
+
if (elements.length > 0 && ranges.length === 0) {
|
|
470
521
|
this.highlightCoordinator.highlightTTSSentenceElements(elements);
|
|
471
522
|
return;
|
|
472
523
|
}
|
|
@@ -484,36 +535,119 @@ export class TTSService {
|
|
|
484
535
|
getCapabilities() {
|
|
485
536
|
return this.currentProvider?.getCapabilities() || null;
|
|
486
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
|
+
}
|
|
487
605
|
/**
|
|
488
606
|
* Build a character-by-character map from normalized text positions to DOM positions
|
|
489
607
|
* This handles the complexity of whitespace normalization
|
|
490
608
|
*/
|
|
491
|
-
getTextProcessingOptions(
|
|
492
|
-
const
|
|
493
|
-
const textNormalization = (providerOptions.textNormalization ||
|
|
494
|
-
{});
|
|
495
|
-
const mode = textNormalization.boundarySpacingMode;
|
|
609
|
+
getTextProcessingOptions(locale) {
|
|
610
|
+
const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
|
|
496
611
|
const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
|
|
497
612
|
? mode
|
|
498
613
|
: undefined;
|
|
499
|
-
const locale = language ||
|
|
500
|
-
(typeof textNormalization.locale === "string"
|
|
501
|
-
? textNormalization.locale
|
|
502
|
-
: undefined);
|
|
503
614
|
return { locale, boundarySpacingMode };
|
|
504
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
|
+
*/
|
|
505
637
|
getMathSpeechOptions() {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
638
|
+
if (this.mathSpeechSource) {
|
|
639
|
+
return normalizeSREMathSpeechOptions(this.mathSpeechSource());
|
|
640
|
+
}
|
|
641
|
+
return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
|
|
509
642
|
}
|
|
510
643
|
buildPositionMap(element, spokenText, language) {
|
|
511
644
|
this.normalizedToDOM.clear();
|
|
512
645
|
const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
|
|
513
646
|
this.normalizedToDOM = map;
|
|
514
|
-
|
|
647
|
+
// The comparison below diffs the whole text, so it runs only when traced.
|
|
648
|
+
if (!isTtsDebugEnabled())
|
|
515
649
|
return;
|
|
516
|
-
|
|
650
|
+
logger.debug("Text comparison:", {
|
|
517
651
|
spokenLength: spokenText.length,
|
|
518
652
|
normalizedDomLength: normalizedDomText.length,
|
|
519
653
|
match: spokenText === normalizedDomText,
|
|
@@ -536,7 +670,7 @@ export class TTSService {
|
|
|
536
670
|
return null;
|
|
537
671
|
})(),
|
|
538
672
|
});
|
|
539
|
-
|
|
673
|
+
logger.debug("Position map built:", {
|
|
540
674
|
entries: this.normalizedToDOM.size,
|
|
541
675
|
spokenTextLength: spokenText.length,
|
|
542
676
|
normalizedDomLength: normalizedDomText.length,
|
|
@@ -552,9 +686,8 @@ export class TTSService {
|
|
|
552
686
|
});
|
|
553
687
|
}
|
|
554
688
|
getStructuralPauseProfile() {
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
const customUnits = (custom.units || {});
|
|
689
|
+
const custom = this.providerOptions().structuralPauses || {};
|
|
690
|
+
const customUnits = custom.units || {};
|
|
558
691
|
const toNumber = (value, fallback) => {
|
|
559
692
|
if (typeof value === "number" && Number.isFinite(value))
|
|
560
693
|
return value;
|
|
@@ -582,17 +715,15 @@ export class TTSService {
|
|
|
582
715
|
return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
|
|
583
716
|
}
|
|
584
717
|
resolveHighlightMode() {
|
|
585
|
-
const
|
|
586
|
-
const configuredMode = providerOptions.highlightMode;
|
|
718
|
+
const configuredMode = this.providerOptions().highlightMode;
|
|
587
719
|
if (configuredMode === "word" || configuredMode === "sentence") {
|
|
588
720
|
return configuredMode;
|
|
589
721
|
}
|
|
590
|
-
const
|
|
591
|
-
if (
|
|
592
|
-
return
|
|
722
|
+
const capabilities = this.currentProvider?.getCapabilities();
|
|
723
|
+
if (capabilities?.defaultHighlightMode) {
|
|
724
|
+
return capabilities.defaultHighlightMode;
|
|
593
725
|
}
|
|
594
|
-
|
|
595
|
-
return supportsWordBoundary ? "word" : "sentence";
|
|
726
|
+
return capabilities?.supportsWordBoundary ? "word" : "sentence";
|
|
596
727
|
}
|
|
597
728
|
hasExplicitBreakSemantics(text) {
|
|
598
729
|
if (!text.includes("<"))
|
|
@@ -632,7 +763,7 @@ export class TTSService {
|
|
|
632
763
|
return "minor";
|
|
633
764
|
}
|
|
634
765
|
getBoundaryAnchor(textNode, root) {
|
|
635
|
-
let current = textNode
|
|
766
|
+
let current = flatTreeParentElement(textNode);
|
|
636
767
|
let best = null;
|
|
637
768
|
while (current && current !== root) {
|
|
638
769
|
if (this.isElementHidden(current))
|
|
@@ -665,7 +796,7 @@ export class TTSService {
|
|
|
665
796
|
}
|
|
666
797
|
}
|
|
667
798
|
}
|
|
668
|
-
current = current
|
|
799
|
+
current = flatTreeParentElement(current);
|
|
669
800
|
}
|
|
670
801
|
if (!best)
|
|
671
802
|
return null;
|
|
@@ -675,20 +806,31 @@ export class TTSService {
|
|
|
675
806
|
units: this.getStructuralPauseProfile().units[strength],
|
|
676
807
|
};
|
|
677
808
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
809
|
+
/**
|
|
810
|
+
* `textOffset` is where `normalizedText` starts in the element's visible text:
|
|
811
|
+
* boundaries are collected over the whole element, and a selection speaks only
|
|
812
|
+
* part of it.
|
|
813
|
+
*/
|
|
814
|
+
createSpeechPlan(contentElement, normalizedText, textOffset, language) {
|
|
815
|
+
const boundaries = this.collectSpeechPlanBoundaries(contentElement, normalizedText, language);
|
|
816
|
+
if (textOffset === 0) {
|
|
817
|
+
return this.createSpeechPlanSegments(normalizedText, boundaries);
|
|
818
|
+
}
|
|
819
|
+
const shifted = new Map();
|
|
820
|
+
for (const [point, units] of boundaries) {
|
|
821
|
+
shifted.set(point - textOffset, units);
|
|
822
|
+
}
|
|
823
|
+
return this.createSpeechPlanSegments(normalizedText, shifted);
|
|
681
824
|
}
|
|
682
|
-
collectSpeechPlanBoundaries(contentElement, normalizedText) {
|
|
825
|
+
collectSpeechPlanBoundaries(contentElement, normalizedText, language) {
|
|
683
826
|
const boundaries = new Map();
|
|
684
|
-
|
|
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));
|
|
685
830
|
const nodeStartOffsets = this.createNodeStartOffsets(map);
|
|
686
|
-
const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
|
|
687
|
-
let currentNode = walker.nextNode();
|
|
688
831
|
let previousBoundaryAnchor = null;
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
const parent = textNode.parentElement;
|
|
832
|
+
for (const textNode of flatTreeTextNodes(contentElement)) {
|
|
833
|
+
const parent = flatTreeParentElement(textNode);
|
|
692
834
|
if (parent &&
|
|
693
835
|
!this.isElementHidden(parent) &&
|
|
694
836
|
!isNodeSuppressedForTTS(textNode)) {
|
|
@@ -702,7 +844,6 @@ export class TTSService {
|
|
|
702
844
|
previousBoundaryAnchor = boundary.anchor;
|
|
703
845
|
}
|
|
704
846
|
}
|
|
705
|
-
currentNode = walker.nextNode();
|
|
706
847
|
}
|
|
707
848
|
return boundaries;
|
|
708
849
|
}
|
|
@@ -750,9 +891,7 @@ export class TTSService {
|
|
|
750
891
|
return segments.filter((segment) => segment.text.trim().length > 0);
|
|
751
892
|
}
|
|
752
893
|
segmentSentences(text) {
|
|
753
|
-
|
|
754
|
-
?.locale || undefined;
|
|
755
|
-
return segmentTextToSentences(text, { locale });
|
|
894
|
+
return segmentTextToSentences(text, { locale: this.readLocale });
|
|
756
895
|
}
|
|
757
896
|
createSeekSegmentsFromText(text) {
|
|
758
897
|
return this.segmentSentences(text)
|
|
@@ -852,17 +991,20 @@ export class TTSService {
|
|
|
852
991
|
const providerWithPlan = this.provider;
|
|
853
992
|
if (!shouldTrackSentenceProgress &&
|
|
854
993
|
typeof providerWithPlan.speakSegments === "function") {
|
|
994
|
+
await this.waitWhilePaused(runId);
|
|
995
|
+
if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
855
998
|
this.currentBoundaryOffset = 0;
|
|
856
999
|
const originalOnWordBoundary = this.provider.onWordBoundary;
|
|
857
1000
|
this.provider.onWordBoundary = (word, position, length) => {
|
|
858
1001
|
originalOnWordBoundary?.(word, position, length);
|
|
859
1002
|
if (Number.isFinite(position)) {
|
|
860
1003
|
this.currentBoundaryOffset = position;
|
|
861
|
-
this.currentSeekSegmentIndex = this.getCurrentSeekSegmentIndex();
|
|
862
1004
|
}
|
|
863
1005
|
};
|
|
864
1006
|
try {
|
|
865
|
-
await providerWithPlan.speakSegments(segments);
|
|
1007
|
+
await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
|
|
866
1008
|
}
|
|
867
1009
|
finally {
|
|
868
1010
|
if (runId === this.speakRunId && this.provider === providerWithPlan) {
|
|
@@ -876,39 +1018,43 @@ export class TTSService {
|
|
|
876
1018
|
wordBoundaryOffset: this.activeWordBoundaryOffset,
|
|
877
1019
|
});
|
|
878
1020
|
for (const segment of segments) {
|
|
1021
|
+
await this.waitWhilePaused(runId);
|
|
879
1022
|
if (runId !== this.speakRunId)
|
|
880
1023
|
return;
|
|
881
|
-
const seekIndex = this.seekSegments.findIndex((candidate) => candidate.startOffset === segment.startOffset);
|
|
882
|
-
if (seekIndex >= 0) {
|
|
883
|
-
this.currentSeekSegmentIndex = seekIndex;
|
|
884
|
-
}
|
|
885
1024
|
this.currentBoundaryOffset = segment.startOffset;
|
|
886
1025
|
if (shouldTrackSentenceProgress) {
|
|
887
1026
|
this.runWhenPlaybackStarts(runId, () => {
|
|
888
1027
|
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
889
1028
|
});
|
|
890
1029
|
}
|
|
891
|
-
await this.
|
|
1030
|
+
await this.speakWithinLimit(segment.text, runId);
|
|
892
1031
|
const pauseMs = segment.pauseMsAfter ?? 0;
|
|
893
|
-
if (pauseMs > 0) {
|
|
894
|
-
await
|
|
1032
|
+
if (pauseMs > 0 && runId === this.speakRunId) {
|
|
1033
|
+
await this.waitStructuralPause(pauseMs);
|
|
895
1034
|
}
|
|
896
1035
|
}
|
|
897
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* `startOffset` indexes the spoken text; the position map indexes the whole
|
|
1039
|
+
* content element, which for a selection starts `activeWordBoundaryOffset`
|
|
1040
|
+
* characters earlier.
|
|
1041
|
+
*/
|
|
898
1042
|
highlightSentenceSegment(startOffset, text) {
|
|
899
1043
|
if (!this.highlightCoordinator || typeof document === "undefined")
|
|
900
1044
|
return;
|
|
901
1045
|
const length = text.trimEnd().length;
|
|
902
1046
|
if (length <= 0)
|
|
903
1047
|
return;
|
|
904
|
-
const
|
|
905
|
-
const
|
|
1048
|
+
const mapStart = startOffset + this.activeWordBoundaryOffset;
|
|
1049
|
+
const start = this.normalizedToDOM.get(mapStart);
|
|
1050
|
+
const end = this.normalizedToDOM.get(mapStart + length - 1);
|
|
906
1051
|
if (!start || !end)
|
|
907
1052
|
return;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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);
|
|
912
1058
|
this.activeSentenceStartOffset = startOffset;
|
|
913
1059
|
}
|
|
914
1060
|
getSegmentIndexForOffset(segments, offset) {
|
|
@@ -940,66 +1086,145 @@ export class TTSService {
|
|
|
940
1086
|
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
941
1087
|
}
|
|
942
1088
|
/**
|
|
943
|
-
*
|
|
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.
|
|
944
1093
|
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
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.
|
|
948
1097
|
*/
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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.");
|
|
962
1150
|
return null;
|
|
963
1151
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
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
|
+
}
|
|
973
1181
|
return null;
|
|
974
1182
|
}
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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;
|
|
984
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);
|
|
1204
|
+
}
|
|
1205
|
+
logger.debug("selection offset calculation:", {
|
|
1206
|
+
selectedText: text,
|
|
1207
|
+
mapped: !!mapped,
|
|
1208
|
+
offset,
|
|
1209
|
+
rootTag: root.tagName,
|
|
1210
|
+
});
|
|
985
1211
|
return {
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
+
},
|
|
989
1225
|
};
|
|
990
1226
|
}
|
|
991
|
-
|
|
992
|
-
* Speak text with optional catalog support
|
|
993
|
-
*
|
|
994
|
-
* @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
|
|
995
|
-
* @param options Optional catalog ID, language, and content element for highlighting
|
|
996
|
-
*/
|
|
997
|
-
async speak(text, options) {
|
|
998
|
-
if (!this.provider) {
|
|
999
|
-
throw new Error("TTS service not initialized");
|
|
1000
|
-
}
|
|
1001
|
-
const runId = ++this.speakRunId;
|
|
1002
|
-
this.clearPlaybackStartBarrier();
|
|
1227
|
+
async speakContent(text, options, runId) {
|
|
1003
1228
|
let playbackStartBarrier = null;
|
|
1004
1229
|
try {
|
|
1005
1230
|
await this.applyLanguageSettings(options);
|
|
@@ -1015,8 +1240,9 @@ export class TTSService {
|
|
|
1015
1240
|
catalogId: options?.catalogId,
|
|
1016
1241
|
});
|
|
1017
1242
|
this.initializeSpeakTracking(highlightText, options);
|
|
1018
|
-
const highlightMode =
|
|
1019
|
-
|
|
1243
|
+
const highlightMode = speechMatchesVisibleText
|
|
1244
|
+
? this.resolveHighlightMode()
|
|
1245
|
+
: "sentence";
|
|
1020
1246
|
this.activeHighlightMode = highlightMode;
|
|
1021
1247
|
this.activeWordBoundaryOffset = options?.wordBoundaryOffset || 0;
|
|
1022
1248
|
const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
|
|
@@ -1030,7 +1256,7 @@ export class TTSService {
|
|
|
1030
1256
|
: hasExplicitBreaks || !speechMatchesVisibleText
|
|
1031
1257
|
? []
|
|
1032
1258
|
: shouldUsePlan && this.currentContentElement
|
|
1033
|
-
? this.createSpeechPlan(this.currentContentElement, normalizedText)
|
|
1259
|
+
? this.createSpeechPlan(this.currentContentElement, normalizedText, options?.wordBoundaryOffset || 0, options?.language)
|
|
1034
1260
|
: this.createSeekSegmentsFromText(highlightText);
|
|
1035
1261
|
this.sentenceHighlightSegments = hasExplicitBreaks
|
|
1036
1262
|
? []
|
|
@@ -1039,7 +1265,6 @@ export class TTSService {
|
|
|
1039
1265
|
shouldUsePlan,
|
|
1040
1266
|
playbackSegments: this.seekSegments,
|
|
1041
1267
|
});
|
|
1042
|
-
this.setState(PlaybackState.LOADING);
|
|
1043
1268
|
playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
|
|
1044
1269
|
this.prepareHighlightsForSpeak({
|
|
1045
1270
|
contentToSpeak: highlightText,
|
|
@@ -1057,9 +1282,8 @@ export class TTSService {
|
|
|
1057
1282
|
else {
|
|
1058
1283
|
this.clearWordBoundaryHighlighting();
|
|
1059
1284
|
}
|
|
1060
|
-
if (!playbackStartBarrier)
|
|
1061
|
-
this.
|
|
1062
|
-
}
|
|
1285
|
+
if (!playbackStartBarrier)
|
|
1286
|
+
this.markPlaying();
|
|
1063
1287
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1064
1288
|
await this.executeSpeakPlayback({
|
|
1065
1289
|
shouldUsePlan,
|
|
@@ -1074,146 +1298,68 @@ export class TTSService {
|
|
|
1074
1298
|
this.clearHighlightsAndTracking();
|
|
1075
1299
|
}
|
|
1076
1300
|
catch (error) {
|
|
1077
|
-
|
|
1078
|
-
console.error("TTS error:", finalError);
|
|
1079
|
-
this.lastError =
|
|
1080
|
-
finalError instanceof Error ? finalError.message : String(finalError);
|
|
1301
|
+
// A superseded run's failure is the abort that superseded it.
|
|
1081
1302
|
if (runId !== this.speakRunId)
|
|
1082
1303
|
return;
|
|
1304
|
+
console.error("TTS error:", error);
|
|
1305
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1083
1306
|
this.setState(PlaybackState.ERROR);
|
|
1084
1307
|
this.clearHighlightsAndTracking();
|
|
1085
|
-
throw
|
|
1308
|
+
throw error;
|
|
1086
1309
|
}
|
|
1087
1310
|
finally {
|
|
1088
1311
|
this.clearPlaybackStartBarrier(playbackStartBarrier);
|
|
1089
1312
|
}
|
|
1090
1313
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
// rejected initialization. Carry only settings whose meaning is portable
|
|
1098
|
-
// when crossing the provider boundary; otherwise a Polly/Google/SC voice
|
|
1099
|
-
// such as "Joanna" can incorrectly become an explicit Browser voice.
|
|
1100
|
-
const browserConfig = {};
|
|
1101
|
-
if (this.ttsConfig.rate !== undefined) {
|
|
1102
|
-
browserConfig.rate = this.ttsConfig.rate;
|
|
1103
|
-
}
|
|
1104
|
-
if (this.ttsConfig.pitch !== undefined) {
|
|
1105
|
-
browserConfig.pitch = this.ttsConfig.pitch;
|
|
1106
|
-
}
|
|
1107
|
-
if (this.ttsConfig.mathTokenHighlighting !== undefined) {
|
|
1108
|
-
browserConfig.mathTokenHighlighting =
|
|
1109
|
-
this.ttsConfig.mathTokenHighlighting;
|
|
1110
|
-
}
|
|
1111
|
-
const operation = context?.operation || "tts-initialize";
|
|
1112
|
-
const offendingText = context?.contentToSpeak ?? "";
|
|
1113
|
-
const offendingPreview = offendingText
|
|
1114
|
-
.replace(/\s+/g, " ")
|
|
1115
|
-
.trim()
|
|
1116
|
-
.slice(0, 120);
|
|
1117
|
-
const offendingDiagnostic = offendingText.length > 0
|
|
1118
|
-
? {
|
|
1119
|
-
textLength: offendingText.length,
|
|
1120
|
-
textPreview: offendingPreview.length < offendingText.length
|
|
1121
|
-
? `${offendingPreview}…`
|
|
1122
|
-
: offendingPreview,
|
|
1123
|
-
}
|
|
1124
|
-
: {};
|
|
1125
|
-
try {
|
|
1126
|
-
this.provider?.stop();
|
|
1127
|
-
this.currentProvider.destroy();
|
|
1128
|
-
this.currentProvider = fallbackProvider;
|
|
1129
|
-
this.provider = await fallbackProvider.initialize(browserConfig);
|
|
1130
|
-
this.ttsConfig = browserConfig;
|
|
1131
|
-
await this.emitTelemetry("pie-tool-runtime-fallback", {
|
|
1132
|
-
toolId: "textToSpeech",
|
|
1133
|
-
operation,
|
|
1134
|
-
fromProvider: previousProviderId,
|
|
1135
|
-
toProvider: fallbackProvider.providerId,
|
|
1136
|
-
reason: reason instanceof Error ? reason.message : String(reason),
|
|
1137
|
-
...offendingDiagnostic,
|
|
1138
|
-
});
|
|
1139
|
-
console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
|
|
1140
|
-
fromProvider: previousProviderId,
|
|
1141
|
-
reason,
|
|
1142
|
-
...offendingDiagnostic,
|
|
1143
|
-
});
|
|
1144
|
-
return true;
|
|
1145
|
-
}
|
|
1146
|
-
catch (fallbackError) {
|
|
1147
|
-
await this.emitTelemetry("pie-tool-runtime-fallback-error", {
|
|
1148
|
-
toolId: "textToSpeech",
|
|
1149
|
-
operation,
|
|
1150
|
-
fromProvider: previousProviderId,
|
|
1151
|
-
toProvider: "browser",
|
|
1152
|
-
errorType: "TTSRuntimeFallbackError",
|
|
1153
|
-
message: fallbackError instanceof Error
|
|
1154
|
-
? fallbackError.message
|
|
1155
|
-
: String(fallbackError),
|
|
1156
|
-
});
|
|
1157
|
-
console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
|
|
1158
|
-
return false;
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
isBrowserSpeechFallbackAvailable() {
|
|
1162
|
-
if (typeof window === "undefined")
|
|
1163
|
-
return false;
|
|
1164
|
-
if (!("speechSynthesis" in window))
|
|
1165
|
-
return false;
|
|
1166
|
-
return (typeof globalThis
|
|
1167
|
-
.SpeechSynthesisUtterance === "function");
|
|
1168
|
-
}
|
|
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
|
+
*/
|
|
1169
1320
|
async applyLanguageSettings(options) {
|
|
1170
|
-
if (!
|
|
1321
|
+
if (!this.provider)
|
|
1322
|
+
return;
|
|
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) {
|
|
1171
1336
|
return;
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
{});
|
|
1175
|
-
const segmenter = (providerOptions.segmenter || {});
|
|
1337
|
+
}
|
|
1338
|
+
// Every key is set, undefined included: providers merge options shallowly.
|
|
1176
1339
|
const mergedProviderOptions = {
|
|
1177
1340
|
...providerOptions,
|
|
1178
|
-
locale:
|
|
1341
|
+
locale: next.locale,
|
|
1179
1342
|
textNormalization: {
|
|
1180
|
-
...textNormalization,
|
|
1181
|
-
locale:
|
|
1182
|
-
},
|
|
1183
|
-
segmenter: {
|
|
1184
|
-
...segmenter,
|
|
1185
|
-
locale: options.language,
|
|
1343
|
+
...providerOptions.textNormalization,
|
|
1344
|
+
locale: next.textNormalization,
|
|
1186
1345
|
},
|
|
1346
|
+
segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
|
|
1347
|
+
contentLanguage: named,
|
|
1187
1348
|
};
|
|
1188
1349
|
this.ttsConfig = {
|
|
1189
1350
|
...this.ttsConfig,
|
|
1190
1351
|
providerOptions: mergedProviderOptions,
|
|
1191
1352
|
};
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
|
|
1196
|
-
}
|
|
1353
|
+
await this.provider.updateSettings({
|
|
1354
|
+
providerOptions: mergedProviderOptions,
|
|
1355
|
+
});
|
|
1197
1356
|
}
|
|
1198
1357
|
async resolveSpeechContent(text, options) {
|
|
1199
1358
|
const normalizedInputText = normalizeTextForSpeech(text);
|
|
1200
|
-
if (options
|
|
1201
|
-
return {
|
|
1202
|
-
contentToSpeak: normalizedInputText,
|
|
1203
|
-
speechText: normalizedInputText,
|
|
1204
|
-
visibleText: normalizedInputText,
|
|
1205
|
-
highlightText: normalizedInputText,
|
|
1206
|
-
usedCatalogSpoken: false,
|
|
1207
|
-
speechSource: "dom-or-input",
|
|
1208
|
-
normalizedText: normalizedInputText,
|
|
1209
|
-
containsMathMarkup: false,
|
|
1210
|
-
speechMatchesVisibleText: true,
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
if (options?.catalogId && this.catalogResolver) {
|
|
1359
|
+
if (options.catalogId && this.catalogResolver) {
|
|
1214
1360
|
const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
|
|
1215
1361
|
type: "spoken",
|
|
1216
|
-
language: options.language
|
|
1362
|
+
language: options.language,
|
|
1217
1363
|
useFallback: true,
|
|
1218
1364
|
context: options.catalogContext,
|
|
1219
1365
|
form: "content",
|
|
@@ -1222,11 +1368,9 @@ export class TTSService {
|
|
|
1222
1368
|
// instance. Fall through to generated TTS rather than speaking "".
|
|
1223
1369
|
const spokenText = catalogContent?.content;
|
|
1224
1370
|
if (catalogContent && spokenText !== undefined) {
|
|
1225
|
-
const visibleText = options
|
|
1226
|
-
? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
|
|
1227
|
-
: normalizedInputText;
|
|
1371
|
+
const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
1228
1372
|
const normalizedCatalogText = normalizeTextForSpeech(spokenText);
|
|
1229
|
-
|
|
1373
|
+
logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
|
|
1230
1374
|
return {
|
|
1231
1375
|
contentToSpeak: spokenText,
|
|
1232
1376
|
speechText: spokenText,
|
|
@@ -1239,24 +1383,12 @@ export class TTSService {
|
|
|
1239
1383
|
speechMatchesVisibleText: normalizedCatalogText === visibleText,
|
|
1240
1384
|
};
|
|
1241
1385
|
}
|
|
1242
|
-
|
|
1386
|
+
logger.debug(`No catalog found for "${options.catalogId}", falling back to generated TTS`);
|
|
1243
1387
|
}
|
|
1244
|
-
const composed = options
|
|
1245
|
-
? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
|
|
1246
|
-
: null;
|
|
1388
|
+
const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
|
|
1247
1389
|
if (composed)
|
|
1248
1390
|
return composed;
|
|
1249
|
-
const generated = options
|
|
1250
|
-
? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
|
|
1251
|
-
: {
|
|
1252
|
-
contentToSpeak: normalizedInputText,
|
|
1253
|
-
speechText: normalizedInputText,
|
|
1254
|
-
visibleText: normalizedInputText,
|
|
1255
|
-
highlightText: normalizedInputText,
|
|
1256
|
-
normalizedText: normalizedInputText,
|
|
1257
|
-
containsMathMarkup: false,
|
|
1258
|
-
speechMatchesVisibleText: true,
|
|
1259
|
-
};
|
|
1391
|
+
const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
|
|
1260
1392
|
return {
|
|
1261
1393
|
...generated,
|
|
1262
1394
|
usedCatalogSpoken: false,
|
|
@@ -1270,7 +1402,11 @@ export class TTSService {
|
|
|
1270
1402
|
if (!chunks.some((chunk) => chunk.sourceElement))
|
|
1271
1403
|
return null;
|
|
1272
1404
|
const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
|
|
1273
|
-
|
|
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;
|
|
1274
1410
|
return {
|
|
1275
1411
|
contentToSpeak: speechText,
|
|
1276
1412
|
speechText,
|
|
@@ -1299,13 +1435,22 @@ export class TTSService {
|
|
|
1299
1435
|
speechMatchesVisibleText: true,
|
|
1300
1436
|
});
|
|
1301
1437
|
};
|
|
1438
|
+
const range = options.contentRange;
|
|
1302
1439
|
const resolveCatalog = (element) => {
|
|
1303
1440
|
const catalogIdRef = element.getAttribute("data-catalog-idref");
|
|
1304
1441
|
if (!catalogIdRef)
|
|
1305
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
|
+
}
|
|
1306
1449
|
const lookup = {
|
|
1307
1450
|
type: "spoken",
|
|
1308
|
-
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,
|
|
1309
1454
|
useFallback: true,
|
|
1310
1455
|
context: options.catalogContext,
|
|
1311
1456
|
};
|
|
@@ -1342,7 +1487,7 @@ export class TTSService {
|
|
|
1342
1487
|
: null;
|
|
1343
1488
|
};
|
|
1344
1489
|
const getMathElementsForAlignment = (element) => {
|
|
1345
|
-
const mathElements =
|
|
1490
|
+
const mathElements = flatQuerySelectorAll(element, "math");
|
|
1346
1491
|
if (element.localName?.toLowerCase() === "math") {
|
|
1347
1492
|
return [element, ...mathElements];
|
|
1348
1493
|
}
|
|
@@ -1355,7 +1500,12 @@ export class TTSService {
|
|
|
1355
1500
|
if (isNodeExcludedFromSpeech(node, root))
|
|
1356
1501
|
return;
|
|
1357
1502
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
1358
|
-
|
|
1503
|
+
if (!range) {
|
|
1504
|
+
textBuffer += ` ${node.textContent || ""}`;
|
|
1505
|
+
}
|
|
1506
|
+
else if (rangeIntersectsComposedNode(range, node)) {
|
|
1507
|
+
textBuffer += ` ${textInRange(node, range)}`;
|
|
1508
|
+
}
|
|
1359
1509
|
return;
|
|
1360
1510
|
}
|
|
1361
1511
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
@@ -1366,7 +1516,7 @@ export class TTSService {
|
|
|
1366
1516
|
flushTextBuffer();
|
|
1367
1517
|
const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
|
|
1368
1518
|
const visibleText = collectedVisible.visibleText ||
|
|
1369
|
-
normalizeTextForSpeech(element
|
|
1519
|
+
normalizeTextForSpeech(flatTextContent(element));
|
|
1370
1520
|
const regionElement = resolveReadableRegion(element, root);
|
|
1371
1521
|
// The script chunk, when there is a script. Built exactly as before,
|
|
1372
1522
|
// and it doubles as the recording's fallback: word-level alignment is
|
|
@@ -1434,7 +1584,7 @@ export class TTSService {
|
|
|
1434
1584
|
chunks.push(scriptChunk);
|
|
1435
1585
|
return;
|
|
1436
1586
|
}
|
|
1437
|
-
for (const child of
|
|
1587
|
+
for (const child of flatTreeChildNodes(element)) {
|
|
1438
1588
|
visit(child);
|
|
1439
1589
|
}
|
|
1440
1590
|
};
|
|
@@ -1456,12 +1606,13 @@ export class TTSService {
|
|
|
1456
1606
|
const provider = this.currentProvider;
|
|
1457
1607
|
if (!provider)
|
|
1458
1608
|
return "plain";
|
|
1459
|
-
// Belt-and-suspenders: the browser provider can never voice SSML.
|
|
1460
|
-
if (provider.providerId?.toLowerCase() === "browser")
|
|
1461
|
-
return "plain";
|
|
1462
1609
|
return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
|
|
1463
1610
|
}
|
|
1464
|
-
|
|
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) {
|
|
1465
1616
|
// Delegate to the generated-speech module: a pure plan assembly + SRE
|
|
1466
1617
|
// memoization core, plus a DOM adapter that binds live-DOM anchors and
|
|
1467
1618
|
// emits playback chunks. The aggregate `contentToSpeak` stays plain text
|
|
@@ -1469,14 +1620,19 @@ export class TTSService {
|
|
|
1469
1620
|
const playbackFormat = this.resolveGeneratedPlaybackFormat();
|
|
1470
1621
|
const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
|
|
1471
1622
|
contentRoot: contentElement,
|
|
1623
|
+
range,
|
|
1472
1624
|
language,
|
|
1473
1625
|
mathSpeech: this.getMathSpeechOptions(),
|
|
1474
1626
|
textProcessingOptions: this.getTextProcessingOptions(language),
|
|
1475
1627
|
produceSsml: playbackFormat === "ssml",
|
|
1476
1628
|
resolveMathSpeech: this.generatedMathSpeechResolver,
|
|
1477
1629
|
});
|
|
1478
|
-
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1479
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;
|
|
1480
1636
|
return {
|
|
1481
1637
|
contentToSpeak: visibleText,
|
|
1482
1638
|
speechText: visibleText,
|
|
@@ -1487,6 +1643,7 @@ export class TTSService {
|
|
|
1487
1643
|
speechMatchesVisibleText: true,
|
|
1488
1644
|
};
|
|
1489
1645
|
}
|
|
1646
|
+
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1490
1647
|
const speechChunks = planToCompositionChunkInputs(plan, {
|
|
1491
1648
|
format: playbackFormat,
|
|
1492
1649
|
});
|
|
@@ -1507,7 +1664,7 @@ export class TTSService {
|
|
|
1507
1664
|
.replace(/\s+/g, " ")
|
|
1508
1665
|
.trim()
|
|
1509
1666
|
.slice(0, 200);
|
|
1510
|
-
|
|
1667
|
+
logger.debug("Speak resolved content", {
|
|
1511
1668
|
source: args.speechSource,
|
|
1512
1669
|
catalogId: args.catalogId || null,
|
|
1513
1670
|
length: args.contentToSpeak.length,
|
|
@@ -1516,17 +1673,16 @@ export class TTSService {
|
|
|
1516
1673
|
}
|
|
1517
1674
|
initializeSpeakTracking(contentToSpeak, options) {
|
|
1518
1675
|
this.currentText = contentToSpeak;
|
|
1519
|
-
this.currentContentElement = options
|
|
1676
|
+
this.currentContentElement = options.contentElement;
|
|
1520
1677
|
this.lastError = null;
|
|
1521
1678
|
this.currentBoundaryOffset = 0;
|
|
1522
1679
|
this.playbackChunks = [];
|
|
1523
|
-
this.currentSeekSegmentIndex = 0;
|
|
1524
1680
|
this.activeSentenceStartOffset = null;
|
|
1525
1681
|
}
|
|
1526
1682
|
prepareHighlightsForSpeak(args) {
|
|
1527
1683
|
if (!this.currentContentElement || !this.highlightCoordinator)
|
|
1528
1684
|
return;
|
|
1529
|
-
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options
|
|
1685
|
+
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
|
|
1530
1686
|
if (args.highlightMode === "sentence" && args.shouldUsePlan) {
|
|
1531
1687
|
return;
|
|
1532
1688
|
}
|
|
@@ -1534,7 +1690,7 @@ export class TTSService {
|
|
|
1534
1690
|
this.runWhenPlaybackStarts(args.runId, () => {
|
|
1535
1691
|
if (initialSegment) {
|
|
1536
1692
|
this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
|
|
1537
|
-
|
|
1693
|
+
logger.debug("Applied initial sentence highlighting");
|
|
1538
1694
|
}
|
|
1539
1695
|
else {
|
|
1540
1696
|
try {
|
|
@@ -1549,33 +1705,29 @@ export class TTSService {
|
|
|
1549
1705
|
});
|
|
1550
1706
|
}
|
|
1551
1707
|
configureWordBoundaryHighlighting(args) {
|
|
1552
|
-
if (!this.provider
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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;
|
|
1556
1713
|
return;
|
|
1557
1714
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
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.
|
|
1560
1719
|
this.provider.onWordBoundary = (word, charIndex, length) => {
|
|
1561
1720
|
const wordLength = length || word.length;
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (nativeRange) {
|
|
1570
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
|
|
1571
|
-
}
|
|
1572
|
-
else {
|
|
1573
|
-
this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
else {
|
|
1577
|
-
console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
|
|
1721
|
+
const spokenIndex = charIndex + this.currentBoundaryOffset;
|
|
1722
|
+
const globalIndex = spokenIndex + args.wordBoundaryOffset;
|
|
1723
|
+
this.highlightSentenceForOffset(spokenIndex);
|
|
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;
|
|
1578
1728
|
}
|
|
1729
|
+
logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
|
|
1730
|
+
this.paintTTSWord(ranges);
|
|
1579
1731
|
};
|
|
1580
1732
|
}
|
|
1581
1733
|
clearWordBoundaryHighlighting() {
|
|
@@ -1590,26 +1742,22 @@ export class TTSService {
|
|
|
1590
1742
|
try {
|
|
1591
1743
|
this.lastRenderedRegionTarget = null;
|
|
1592
1744
|
if (chunk.regionRange) {
|
|
1593
|
-
|
|
1594
|
-
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]);
|
|
1595
1748
|
return;
|
|
1596
1749
|
}
|
|
1597
1750
|
if (!element)
|
|
1598
1751
|
return;
|
|
1599
1752
|
const range = document.createRange();
|
|
1600
1753
|
range.selectNodeContents(element);
|
|
1601
|
-
this.highlightCoordinator.clearHighlights
|
|
1754
|
+
this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
|
|
1602
1755
|
this.paintTTSSentenceRanges([range]);
|
|
1603
1756
|
}
|
|
1604
1757
|
catch {
|
|
1605
1758
|
// Continue playback even when a detached node cannot be highlighted.
|
|
1606
1759
|
}
|
|
1607
1760
|
}
|
|
1608
|
-
highlightCatalogActiveRange(range) {
|
|
1609
|
-
if (!this.highlightCoordinator)
|
|
1610
|
-
return;
|
|
1611
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
|
|
1612
|
-
}
|
|
1613
1761
|
highlightRenderableRegionTarget(target) {
|
|
1614
1762
|
if (!this.highlightCoordinator ||
|
|
1615
1763
|
!target ||
|
|
@@ -1626,7 +1774,7 @@ export class TTSService {
|
|
|
1626
1774
|
range.selectNodeContents(target.element);
|
|
1627
1775
|
}
|
|
1628
1776
|
else if (target.type === "range") {
|
|
1629
|
-
this.paintTTSSentenceRanges([target.range]);
|
|
1777
|
+
this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
|
|
1630
1778
|
this.lastRenderedRegionTarget = target;
|
|
1631
1779
|
return;
|
|
1632
1780
|
}
|
|
@@ -1656,23 +1804,19 @@ export class TTSService {
|
|
|
1656
1804
|
}
|
|
1657
1805
|
if (target.type === "text-range") {
|
|
1658
1806
|
const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
|
|
1659
|
-
if (nativeRange)
|
|
1660
|
-
this.
|
|
1661
|
-
}
|
|
1662
|
-
else {
|
|
1663
|
-
this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
|
|
1664
|
-
}
|
|
1807
|
+
if (nativeRange)
|
|
1808
|
+
this.paintTTSWord([nativeRange]);
|
|
1665
1809
|
return;
|
|
1666
1810
|
}
|
|
1667
1811
|
if (target.type === "range") {
|
|
1668
|
-
this.
|
|
1812
|
+
this.paintTTSWord(target.ranges ?? [target.range]);
|
|
1669
1813
|
return;
|
|
1670
1814
|
}
|
|
1671
1815
|
const elementNativeRange = document.createRange();
|
|
1672
1816
|
elementNativeRange.selectNodeContents(target.element);
|
|
1673
1817
|
const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
|
|
1674
1818
|
if (resolvedElementRange.remapped) {
|
|
1675
|
-
this.
|
|
1819
|
+
this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
|
|
1676
1820
|
return;
|
|
1677
1821
|
}
|
|
1678
1822
|
// Element targets are atomic: a resolved math token, a whole-expression
|
|
@@ -1687,10 +1831,12 @@ export class TTSService {
|
|
|
1687
1831
|
const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
|
|
1688
1832
|
if (target.quality === "semantic-token" &&
|
|
1689
1833
|
onlyChild?.nodeType === Node.TEXT_NODE) {
|
|
1690
|
-
|
|
1834
|
+
const tokenRange = document.createRange();
|
|
1835
|
+
tokenRange.selectNodeContents(onlyChild);
|
|
1836
|
+
this.highlightCoordinator.highlightTTSWord([tokenRange]);
|
|
1691
1837
|
return;
|
|
1692
1838
|
}
|
|
1693
|
-
this.highlightCoordinator.highlightTTSWordElement
|
|
1839
|
+
this.highlightCoordinator.highlightTTSWordElement(target.element);
|
|
1694
1840
|
}
|
|
1695
1841
|
renderHighlightDecision(decision) {
|
|
1696
1842
|
this.highlightRenderableRegionTarget(decision.regionTarget);
|
|
@@ -1706,7 +1852,9 @@ export class TTSService {
|
|
|
1706
1852
|
// anchors, plain alignment). The fallback chunk carries no further
|
|
1707
1853
|
// fallback, so this cannot recurse.
|
|
1708
1854
|
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1709
|
-
|
|
1855
|
+
logger.debug("SSML chunk speak failed; retrying plain text", {
|
|
1856
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1857
|
+
});
|
|
1710
1858
|
// A recorded-first run can own the start barrier even when its fallback
|
|
1711
1859
|
// provider has no formal start signal. Drop the failed recording's queued
|
|
1712
1860
|
// highlight and resume that non-start-aware provider's immediate-start lifecycle.
|
|
@@ -1715,7 +1863,7 @@ export class TTSService {
|
|
|
1715
1863
|
!("onPlaybackStart" in this.provider)) {
|
|
1716
1864
|
this.pendingPlaybackStartHighlights = [];
|
|
1717
1865
|
this.clearPlaybackStartBarrier();
|
|
1718
|
-
this.
|
|
1866
|
+
this.markPlaying();
|
|
1719
1867
|
}
|
|
1720
1868
|
await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
|
|
1721
1869
|
return;
|
|
@@ -1775,6 +1923,18 @@ export class TTSService {
|
|
|
1775
1923
|
cleanup();
|
|
1776
1924
|
reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
|
|
1777
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
|
+
};
|
|
1778
1938
|
// Cancellation has to settle this promise, not just stop the element:
|
|
1779
1939
|
// `stop()` bumps the run id, and a pending play that never resolves would
|
|
1780
1940
|
// wedge the chunk loop on a run nobody is listening to any more.
|
|
@@ -1788,6 +1948,7 @@ export class TTSService {
|
|
|
1788
1948
|
element.pause();
|
|
1789
1949
|
resolve();
|
|
1790
1950
|
},
|
|
1951
|
+
play,
|
|
1791
1952
|
};
|
|
1792
1953
|
element.addEventListener("ended", onEnded);
|
|
1793
1954
|
element.addEventListener("error", onError);
|
|
@@ -1795,7 +1956,7 @@ export class TTSService {
|
|
|
1795
1956
|
// Reaching the slice's end is this clip finishing, so the chunk sequence
|
|
1796
1957
|
// advances rather than the element merely pausing.
|
|
1797
1958
|
disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
|
|
1798
|
-
|
|
1959
|
+
play();
|
|
1799
1960
|
}).finally(() => {
|
|
1800
1961
|
if (this.activeRecordedAudio?.element === element) {
|
|
1801
1962
|
this.activeRecordedAudio = null;
|
|
@@ -1848,7 +2009,9 @@ export class TTSService {
|
|
|
1848
2009
|
this.highlightCatalogRegion(chunk);
|
|
1849
2010
|
}
|
|
1850
2011
|
});
|
|
1851
|
-
|
|
2012
|
+
// A sentence-mode read takes no word boundaries, aligned chunk or not.
|
|
2013
|
+
const canUseChunkBoundaries = this.resolveHighlightMode() === "word" &&
|
|
2014
|
+
chunk.sourceElement &&
|
|
1852
2015
|
((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
|
|
1853
2016
|
(chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
|
|
1854
2017
|
(chunk.alignment &&
|
|
@@ -1860,7 +2023,7 @@ export class TTSService {
|
|
|
1860
2023
|
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1861
2024
|
provider.onWordBoundary = undefined;
|
|
1862
2025
|
try {
|
|
1863
|
-
await
|
|
2026
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1864
2027
|
}
|
|
1865
2028
|
finally {
|
|
1866
2029
|
if (this.provider === provider &&
|
|
@@ -1892,7 +2055,7 @@ export class TTSService {
|
|
|
1892
2055
|
};
|
|
1893
2056
|
provider.onWordBoundary = installedHandler;
|
|
1894
2057
|
try {
|
|
1895
|
-
await
|
|
2058
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1896
2059
|
}
|
|
1897
2060
|
finally {
|
|
1898
2061
|
if (this.provider === provider &&
|
|
@@ -1905,9 +2068,9 @@ export class TTSService {
|
|
|
1905
2068
|
async executeSpeakPlayback(args) {
|
|
1906
2069
|
if (args.speechChunks?.length && this.provider) {
|
|
1907
2070
|
for (let index = 0; index < args.speechChunks.length; index++) {
|
|
2071
|
+
await this.waitWhilePaused(args.runId);
|
|
1908
2072
|
if (args.runId !== this.speakRunId)
|
|
1909
2073
|
return;
|
|
1910
|
-
this.currentSeekSegmentIndex = index;
|
|
1911
2074
|
const segment = this.seekSegments[index];
|
|
1912
2075
|
if (segment) {
|
|
1913
2076
|
this.currentBoundaryOffset = segment.startOffset;
|
|
@@ -1924,16 +2087,16 @@ export class TTSService {
|
|
|
1924
2087
|
highlightMode: args.highlightMode,
|
|
1925
2088
|
});
|
|
1926
2089
|
}
|
|
1927
|
-
else
|
|
1928
|
-
await this.
|
|
2090
|
+
else {
|
|
2091
|
+
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
1929
2092
|
}
|
|
1930
2093
|
return;
|
|
1931
2094
|
}
|
|
1932
|
-
|
|
1933
|
-
await this.provider.speak(args.contentToSpeak);
|
|
1934
|
-
}
|
|
2095
|
+
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
1935
2096
|
}
|
|
1936
2097
|
clearHighlightsAndTracking() {
|
|
2098
|
+
// A finished run leaves no handler for the next read to inherit.
|
|
2099
|
+
this.clearWordBoundaryHighlighting();
|
|
1937
2100
|
this.pendingPlaybackStartHighlights = [];
|
|
1938
2101
|
this.activePlaybackRate = null;
|
|
1939
2102
|
if (this.highlightCoordinator) {
|
|
@@ -1947,99 +2110,63 @@ export class TTSService {
|
|
|
1947
2110
|
this.seekSegments = [];
|
|
1948
2111
|
this.playbackChunks = [];
|
|
1949
2112
|
this.sentenceHighlightSegments = [];
|
|
1950
|
-
this.currentSeekSegmentIndex = 0;
|
|
1951
2113
|
this.activeSentenceStartOffset = null;
|
|
1952
2114
|
}
|
|
1953
2115
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
* This calculates the offset of the range within its parent element
|
|
1957
|
-
* to ensure word highlighting aligns correctly with the selected text.
|
|
1958
|
-
*
|
|
1959
|
-
* @param range DOM Range to speak
|
|
2116
|
+
* The selected part of `root`'s normalized visible text and where it starts, or
|
|
2117
|
+
* null when the map is unavailable or holds no selected character.
|
|
1960
2118
|
*/
|
|
1961
|
-
|
|
1962
|
-
if (
|
|
1963
|
-
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
if (!root) {
|
|
1977
|
-
const ancestor = range.commonAncestorContainer;
|
|
1978
|
-
root =
|
|
1979
|
-
ancestor.nodeType === Node.ELEMENT_NODE
|
|
1980
|
-
? ancestor
|
|
1981
|
-
: ancestor.parentElement;
|
|
1982
|
-
}
|
|
1983
|
-
if (!root)
|
|
1984
|
-
return;
|
|
1985
|
-
const selected = collectRangeTextForSpeech(range, root);
|
|
1986
|
-
const text = selected.text.trim();
|
|
1987
|
-
if (!text) {
|
|
1988
|
-
if (selected.filtered) {
|
|
1989
|
-
console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
|
|
1990
|
-
}
|
|
1991
|
-
return;
|
|
2119
|
+
selectMappedRangeText(range, root, language) {
|
|
2120
|
+
if (typeof range.comparePoint !== "function")
|
|
2121
|
+
return null;
|
|
2122
|
+
const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
|
|
2123
|
+
let start = -1;
|
|
2124
|
+
let end = -1;
|
|
2125
|
+
for (const [index, { node, offset }] of map) {
|
|
2126
|
+
if (index >= rootText.length)
|
|
2127
|
+
continue;
|
|
2128
|
+
if (!rangeHoldsTextPosition(range, node, offset))
|
|
2129
|
+
continue;
|
|
2130
|
+
if (start === -1 || index < start)
|
|
2131
|
+
start = index;
|
|
2132
|
+
if (index > end)
|
|
2133
|
+
end = index;
|
|
1992
2134
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
beforeRange.selectNodeContents(root);
|
|
2000
|
-
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
2001
|
-
const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
|
|
2002
|
-
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
2003
|
-
const offset = normalizedTextBeforeRange.length +
|
|
2004
|
-
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
2005
|
-
this.debugLog("[TTSService] speakRange offset calculation:", {
|
|
2006
|
-
selectedText: text,
|
|
2007
|
-
textBeforeRange: textBeforeRange.substring(0, 100),
|
|
2008
|
-
offset,
|
|
2009
|
-
rootTag: root.tagName,
|
|
2010
|
-
});
|
|
2011
|
-
// Speak the text with the root element as context
|
|
2012
|
-
await this.speak(text, {
|
|
2013
|
-
contentElement: root,
|
|
2014
|
-
ignoreCatalogs: true,
|
|
2015
|
-
wordBoundaryOffset: offset,
|
|
2016
|
-
});
|
|
2135
|
+
if (start === -1)
|
|
2136
|
+
return null;
|
|
2137
|
+
const raw = rootText.slice(start, end + 1);
|
|
2138
|
+
const leading = raw.length - raw.trimStart().length;
|
|
2139
|
+
const text = raw.trim();
|
|
2140
|
+
return text ? { text, offset: start + leading } : null;
|
|
2017
2141
|
}
|
|
2018
2142
|
/**
|
|
2019
|
-
* Pause playback
|
|
2143
|
+
* Pause playback. A pause while the read is loading holds it: its audio does
|
|
2144
|
+
* not start until {@link resume}.
|
|
2020
2145
|
*/
|
|
2021
2146
|
pause() {
|
|
2022
|
-
if (
|
|
2147
|
+
if (this.state !== PlaybackState.PLAYING &&
|
|
2148
|
+
this.state !== PlaybackState.LOADING) {
|
|
2023
2149
|
return;
|
|
2024
|
-
if (this.state === PlaybackState.PLAYING) {
|
|
2025
|
-
this.activeRecordedAudio?.element.pause();
|
|
2026
|
-
this.provider.pause();
|
|
2027
|
-
this.setState(PlaybackState.PAUSED);
|
|
2028
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);
|
|
2029
2157
|
}
|
|
2030
2158
|
/**
|
|
2031
|
-
* Resume playback
|
|
2159
|
+
* Resume playback, back to loading when the pause came before the audio
|
|
2160
|
+
* started.
|
|
2032
2161
|
*/
|
|
2033
2162
|
resume() {
|
|
2034
|
-
if (
|
|
2163
|
+
if (this.state !== PlaybackState.PAUSED)
|
|
2035
2164
|
return;
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
this.setState(PlaybackState.PLAYING);
|
|
2042
|
-
}
|
|
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();
|
|
2043
2170
|
}
|
|
2044
2171
|
normalizePlaybackRate(rate) {
|
|
2045
2172
|
if (!Number.isFinite(rate))
|
|
@@ -2053,19 +2180,27 @@ export class TTSService {
|
|
|
2053
2180
|
const restartChunks = this.playbackChunks.length
|
|
2054
2181
|
? this.playbackChunks.slice(safeTargetIndex)
|
|
2055
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;
|
|
2056
2186
|
this.speakRunId += 1;
|
|
2057
|
-
this.
|
|
2058
|
-
this.cancelRecordedAudio();
|
|
2187
|
+
this.abandonRun();
|
|
2059
2188
|
this.provider.onWordBoundary = undefined;
|
|
2060
2189
|
this.provider.stop();
|
|
2061
|
-
this.currentSeekSegmentIndex = safeTargetIndex;
|
|
2062
2190
|
const runId = ++this.speakRunId;
|
|
2063
2191
|
const restartSegments = this.seekSegments.slice(safeTargetIndex);
|
|
2064
2192
|
this.highlightCoordinator?.clearTTS();
|
|
2065
2193
|
this.activeSentenceStartOffset = null;
|
|
2066
|
-
this.
|
|
2194
|
+
this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
|
|
2195
|
+
if (!holdPaused)
|
|
2196
|
+
this.setState(PlaybackState.LOADING);
|
|
2067
2197
|
const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
|
|
2068
|
-
if (
|
|
2198
|
+
if (holdPaused) {
|
|
2199
|
+
this.resumeState = playbackStartBarrier
|
|
2200
|
+
? PlaybackState.LOADING
|
|
2201
|
+
: PlaybackState.PLAYING;
|
|
2202
|
+
}
|
|
2203
|
+
else if (!playbackStartBarrier) {
|
|
2069
2204
|
this.setState(PlaybackState.PLAYING);
|
|
2070
2205
|
}
|
|
2071
2206
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
@@ -2076,10 +2211,10 @@ export class TTSService {
|
|
|
2076
2211
|
});
|
|
2077
2212
|
if (restartChunks) {
|
|
2078
2213
|
for (let index = 0; index < restartChunks.length; index++) {
|
|
2214
|
+
await this.waitWhilePaused(runId);
|
|
2079
2215
|
if (runId !== this.speakRunId)
|
|
2080
2216
|
return;
|
|
2081
2217
|
const absoluteIndex = safeTargetIndex + index;
|
|
2082
|
-
this.currentSeekSegmentIndex = absoluteIndex;
|
|
2083
2218
|
const segment = this.seekSegments[absoluteIndex];
|
|
2084
2219
|
if (segment) {
|
|
2085
2220
|
this.currentBoundaryOffset = segment.startOffset;
|
|
@@ -2200,13 +2335,12 @@ export class TTSService {
|
|
|
2200
2335
|
* Stop playback
|
|
2201
2336
|
*/
|
|
2202
2337
|
stop() {
|
|
2203
|
-
if (!this.provider)
|
|
2204
|
-
return;
|
|
2205
2338
|
this.speakRunId += 1;
|
|
2206
|
-
this.
|
|
2207
|
-
this.
|
|
2208
|
-
|
|
2209
|
-
|
|
2339
|
+
this.abandonRun();
|
|
2340
|
+
if (this.provider) {
|
|
2341
|
+
this.provider.onWordBoundary = undefined;
|
|
2342
|
+
this.provider.stop();
|
|
2343
|
+
}
|
|
2210
2344
|
this.setState(PlaybackState.IDLE);
|
|
2211
2345
|
this.currentText = null;
|
|
2212
2346
|
// Clear highlights
|
|
@@ -2222,10 +2356,39 @@ export class TTSService {
|
|
|
2222
2356
|
this.seekSegments = [];
|
|
2223
2357
|
this.playbackChunks = [];
|
|
2224
2358
|
this.sentenceHighlightSegments = [];
|
|
2225
|
-
this.currentSeekSegmentIndex = 0;
|
|
2226
2359
|
this.activeSentenceStartOffset = null;
|
|
2227
2360
|
this.activePlaybackRate = null;
|
|
2228
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
|
+
}
|
|
2229
2392
|
/**
|
|
2230
2393
|
* Request UI-level TTS controls to hand off/deactivate.
|
|
2231
2394
|
*
|
|
@@ -2272,28 +2435,15 @@ export class TTSService {
|
|
|
2272
2435
|
return this.lastError;
|
|
2273
2436
|
}
|
|
2274
2437
|
/**
|
|
2275
|
-
* Subscribe to state changes
|
|
2276
|
-
*
|
|
2277
|
-
* @param id Unique listener ID
|
|
2278
|
-
* @param callback Function to call on state change
|
|
2438
|
+
* Subscribe to playback state changes. The returned function unsubscribes;
|
|
2439
|
+
* each subscription is its own, so the same callback can be subscribed twice.
|
|
2279
2440
|
*/
|
|
2280
|
-
onStateChange(
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
/**
|
|
2287
|
-
* Unsubscribe from state changes
|
|
2288
|
-
*/
|
|
2289
|
-
offStateChange(id, callback) {
|
|
2290
|
-
const callbacks = this.listeners.get(id);
|
|
2291
|
-
if (callbacks) {
|
|
2292
|
-
callbacks.delete(callback);
|
|
2293
|
-
if (callbacks.size === 0) {
|
|
2294
|
-
this.listeners.delete(id);
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2441
|
+
onStateChange(callback) {
|
|
2442
|
+
const listener = (state) => callback(state);
|
|
2443
|
+
this.listeners.add(listener);
|
|
2444
|
+
return () => {
|
|
2445
|
+
this.listeners.delete(listener);
|
|
2446
|
+
};
|
|
2297
2447
|
}
|
|
2298
2448
|
/**
|
|
2299
2449
|
* Set state and notify listeners
|
|
@@ -2304,14 +2454,11 @@ export class TTSService {
|
|
|
2304
2454
|
const previousState = this.state;
|
|
2305
2455
|
this.state = newState;
|
|
2306
2456
|
// Notify all listeners
|
|
2307
|
-
for (const
|
|
2308
|
-
|
|
2309
|
-
callback(newState);
|
|
2310
|
-
}
|
|
2457
|
+
for (const listener of [...this.listeners]) {
|
|
2458
|
+
listener(newState);
|
|
2311
2459
|
}
|
|
2312
2460
|
void this.emitTelemetry("pie-tool-playback-state-changed", {
|
|
2313
2461
|
toolId: "textToSpeech",
|
|
2314
|
-
providerId: "tts",
|
|
2315
2462
|
previousState,
|
|
2316
2463
|
state: newState,
|
|
2317
2464
|
});
|
|
@@ -2321,7 +2468,6 @@ export class TTSService {
|
|
|
2321
2468
|
: "pie-tool-playback-start";
|
|
2322
2469
|
void this.emitTelemetry(eventName, {
|
|
2323
2470
|
toolId: "textToSpeech",
|
|
2324
|
-
providerId: "tts",
|
|
2325
2471
|
previousState,
|
|
2326
2472
|
state: newState,
|
|
2327
2473
|
});
|
|
@@ -2330,7 +2476,6 @@ export class TTSService {
|
|
|
2330
2476
|
if (newState === PlaybackState.PAUSED) {
|
|
2331
2477
|
void this.emitTelemetry("pie-tool-playback-pause", {
|
|
2332
2478
|
toolId: "textToSpeech",
|
|
2333
|
-
providerId: "tts",
|
|
2334
2479
|
previousState,
|
|
2335
2480
|
state: newState,
|
|
2336
2481
|
});
|
|
@@ -2339,7 +2484,6 @@ export class TTSService {
|
|
|
2339
2484
|
if (newState === PlaybackState.ERROR) {
|
|
2340
2485
|
void this.emitTelemetry("pie-tool-playback-error", {
|
|
2341
2486
|
toolId: "textToSpeech",
|
|
2342
|
-
providerId: "tts",
|
|
2343
2487
|
previousState,
|
|
2344
2488
|
state: newState,
|
|
2345
2489
|
message: this.lastError || undefined,
|
|
@@ -2352,7 +2496,6 @@ export class TTSService {
|
|
|
2352
2496
|
previousState === PlaybackState.LOADING)) {
|
|
2353
2497
|
void this.emitTelemetry("pie-tool-playback-stop", {
|
|
2354
2498
|
toolId: "textToSpeech",
|
|
2355
|
-
providerId: "tts",
|
|
2356
2499
|
previousState,
|
|
2357
2500
|
state: newState,
|
|
2358
2501
|
});
|