@pie-players/pie-assessment-toolkit 0.3.74 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +658 -714
- package/custom-elements.json +1315 -0
- package/dist/attempt/TestSession.d.ts +1 -21
- package/dist/attempt/TestSession.js +16 -102
- package/dist/components/ItemScope.custom-element.d.ts +1 -0
- package/dist/components/ItemScope.custom-element.js +1 -0
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-3zrc4p3d.js +4 -0
- package/dist/components/chunks/ItemToolBar-66hcwmfw.js +2 -0
- package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
- package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
- package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-kkj69ptr.js} +1 -1
- package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
- package/dist/components/chunks/sre-engine-bj997w4b.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 +33 -6
- package/dist/index.d.ts +25 -50
- package/dist/index.js +15 -45
- package/dist/policy/core/PolicySource.d.ts +2 -2
- package/dist/policy/core/ToolPolicyEngine.d.ts +118 -34
- package/dist/policy/core/ToolPolicyEngine.js +203 -56
- package/dist/policy/core/compose-decision.d.ts +28 -7
- package/dist/policy/core/compose-decision.js +95 -74
- package/dist/policy/core/decision-types.d.ts +98 -55
- package/dist/policy/core/feature-decision.d.ts +28 -19
- package/dist/policy/core/feature-decision.js +15 -11
- package/dist/policy/core/pnp-policy-inputs.d.ts +29 -35
- package/dist/policy/core/pnp-policy-inputs.js +40 -88
- package/dist/policy/core/provenance.d.ts +10 -5
- package/dist/policy/core/provenance.js +1 -1
- package/dist/policy/core/tool-parameters.d.ts +11 -0
- package/dist/policy/core/tool-parameters.js +15 -0
- package/dist/policy/engine.d.ts +7 -23
- package/dist/policy/engine.js +5 -20
- package/dist/policy/sources/PnpPolicySource.d.ts +43 -68
- package/dist/policy/sources/PnpPolicySource.js +133 -132
- package/dist/runtime/SectionControllerBinding.d.ts +283 -0
- package/dist/runtime/SectionControllerBinding.js +456 -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 -3
- package/dist/runtime/adapter/subscriber-fanout.js +2 -6
- 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 +12 -18
- package/dist/runtime/composition-emit-scheduler.js +10 -16
- package/dist/runtime/content-language.d.ts +32 -0
- package/dist/runtime/content-language.js +46 -0
- package/dist/runtime/content-region.d.ts +13 -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 -4
- package/dist/runtime/core/SectionEngineCore.js +2 -6
- package/dist/runtime/core/cohort.d.ts +1 -6
- package/dist/runtime/core/cohort.js +1 -10
- 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 +17 -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 +74 -53
- 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 +22 -14
- package/dist/runtime/runtime-event-claim.d.ts +8 -0
- package/dist/runtime/runtime-event-claim.js +15 -0
- package/dist/runtime/runtime-id.d.ts +1 -1
- package/dist/runtime/runtime-id.js +1 -1
- 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 +194 -0
- package/dist/runtime/tool-host-contract.d.ts +8 -14
- package/dist/runtime/tool-host-contract.js +12 -12
- package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
- package/dist/runtime/unclaimed-registration-watch.js +23 -0
- package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
- package/dist/services/AccessibilityCatalogResolver.js +29 -42
- package/dist/services/ElementToolStateStore.d.ts +42 -67
- package/dist/services/ElementToolStateStore.js +65 -71
- package/dist/services/HighlightCoordinator.d.ts +28 -40
- package/dist/services/HighlightCoordinator.js +459 -381
- package/dist/services/RangeSerializer.d.ts +11 -33
- package/dist/services/RangeSerializer.js +88 -59
- package/dist/services/SSMLExtractor.d.ts +8 -31
- package/dist/services/SSMLExtractor.js +18 -40
- package/dist/services/TTSService.d.ts +159 -119
- package/dist/services/TTSService.js +937 -822
- package/dist/services/ToolCoordinator.d.ts +42 -102
- package/dist/services/ToolCoordinator.js +178 -256
- package/dist/services/ToolRegistry.d.ts +152 -133
- package/dist/services/ToolRegistry.js +192 -178
- package/dist/services/ToolkitCoordinator.d.ts +233 -237
- package/dist/services/ToolkitCoordinator.js +615 -557
- package/dist/services/audio-handoff.d.ts +6 -7
- package/dist/services/audio-handoff.js +20 -14
- package/dist/services/catalog-owner.d.ts +0 -1
- package/dist/services/framework-error-bus.d.ts +3 -6
- package/dist/services/framework-error-bus.js +3 -6
- package/dist/services/framework-error.d.ts +51 -13
- package/dist/services/framework-error.js +19 -20
- package/dist/services/interfaces.d.ts +189 -184
- package/dist/services/section-controller-types.d.ts +7 -7
- package/dist/services/section-session-assignment.d.ts +21 -0
- package/dist/services/section-session-assignment.js +80 -0
- package/dist/services/spoken-audio-cards.d.ts +2 -6
- package/dist/services/spoken-audio-cards.js +2 -6
- 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 +9 -12
- package/dist/services/tool-config-validation.js +47 -87
- package/dist/services/tool-context.d.ts +7 -31
- package/dist/services/tool-context.js +99 -65
- package/dist/services/tool-failure.d.ts +30 -0
- package/dist/services/tool-failure.js +11 -0
- package/dist/services/tool-icons.d.ts +5 -5
- package/dist/services/tool-icons.js +6 -5
- package/dist/services/tool-instance-id.d.ts +8 -7
- package/dist/services/tool-instance-id.js +12 -24
- package/dist/services/tool-providers/TTSToolProvider.d.ts +20 -58
- package/dist/services/tool-providers/TTSToolProvider.js +32 -85
- package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -11
- package/dist/services/tool-providers/ToolProviderRegistry.d.ts +29 -60
- package/dist/services/tool-providers/ToolProviderRegistry.js +128 -144
- package/dist/services/tool-request.d.ts +31 -12
- package/dist/services/tool-request.js +31 -16
- package/dist/services/toolbar-decided-tools.d.ts +16 -0
- package/dist/services/toolbar-decided-tools.js +29 -0
- package/dist/services/toolbar-items.js +2 -5
- package/dist/services/tools-config-normalizer.d.ts +33 -29
- package/dist/services/tools-config-normalizer.js +26 -28
- package/dist/services/tts/browser-provider.d.ts +39 -6
- package/dist/services/tts/browser-provider.js +280 -160
- package/dist/services/tts/catalog-span-alignment.js +14 -7
- package/dist/services/tts/flat-tree.d.ts +64 -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/dom/to-chunk-inputs.d.ts +2 -2
- package/dist/services/tts/generated-speech/index.d.ts +1 -1
- package/dist/services/tts/generated-speech/index.js +1 -1
- package/dist/services/tts/generated-speech/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 +1 -1
- package/dist/services/tts/generated-speech/types.js +1 -1
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +22 -16
- 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/highlight-target-resolver.d.ts +12 -0
- package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
- package/dist/services/tts/math-aware-text-processing.js +42 -47
- 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 +12 -1
- package/dist/services/tts/math-speech.js +54 -18
- package/dist/services/tts/mathml-sanitization.d.ts +7 -0
- package/dist/services/tts/mathml-sanitization.js +9 -1
- package/dist/services/tts/provider-options.d.ts +52 -0
- package/dist/services/tts/provider-options.js +1 -0
- package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
- package/dist/services/tts/ssml/spoken-text.js +29 -0
- package/dist/services/tts/start-failure.d.ts +4 -0
- package/dist/services/tts/start-failure.js +19 -0
- package/dist/services/tts/text-processing.d.ts +9 -0
- package/dist/services/tts/text-processing.js +46 -33
- package/dist/services/tts/text-segmentation.d.ts +12 -0
- package/dist/services/tts/text-segmentation.js +60 -10
- package/dist/services/tts-runtime-config.d.ts +54 -26
- package/dist/services/tts-runtime-config.js +27 -112
- package/dist/tools/content-capability-resolution.d.ts +9 -11
- package/dist/tools/content-capability-resolution.js +8 -45
- package/dist/tools/registration.d.ts +59 -0
- package/dist/tools/registration.js +55 -0
- package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
- package/dist/tools/registrations/toolbar-registration-helpers.js +8 -2
- package/dist/tools/tool-surface-host.d.ts +6 -10
- package/dist/tools/tool-surface-host.js +45 -50
- 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 +15 -29
- package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
- package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
- package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
- package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
- package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
- package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
- package/dist/context/runtime-context-consumer.d.ts +0 -13
- package/dist/context/runtime-context-consumer.js +0 -18
- package/dist/item-loader.d.ts +0 -44
- package/dist/item-loader.js +0 -61
- package/dist/policy/core/engine-context.d.ts +0 -29
- package/dist/policy/core/engine-context.js +0 -27
- package/dist/policy/core/policy-source-tag.d.ts +0 -31
- package/dist/policy/core/policy-source-tag.js +0 -28
- package/dist/policy/internal.d.ts +0 -25
- package/dist/policy/internal.js +0 -25
- package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
- package/dist/runtime/adapter/coordinator-bridge.js +0 -126
- package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
- package/dist/runtime/adapter/framework-error-bridge.js +0 -46
- package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
- package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
- package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
- package/dist/runtime/core/engine-stage-derivation.js +0 -29
- package/dist/runtime/engine-context.d.ts +0 -39
- package/dist/runtime/engine-context.js +0 -31
- package/dist/runtime/internal.d.ts +0 -43
- package/dist/runtime/internal.js +0 -45
- package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
- package/dist/runtime/section-runtime-engine-host-context.js +0 -57
- package/dist/runtime/stage-emit-gate.d.ts +0 -92
- package/dist/runtime/stage-emit-gate.js +0 -82
- package/dist/services/ContextVariableStore.d.ts +0 -113
- package/dist/services/ContextVariableStore.js +0 -206
- package/dist/services/I18nService.d.ts +0 -47
- package/dist/services/I18nService.js +0 -84
- package/dist/services/ThemeProvider.d.ts +0 -95
- package/dist/services/ThemeProvider.js +0 -334
- package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
- package/dist/services/defaultPersonalNeedsProfile.js +0 -26
- package/dist/services/pnp-standard-features.d.ts +0 -238
- package/dist/services/pnp-standard-features.js +0 -338
- package/dist/services/tts-control-events.d.ts +0 -13
- package/dist/services/tts-control-events.js +0 -7
- package/dist/tools/client.d.ts +0 -10
- package/dist/tools/client.js +0 -10
- package/dist/tools/internal.d.ts +0 -44
- package/dist/tools/internal.js +0 -39
- package/dist/utils/logger.d.ts +0 -6
- package/dist/utils/logger.js +0 -10
|
@@ -1,33 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TTSService
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and integrates with HighlightCoordinator for word highlighting.
|
|
4
|
+
* Owns one read-aloud session: the installed provider, playback state, and the
|
|
5
|
+
* word and sentence highlights painted through HighlightCoordinator.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* - Provider and host wiring: initialize, setCatalogResolver, the highlight
|
|
9
|
+
* target resolver and math-speech sources.
|
|
10
|
+
* - Content resolution: catalog spoken card, then catalog-composed chunks
|
|
11
|
+
* (script and/or recording with a plain fallback), then generated speech.
|
|
12
|
+
* - Speech plan: structural pauses, seek segments, sentence segments and the
|
|
13
|
+
* normalized-to-DOM position map.
|
|
14
|
+
* - Playback: speakContent → executeSpeakPlayback, the run guard (speakRunId,
|
|
15
|
+
* runOwner), the pause hold and the playback-start barrier.
|
|
16
|
+
* - Highlight painting: boundary handling, the host resolver remap, catalog
|
|
17
|
+
* highlight plans.
|
|
18
|
+
* - Controls and state: pause/resume/stop/seek/rate, setState and listeners.
|
|
17
19
|
*/
|
|
18
20
|
import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
|
|
19
|
-
import {
|
|
21
|
+
import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
|
|
20
22
|
import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
+
import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
|
|
24
|
+
import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
|
|
25
|
+
import { composedContains, composedParentElement, flatQuerySelectorAll, flatTextContent, flatTreeChildNodes, flatTreeParentElement, flatTreeTextNodes, rangeHoldsTextPosition, rangeIntersectsComposedNode, } from "./tts/flat-tree.js";
|
|
26
|
+
import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, rangeHoldsSpeakableElement, textInRange, } from "./tts/text-processing.js";
|
|
23
27
|
import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
|
|
24
28
|
import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
|
|
25
29
|
import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
|
|
26
30
|
import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
|
|
27
31
|
import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
|
|
28
|
-
import {
|
|
32
|
+
import { toTTSStartFailure } from "./tts/start-failure.js";
|
|
33
|
+
import { segmentSentences as segmentTextToSentences, splitTextToLength, } from "./tts/text-segmentation.js";
|
|
29
34
|
import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
|
|
30
|
-
import {
|
|
35
|
+
import { createRangesFromVisibleMap } from "./tts/highlight-pipeline/visible-map-range.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,29 @@ export var PlaybackState;
|
|
|
39
46
|
PlaybackState["PAUSED"] = "paused";
|
|
40
47
|
PlaybackState["ERROR"] = "error";
|
|
41
48
|
})(PlaybackState || (PlaybackState = {}));
|
|
49
|
+
const DEFAULT_STRUCTURAL_PAUSE_PROFILE = {
|
|
50
|
+
baseMs: 280,
|
|
51
|
+
units: { minor: 0.65, section: 1.0, major: 1.35 },
|
|
52
|
+
minMs: 120,
|
|
53
|
+
maxMs: 900,
|
|
54
|
+
};
|
|
55
|
+
const MIN_PLAYBACK_RATE = 0.25;
|
|
56
|
+
const MAX_PLAYBACK_RATE = 4;
|
|
57
|
+
const optionalString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
58
|
+
const hostLocalesOf = (options, previous = {}) => {
|
|
59
|
+
if (!options)
|
|
60
|
+
return previous;
|
|
61
|
+
return {
|
|
62
|
+
locale: "locale" in options ? optionalString(options.locale) : previous.locale,
|
|
63
|
+
textNormalization: options.textNormalization && "locale" in options.textNormalization
|
|
64
|
+
? optionalString(options.textNormalization.locale)
|
|
65
|
+
: previous.textNormalization,
|
|
66
|
+
segmenter: options.segmenter && "locale" in options.segmenter
|
|
67
|
+
? optionalString(options.segmenter.locale)
|
|
68
|
+
: previous.segmenter,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
const isRange = (target) => "commonAncestorContainer" in target;
|
|
42
72
|
const sameRange = (left, right) => left === right ||
|
|
43
73
|
(left.startContainer === right.startContainer &&
|
|
44
74
|
left.startOffset === right.startOffset &&
|
|
@@ -67,92 +97,158 @@ const sameRenderableHighlightTarget = (left, right) => {
|
|
|
67
97
|
return false;
|
|
68
98
|
};
|
|
69
99
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
100
|
+
* Where two texts first differ, with 20 characters of context each side;
|
|
101
|
+
* debug tracing only.
|
|
102
|
+
*/
|
|
103
|
+
const firstDifference = (spoken, normalized) => {
|
|
104
|
+
for (let i = 0; i < Math.min(spoken.length, normalized.length); i++) {
|
|
105
|
+
if (spoken[i] !== normalized[i]) {
|
|
106
|
+
return {
|
|
107
|
+
index: i,
|
|
108
|
+
spokenChar: spoken[i],
|
|
109
|
+
normalizedChar: normalized[i],
|
|
110
|
+
spokenContext: spoken.substring(Math.max(0, i - 20), i + 20),
|
|
111
|
+
normalizedContext: normalized.substring(Math.max(0, i - 20), i + 20),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
/** The authored `data-tts-break-ms` pause, when it is a positive number. */
|
|
118
|
+
const explicitBreakMsOf = (element) => {
|
|
119
|
+
const ms = Number(element.getAttribute("data-tts-break-ms"));
|
|
120
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
121
|
+
};
|
|
122
|
+
const isBlockDisplay = (element) => {
|
|
123
|
+
if (typeof window === "undefined")
|
|
124
|
+
return false;
|
|
125
|
+
const display = window.getComputedStyle(element).display;
|
|
126
|
+
return (display === "block" ||
|
|
127
|
+
display === "list-item" ||
|
|
128
|
+
display.startsWith("table") ||
|
|
129
|
+
display === "flex" ||
|
|
130
|
+
display === "grid");
|
|
131
|
+
};
|
|
132
|
+
/** `[start, end)` of `text` with its surrounding whitespace excluded. */
|
|
133
|
+
const trimSpan = (text, start, end) => {
|
|
134
|
+
const raw = text.substring(start, end);
|
|
135
|
+
return {
|
|
136
|
+
start: start + (raw.match(/^\s*/)?.[0].length || 0),
|
|
137
|
+
end: end - (raw.match(/\s*$/)?.[0].length || 0),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
72
141
|
* Instantiable service for text-to-speech functionality.
|
|
73
142
|
* Each instance manages its own playback state.
|
|
74
143
|
*/
|
|
75
144
|
export class TTSService {
|
|
145
|
+
// currentProvider is the provider the host installed; provider is the
|
|
146
|
+
// implementation its initialize() returned, which does the speaking.
|
|
76
147
|
currentProvider = null;
|
|
77
148
|
provider = null;
|
|
78
149
|
highlightCoordinator = null;
|
|
79
150
|
highlightTargetResolverProvider = null;
|
|
80
151
|
catalogResolver = null;
|
|
81
152
|
state = PlaybackState.IDLE;
|
|
153
|
+
// The state a resume returns to: loading when the pause came before the
|
|
154
|
+
// audio started sounding.
|
|
155
|
+
resumeState = PlaybackState.PLAYING;
|
|
82
156
|
ttsConfig = {};
|
|
157
|
+
// The locales the host configured. A read naming a language sets all three
|
|
158
|
+
// to it; a read naming none restores these.
|
|
159
|
+
hostLocales = {};
|
|
160
|
+
// The locale of the read in progress, which segmentation reads.
|
|
161
|
+
readLocale = DEFAULT_CONTENT_LANGUAGE;
|
|
162
|
+
disposed = false;
|
|
163
|
+
readinessGate = null;
|
|
164
|
+
mathSpeechSource = null;
|
|
83
165
|
currentText = null;
|
|
84
166
|
currentContentElement = null;
|
|
167
|
+
// Offsets in the normalized visible text of the element being read, mapped
|
|
168
|
+
// to DOM text positions; word boundaries resolve through it.
|
|
85
169
|
normalizedToDOM = new Map();
|
|
86
|
-
listeners = new
|
|
170
|
+
listeners = new Set();
|
|
87
171
|
lastError = null;
|
|
172
|
+
// Bumped by every new run and by stop, restart and dispose; an async step
|
|
173
|
+
// that resumes under an older id has been superseded and returns.
|
|
88
174
|
speakRunId = 0;
|
|
175
|
+
runOwner = null;
|
|
176
|
+
// currentBoundaryOffset is the spoken-text offset the run has reached, which
|
|
177
|
+
// seek and word boundaries count from; activeWordBoundaryOffset is where the
|
|
178
|
+
// spoken text starts in the element's visible text, nonzero for a selection.
|
|
89
179
|
currentBoundaryOffset = 0;
|
|
90
180
|
activeWordBoundaryOffset = 0;
|
|
91
181
|
seekSegments = [];
|
|
92
182
|
playbackChunks = [];
|
|
93
183
|
// The recording currently playing, if any. `cancel` settles the pending play
|
|
94
184
|
// promise as well as stopping the element, so stop/seek cannot wedge the
|
|
95
|
-
// chunk loop on a run that has already been superseded.
|
|
185
|
+
// chunk loop on a run that has already been superseded. `play` starts it, or
|
|
186
|
+
// starts it again after a pause.
|
|
96
187
|
activeRecordedAudio = null;
|
|
97
188
|
sentenceHighlightSegments = [];
|
|
98
189
|
activeSentenceStartOffset = null;
|
|
99
190
|
playbackStartDeferredRunId = null;
|
|
100
191
|
pendingPlaybackStartHighlights = [];
|
|
101
192
|
playbackStartBarrierCleanup = null;
|
|
193
|
+
// A pause between two parts of a run, in a structural gap or while the next
|
|
194
|
+
// part loads, holds the run here until resume.
|
|
195
|
+
pauseHold = null;
|
|
196
|
+
structuralPause = null;
|
|
102
197
|
playbackRateWriteQueue = Promise.resolve();
|
|
103
198
|
playbackRateRequestId = 0;
|
|
104
199
|
pendingPlaybackRateRequest = null;
|
|
105
200
|
activePlaybackRate = null;
|
|
106
201
|
activeHighlightMode = "word";
|
|
202
|
+
// What the plan part being spoken paints in sentence mode.
|
|
203
|
+
wordlessPartHighlight = null;
|
|
107
204
|
lastRenderedRegionTarget = null;
|
|
108
205
|
telemetryReporter = null;
|
|
109
|
-
// Memoized SRE resolver for the runtime generated-speech path
|
|
206
|
+
// Memoized SRE resolver for the runtime generated-speech path.
|
|
110
207
|
// Caches DOM-free spoken text keyed by SRE settings and MathML source across
|
|
111
208
|
// utterances within this service instance.
|
|
112
209
|
generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
|
|
113
|
-
// Verbose alignment/highlight tracing is opt-in: it is expensive (large
|
|
114
|
-
// objects, a full text diff per utterance) and noisy in production. Enable
|
|
115
|
-
// with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
116
|
-
static debugEnabled = (() => {
|
|
117
|
-
try {
|
|
118
|
-
if (typeof process !== "undefined" &&
|
|
119
|
-
process.env?.PIE_TTS_DEBUG === "1") {
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// `process` is not defined in the browser; fall through to the global.
|
|
125
|
-
}
|
|
126
|
-
return (globalThis.__PIE_TTS_DEBUG__ === true);
|
|
127
|
-
})();
|
|
128
|
-
constructor() { }
|
|
129
|
-
debugLog(message, detail) {
|
|
130
|
-
if (!TTSService.debugEnabled)
|
|
131
|
-
return;
|
|
132
|
-
if (detail === undefined) {
|
|
133
|
-
console.debug(message);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
console.debug(message, detail);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
210
|
clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
|
|
140
211
|
cleanup?.();
|
|
141
212
|
if (this.playbackStartBarrierCleanup === cleanup) {
|
|
142
213
|
this.playbackStartBarrierCleanup = null;
|
|
143
214
|
}
|
|
144
215
|
}
|
|
145
|
-
notifyPlaybackStarted(runId) {
|
|
216
|
+
notifyPlaybackStarted(runId, playback) {
|
|
146
217
|
if (this.playbackStartDeferredRunId !== runId ||
|
|
147
218
|
runId !== this.speakRunId) {
|
|
148
219
|
return;
|
|
149
220
|
}
|
|
150
|
-
this.
|
|
221
|
+
if (this.state === PlaybackState.PAUSED) {
|
|
222
|
+
// The learner paused before this part began: it holds where it started.
|
|
223
|
+
this.activeRecordedAudio?.element.pause();
|
|
224
|
+
this.provider?.pause();
|
|
225
|
+
}
|
|
226
|
+
this.markPlaying();
|
|
151
227
|
const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
|
|
152
228
|
for (const applyHighlight of pendingHighlights) {
|
|
153
229
|
applyHighlight();
|
|
154
230
|
}
|
|
231
|
+
if (playback?.wordBoundaries === false)
|
|
232
|
+
this.highlightWordlessPlayback();
|
|
155
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* A word-mode part whose playback sends no boundaries, such as a server
|
|
236
|
+
* response without speech marks, highlights as a sentence-mode read does.
|
|
237
|
+
* Catalog chunks and a single speak highlight the same in both modes.
|
|
238
|
+
*/
|
|
239
|
+
highlightWordlessPlayback() {
|
|
240
|
+
const paint = this.wordlessPartHighlight;
|
|
241
|
+
if (!paint || this.activeHighlightMode !== "word")
|
|
242
|
+
return;
|
|
243
|
+
this.highlightCoordinator?.clearTTSWord();
|
|
244
|
+
paint();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Defers marking the run playing, and the highlights queued through
|
|
248
|
+
* runWhenPlaybackStarts, until the provider or the recording signals that
|
|
249
|
+
* audio started. Returns the barrier's cleanup, or null when nothing will
|
|
250
|
+
* signal a start, in which case the caller marks the run playing itself.
|
|
251
|
+
*/
|
|
156
252
|
installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
|
|
157
253
|
this.clearPlaybackStartBarrier();
|
|
158
254
|
const provider = this.provider;
|
|
@@ -160,12 +256,12 @@ export class TTSService {
|
|
|
160
256
|
if (!startAwareProvider && !hasMediaStartSignal)
|
|
161
257
|
return null;
|
|
162
258
|
const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
|
|
163
|
-
const onPlaybackStart = () => {
|
|
259
|
+
const onPlaybackStart = (playback) => {
|
|
164
260
|
try {
|
|
165
|
-
this.notifyPlaybackStarted(runId);
|
|
261
|
+
this.notifyPlaybackStarted(runId, playback);
|
|
166
262
|
}
|
|
167
263
|
finally {
|
|
168
|
-
previousOnPlaybackStart?.();
|
|
264
|
+
previousOnPlaybackStart?.(playback);
|
|
169
265
|
}
|
|
170
266
|
};
|
|
171
267
|
if (startAwareProvider) {
|
|
@@ -191,6 +287,84 @@ export class TTSService {
|
|
|
191
287
|
}
|
|
192
288
|
callback();
|
|
193
289
|
}
|
|
290
|
+
/** Resolves when run `runId` may start its next part: at once unless paused. */
|
|
291
|
+
async waitWhilePaused(runId) {
|
|
292
|
+
while (this.state === PlaybackState.PAUSED && runId === this.speakRunId) {
|
|
293
|
+
if (!this.pauseHold) {
|
|
294
|
+
let release = () => { };
|
|
295
|
+
const promise = new Promise((resolve) => {
|
|
296
|
+
release = resolve;
|
|
297
|
+
});
|
|
298
|
+
this.pauseHold = { promise, release };
|
|
299
|
+
}
|
|
300
|
+
await this.pauseHold.promise;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
releasePauseHold() {
|
|
304
|
+
const hold = this.pauseHold;
|
|
305
|
+
this.pauseHold = null;
|
|
306
|
+
hold?.release();
|
|
307
|
+
}
|
|
308
|
+
waitStructuralPause(pauseMs) {
|
|
309
|
+
this.cancelStructuralPause();
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const finish = () => {
|
|
312
|
+
if (this.structuralPause?.finish === finish) {
|
|
313
|
+
clearTimeout(this.structuralPause.timer);
|
|
314
|
+
this.structuralPause = null;
|
|
315
|
+
}
|
|
316
|
+
resolve();
|
|
317
|
+
};
|
|
318
|
+
this.structuralPause = { timer: setTimeout(finish, pauseMs), finish };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
cancelStructuralPause() {
|
|
322
|
+
this.structuralPause?.finish();
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Release what a superseded run still holds: its start barrier, a recorded
|
|
326
|
+
* clip still playing, a structural gap and a pause hold. Its loops then see
|
|
327
|
+
* the new run id and return.
|
|
328
|
+
*/
|
|
329
|
+
abandonRun() {
|
|
330
|
+
this.clearPlaybackStartBarrier();
|
|
331
|
+
this.cancelRecordedAudio();
|
|
332
|
+
this.cancelStructuralPause();
|
|
333
|
+
this.releasePauseHold();
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Start a run and enter loading. From here a pause or stop applies to the
|
|
337
|
+
* run, whatever it is still waiting for: the provider, its content or the
|
|
338
|
+
* provider's audio.
|
|
339
|
+
*/
|
|
340
|
+
beginRun(owner) {
|
|
341
|
+
// A recorded clip that starts the new run would otherwise play over the
|
|
342
|
+
// previous run's synthesis, which only the provider's next speak stops.
|
|
343
|
+
const supersedesPlayback = this.state === PlaybackState.LOADING ||
|
|
344
|
+
this.state === PlaybackState.PLAYING ||
|
|
345
|
+
this.state === PlaybackState.PAUSED;
|
|
346
|
+
const runId = ++this.speakRunId;
|
|
347
|
+
this.runOwner = owner;
|
|
348
|
+
this.abandonRun();
|
|
349
|
+
if (supersedesPlayback)
|
|
350
|
+
this.provider?.stop();
|
|
351
|
+
// Announced even from loading, so a listener sees the owner change.
|
|
352
|
+
this.setState(PlaybackState.LOADING, true);
|
|
353
|
+
return runId;
|
|
354
|
+
}
|
|
355
|
+
/** The run's audio is sounding; under a pause, it sounds again on resume. */
|
|
356
|
+
markPlaying() {
|
|
357
|
+
if (this.state === PlaybackState.PAUSED) {
|
|
358
|
+
this.resumeState = PlaybackState.PLAYING;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.setState(PlaybackState.PLAYING);
|
|
362
|
+
}
|
|
363
|
+
assertNotDisposed() {
|
|
364
|
+
if (this.disposed) {
|
|
365
|
+
throw toTTSStartFailure(new Error("TTS service disposed"));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
194
368
|
async emitTelemetry(eventName, payload) {
|
|
195
369
|
try {
|
|
196
370
|
await this.telemetryReporter?.(eventName, payload);
|
|
@@ -201,77 +375,41 @@ export class TTSService {
|
|
|
201
375
|
}
|
|
202
376
|
/**
|
|
203
377
|
* Initialize TTS service with a provider
|
|
204
|
-
*
|
|
205
|
-
* @param provider TTS provider instance
|
|
206
|
-
* @param config Provider configuration
|
|
207
378
|
*/
|
|
208
379
|
async initialize(provider, config = {}) {
|
|
380
|
+
if (this.disposed)
|
|
381
|
+
throw new Error("TTS service disposed");
|
|
209
382
|
this.currentProvider = provider;
|
|
210
383
|
this.ttsConfig = { ...config };
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
: null;
|
|
218
|
-
// Initialize provider and get implementation. Browser fallback is only
|
|
219
|
-
// allowed here, before the configured provider has successfully become
|
|
220
|
-
// the active runtime. Per-call playback/synthesis errors must surface
|
|
221
|
-
// instead of silently replacing a user-selected provider and voice.
|
|
222
|
-
try {
|
|
223
|
-
this.provider = await provider.initialize(config);
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
if (provider.providerId.toLowerCase() === "browser") {
|
|
227
|
-
throw error;
|
|
228
|
-
}
|
|
229
|
-
if (!this.isBrowserSpeechFallbackAvailable()) {
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
|
-
const switched = await this.switchProviderToBrowser(error, {
|
|
233
|
-
operation: "tts-initialize",
|
|
234
|
-
});
|
|
235
|
-
if (!switched) {
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
384
|
+
this.hostLocales = hostLocalesOf(config.providerOptions);
|
|
385
|
+
const telemetry = this.providerOptions().__pieTelemetry;
|
|
386
|
+
this.telemetryReporter = typeof telemetry === "function" ? telemetry : null;
|
|
387
|
+
// A provider that fails to start throws to the caller, which owns any
|
|
388
|
+
// fallback. Playback and synthesis errors surface the same way.
|
|
389
|
+
this.provider = await provider.initialize(config);
|
|
239
390
|
}
|
|
240
391
|
/**
|
|
241
|
-
* Update
|
|
242
|
-
*
|
|
243
|
-
* This allows changing rate, pitch, and voice on the fly.
|
|
244
|
-
* Note: Some providers may require reinitialization for voice changes.
|
|
245
|
-
*
|
|
246
|
-
* @param settings Partial settings to update (rate, pitch, voice)
|
|
392
|
+
* Update rate, pitch, voice or provider options without reinitializing.
|
|
393
|
+
* `providerOptions` merge over the ones already configured.
|
|
247
394
|
*/
|
|
248
395
|
async updateSettings(settings) {
|
|
249
396
|
if (!this.provider) {
|
|
250
397
|
throw new Error("TTSService not initialized. Call initialize() first.");
|
|
251
398
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
await provider.updateSettings(mergedSettings);
|
|
267
|
-
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
// Fallback: Reinitialize with merged config
|
|
271
|
-
// This requires storing the original config, which we don't have
|
|
272
|
-
// So for now, just log a warning
|
|
273
|
-
console.warn("[TTSService] Provider does not support dynamic settings updates. Some settings may require reinitialization.");
|
|
274
|
-
}
|
|
399
|
+
const mergedSettings = {
|
|
400
|
+
...settings,
|
|
401
|
+
...(settings.providerOptions
|
|
402
|
+
? {
|
|
403
|
+
providerOptions: {
|
|
404
|
+
...this.providerOptions(),
|
|
405
|
+
...settings.providerOptions,
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
: {}),
|
|
409
|
+
};
|
|
410
|
+
await this.provider.updateSettings(mergedSettings);
|
|
411
|
+
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
412
|
+
this.hostLocales = hostLocalesOf(settings.providerOptions, this.hostLocales);
|
|
275
413
|
}
|
|
276
414
|
/**
|
|
277
415
|
* Set highlight coordinator for word highlighting
|
|
@@ -279,6 +417,29 @@ export class TTSService {
|
|
|
279
417
|
setHighlightCoordinator(coordinator) {
|
|
280
418
|
this.highlightCoordinator = coordinator;
|
|
281
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Starts the service when a caller speaks before it is initialized; a gate
|
|
422
|
+
* that rejects fails the speak with its error. The coordinator installs one
|
|
423
|
+
* that runs its own text-to-speech start.
|
|
424
|
+
*/
|
|
425
|
+
setReadinessGate(gate) {
|
|
426
|
+
this.readinessGate = gate;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Where the configured math speech options come from before, and apart from,
|
|
430
|
+
* initialization, so math names do not depend on when speech started.
|
|
431
|
+
*/
|
|
432
|
+
setMathSpeechSource(source) {
|
|
433
|
+
this.mathSpeechSource = source;
|
|
434
|
+
}
|
|
435
|
+
/** The start a speak must wait for, or null when it can speak now without yielding. */
|
|
436
|
+
pendingReadiness() {
|
|
437
|
+
if (this.provider || !this.readinessGate)
|
|
438
|
+
return null;
|
|
439
|
+
return this.readinessGate().catch((error) => {
|
|
440
|
+
throw toTTSStartFailure(error);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
282
443
|
/**
|
|
283
444
|
* Set a late-bound provider for optional host TTS highlight target remapping.
|
|
284
445
|
*
|
|
@@ -307,37 +468,10 @@ export class TTSService {
|
|
|
307
468
|
*
|
|
308
469
|
* When set, the speak() method will check for pre-authored spoken
|
|
309
470
|
* content in catalogs before falling back to generated TTS.
|
|
310
|
-
*
|
|
311
|
-
* @param resolver AccessibilityCatalogResolver instance
|
|
312
471
|
*/
|
|
313
472
|
setCatalogResolver(resolver) {
|
|
314
473
|
this.catalogResolver = resolver;
|
|
315
474
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Whether a catalog holds spoken content this service could actually speak.
|
|
318
|
-
*
|
|
319
|
-
* Exists because `data-catalog-idref` is one attribute with several readers:
|
|
320
|
-
* a node can be docked to a catalog that carries only a signing card, and the
|
|
321
|
-
* nearest docked ancestor of a selection is therefore not necessarily the one
|
|
322
|
-
* holding its authored SSML. A caller resolving a selection climbs ancestors
|
|
323
|
-
* and asks this before settling on an id — see the TTS tool's
|
|
324
|
-
* `findSpokenCatalogId`. Without it, a signing card docked on an inner node
|
|
325
|
-
* silently shadows authored speech on an outer one.
|
|
326
|
-
*/
|
|
327
|
-
hasSpokenAlternate(catalogId, language = "en-US") {
|
|
328
|
-
if (!catalogId || !this.catalogResolver)
|
|
329
|
-
return false;
|
|
330
|
-
const resolved = this.catalogResolver.getAlternative(catalogId, {
|
|
331
|
-
type: "spoken",
|
|
332
|
-
language,
|
|
333
|
-
useFallback: true,
|
|
334
|
-
// The string form specifically. A node may also carry a recording of the
|
|
335
|
-
// same script; that one is not what this reports on, and card order must
|
|
336
|
-
// not decide the answer.
|
|
337
|
-
form: "content",
|
|
338
|
-
});
|
|
339
|
-
return resolved?.content !== undefined;
|
|
340
|
-
}
|
|
341
475
|
getHighlightResolverRuntime() {
|
|
342
476
|
let provided = null;
|
|
343
477
|
try {
|
|
@@ -364,12 +498,12 @@ export class TTSService {
|
|
|
364
498
|
return null;
|
|
365
499
|
return node.nodeType === Node.ELEMENT_NODE
|
|
366
500
|
? node
|
|
367
|
-
: node
|
|
501
|
+
: composedParentElement(node);
|
|
368
502
|
}
|
|
369
503
|
isElementWithinScope(element, scope) {
|
|
370
504
|
if (!scope)
|
|
371
505
|
return true;
|
|
372
|
-
return
|
|
506
|
+
return composedContains(scope, element);
|
|
373
507
|
}
|
|
374
508
|
isRangeWithinScope(range, scope) {
|
|
375
509
|
const startElement = this.getNodeElement(range.startContainer);
|
|
@@ -444,28 +578,21 @@ export class TTSService {
|
|
|
444
578
|
return null;
|
|
445
579
|
}
|
|
446
580
|
}
|
|
447
|
-
|
|
448
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Paint the spoken word, each range offered to the host's resolver. A word
|
|
583
|
+
* split across inline elements is one range over its text nodes; across
|
|
584
|
+
* shadow trees, one range per tree.
|
|
585
|
+
*/
|
|
586
|
+
paintTTSWord(nativeRanges) {
|
|
587
|
+
if (!this.highlightCoordinator || nativeRanges.length === 0)
|
|
449
588
|
return;
|
|
450
|
-
|
|
451
|
-
if (range.startContainer === range.endContainer &&
|
|
452
|
-
range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
453
|
-
this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
catch {
|
|
458
|
-
// Fall through to generic range painting for lightweight test doubles.
|
|
459
|
-
}
|
|
460
|
-
this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
|
|
589
|
+
this.highlightCoordinator.highlightTTSWord(nativeRanges.map((range) => this.resolveWordHighlightRange(range).range));
|
|
461
590
|
}
|
|
462
591
|
paintTTSSentenceRanges(nativeRanges) {
|
|
463
592
|
if (!this.highlightCoordinator)
|
|
464
593
|
return;
|
|
465
594
|
const { ranges, elements } = this.resolveSentenceHighlightTargets(nativeRanges);
|
|
466
|
-
if (elements.length > 0 &&
|
|
467
|
-
ranges.length === 0 &&
|
|
468
|
-
this.highlightCoordinator.highlightTTSSentenceElements) {
|
|
595
|
+
if (elements.length > 0 && ranges.length === 0) {
|
|
469
596
|
this.highlightCoordinator.highlightTTSSentenceElements(elements);
|
|
470
597
|
return;
|
|
471
598
|
}
|
|
@@ -477,42 +604,123 @@ export class TTSService {
|
|
|
477
604
|
}
|
|
478
605
|
this.highlightCoordinator.highlightTTSSentence(paintedRanges);
|
|
479
606
|
}
|
|
607
|
+
providerOptions() {
|
|
608
|
+
const options = this.ttsConfig.providerOptions;
|
|
609
|
+
return options && typeof options === "object" ? options : {};
|
|
610
|
+
}
|
|
611
|
+
/** The longest text the provider accepts in one speak, when it declares one. */
|
|
612
|
+
maxTextLength() {
|
|
613
|
+
const limit = this.currentProvider?.getCapabilities?.().maxTextLength;
|
|
614
|
+
return typeof limit === "number" && limit > 0 ? limit : undefined;
|
|
615
|
+
}
|
|
480
616
|
/**
|
|
481
|
-
*
|
|
617
|
+
* Speak `text`, in pieces when it exceeds the provider's `maxTextLength`. Word
|
|
618
|
+
* boundaries keep indexing the whole text. An SSML document cannot be cut, so
|
|
619
|
+
* one over the limit fails and the chunk's plain fallback reads instead.
|
|
482
620
|
*/
|
|
483
|
-
|
|
484
|
-
|
|
621
|
+
async speakWithinLimit(text, runId) {
|
|
622
|
+
const provider = this.provider;
|
|
623
|
+
if (!provider)
|
|
624
|
+
return;
|
|
625
|
+
const limit = this.maxTextLength();
|
|
626
|
+
if (!limit || text.length <= limit) {
|
|
627
|
+
await this.waitWhilePaused(runId);
|
|
628
|
+
if (runId !== this.speakRunId || this.provider !== provider)
|
|
629
|
+
return;
|
|
630
|
+
await provider.speak(text);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (isSsmlDocument(text)) {
|
|
634
|
+
throw new Error(`[tts] SSML of ${text.length} characters exceeds the provider's limit of ${limit}`);
|
|
635
|
+
}
|
|
636
|
+
const onWordBoundary = provider.onWordBoundary;
|
|
637
|
+
try {
|
|
638
|
+
for (const piece of splitTextToLength(text, limit, {
|
|
639
|
+
locale: this.readLocale,
|
|
640
|
+
})) {
|
|
641
|
+
await this.waitWhilePaused(runId);
|
|
642
|
+
if (runId !== this.speakRunId || this.provider !== provider)
|
|
643
|
+
return;
|
|
644
|
+
provider.onWordBoundary = onWordBoundary
|
|
645
|
+
? (word, position, length) => onWordBoundary(word, position + piece.offset, length)
|
|
646
|
+
: undefined;
|
|
647
|
+
await provider.speak(piece.text);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
if (this.provider === provider && runId === this.speakRunId) {
|
|
652
|
+
provider.onWordBoundary = onWordBoundary;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Segments no longer than the provider's `maxTextLength`, offsets kept. */
|
|
657
|
+
segmentsWithinLimit(segments) {
|
|
658
|
+
const limit = this.maxTextLength();
|
|
659
|
+
if (!limit)
|
|
660
|
+
return segments;
|
|
661
|
+
return segments.flatMap((segment) => {
|
|
662
|
+
if (segment.text.length <= limit)
|
|
663
|
+
return [segment];
|
|
664
|
+
const pieces = splitTextToLength(segment.text, limit, {
|
|
665
|
+
locale: this.readLocale,
|
|
666
|
+
});
|
|
667
|
+
return pieces.map((piece, index) => ({
|
|
668
|
+
text: piece.text,
|
|
669
|
+
startOffset: segment.startOffset + piece.offset,
|
|
670
|
+
pauseMsAfter: index === pieces.length - 1 ? segment.pauseMsAfter : undefined,
|
|
671
|
+
}));
|
|
672
|
+
});
|
|
485
673
|
}
|
|
486
674
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
675
|
+
* Text-collection options for `locale`, with the provider's boundary-spacing
|
|
676
|
+
* mode when it names a known one.
|
|
489
677
|
*/
|
|
490
|
-
getTextProcessingOptions(
|
|
491
|
-
const
|
|
492
|
-
const textNormalization = (providerOptions.textNormalization ||
|
|
493
|
-
{});
|
|
494
|
-
const mode = textNormalization.boundarySpacingMode;
|
|
678
|
+
getTextProcessingOptions(locale) {
|
|
679
|
+
const mode = this.providerOptions().textNormalization?.boundarySpacingMode;
|
|
495
680
|
const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
|
|
496
681
|
? mode
|
|
497
682
|
: undefined;
|
|
498
|
-
const locale = language ||
|
|
499
|
-
(typeof textNormalization.locale === "string"
|
|
500
|
-
? textNormalization.locale
|
|
501
|
-
: undefined);
|
|
502
683
|
return { locale, boundarySpacingMode };
|
|
503
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* The language a read of `node` is in. Its content language is the nearest
|
|
687
|
+
* `lang` between it and its shell, else the language the speak names, else a
|
|
688
|
+
* pinned `lang_id`. Its locale is that content language, else the host's
|
|
689
|
+
* configured locale, else en-US.
|
|
690
|
+
*/
|
|
691
|
+
resolveReadLanguage(node, named) {
|
|
692
|
+
const contentLanguage = findContentLanguage(node, { contentLanguage: named }) ??
|
|
693
|
+
optionalString(this.providerOptions().lang_id);
|
|
694
|
+
return {
|
|
695
|
+
contentLanguage,
|
|
696
|
+
locale: contentLanguage ??
|
|
697
|
+
this.hostLocales.locale ??
|
|
698
|
+
this.hostLocales.textNormalization ??
|
|
699
|
+
DEFAULT_CONTENT_LANGUAGE,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The host's SRE options for math speech: from the installed source, else as
|
|
704
|
+
* this service was initialized with.
|
|
705
|
+
*/
|
|
504
706
|
getMathSpeechOptions() {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
707
|
+
if (this.mathSpeechSource) {
|
|
708
|
+
return normalizeSREMathSpeechOptions(this.mathSpeechSource());
|
|
709
|
+
}
|
|
710
|
+
return normalizeSREMathSpeechOptions(this.providerOptions().mathSpeech);
|
|
508
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Rebuilds normalizedToDOM from `element`'s visible text; traced runs also
|
|
714
|
+
* log how it differs from `spokenText`.
|
|
715
|
+
*/
|
|
509
716
|
buildPositionMap(element, spokenText, language) {
|
|
510
717
|
this.normalizedToDOM.clear();
|
|
511
718
|
const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
|
|
512
719
|
this.normalizedToDOM = map;
|
|
513
|
-
|
|
720
|
+
// The comparison below diffs the whole text, so it runs only when traced.
|
|
721
|
+
if (!isTtsDebugEnabled())
|
|
514
722
|
return;
|
|
515
|
-
|
|
723
|
+
logger.debug("Text comparison:", {
|
|
516
724
|
spokenLength: spokenText.length,
|
|
517
725
|
normalizedDomLength: normalizedDomText.length,
|
|
518
726
|
match: spokenText === normalizedDomText,
|
|
@@ -520,22 +728,9 @@ export class TTSService {
|
|
|
520
728
|
normalizedPreview: normalizedDomText.substring(0, 150),
|
|
521
729
|
differAtIndex: spokenText === normalizedDomText
|
|
522
730
|
? null
|
|
523
|
-
: (
|
|
524
|
-
for (let i = 0; i < Math.min(spokenText.length, normalizedDomText.length); i++) {
|
|
525
|
-
if (spokenText[i] !== normalizedDomText[i]) {
|
|
526
|
-
return {
|
|
527
|
-
index: i,
|
|
528
|
-
spokenChar: spokenText[i],
|
|
529
|
-
normalizedChar: normalizedDomText[i],
|
|
530
|
-
spokenContext: spokenText.substring(Math.max(0, i - 20), i + 20),
|
|
531
|
-
normalizedContext: normalizedDomText.substring(Math.max(0, i - 20), i + 20),
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return null;
|
|
536
|
-
})(),
|
|
731
|
+
: firstDifference(spokenText, normalizedDomText),
|
|
537
732
|
});
|
|
538
|
-
|
|
733
|
+
logger.debug("Position map built:", {
|
|
539
734
|
entries: this.normalizedToDOM.size,
|
|
540
735
|
spokenTextLength: spokenText.length,
|
|
541
736
|
normalizedDomLength: normalizedDomText.length,
|
|
@@ -551,9 +746,8 @@ export class TTSService {
|
|
|
551
746
|
});
|
|
552
747
|
}
|
|
553
748
|
getStructuralPauseProfile() {
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
const customUnits = (custom.units || {});
|
|
749
|
+
const custom = this.providerOptions().structuralPauses || {};
|
|
750
|
+
const customUnits = custom.units || {};
|
|
557
751
|
const toNumber = (value, fallback) => {
|
|
558
752
|
if (typeof value === "number" && Number.isFinite(value))
|
|
559
753
|
return value;
|
|
@@ -563,47 +757,42 @@ export class TTSService {
|
|
|
563
757
|
}
|
|
564
758
|
return fallback;
|
|
565
759
|
};
|
|
760
|
+
const defaults = DEFAULT_STRUCTURAL_PAUSE_PROFILE;
|
|
566
761
|
return {
|
|
567
|
-
baseMs: toNumber(custom.baseMs,
|
|
762
|
+
baseMs: toNumber(custom.baseMs, defaults.baseMs),
|
|
568
763
|
units: {
|
|
569
|
-
minor: toNumber(customUnits.minor,
|
|
570
|
-
section: toNumber(customUnits.section,
|
|
571
|
-
major: toNumber(customUnits.major,
|
|
764
|
+
minor: toNumber(customUnits.minor, defaults.units.minor),
|
|
765
|
+
section: toNumber(customUnits.section, defaults.units.section),
|
|
766
|
+
major: toNumber(customUnits.major, defaults.units.major),
|
|
572
767
|
},
|
|
573
|
-
minMs: toNumber(custom.minMs,
|
|
574
|
-
maxMs: toNumber(custom.maxMs,
|
|
768
|
+
minMs: toNumber(custom.minMs, defaults.minMs),
|
|
769
|
+
maxMs: toNumber(custom.maxMs, defaults.maxMs),
|
|
575
770
|
};
|
|
576
771
|
}
|
|
577
772
|
resolvePauseMsFromUnits(units) {
|
|
578
773
|
const profile = this.getStructuralPauseProfile();
|
|
579
|
-
const rate = Math.max(
|
|
774
|
+
const rate = Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, Number(this.ttsConfig.rate || 1)));
|
|
580
775
|
const raw = (profile.baseMs * Math.max(0, units)) / rate;
|
|
581
776
|
return Math.max(profile.minMs, Math.min(profile.maxMs, Math.round(raw)));
|
|
582
777
|
}
|
|
583
778
|
resolveHighlightMode() {
|
|
584
|
-
const
|
|
585
|
-
const configuredMode = providerOptions.highlightMode;
|
|
779
|
+
const configuredMode = this.providerOptions().highlightMode;
|
|
586
780
|
if (configuredMode === "word" || configuredMode === "sentence") {
|
|
587
781
|
return configuredMode;
|
|
588
782
|
}
|
|
589
|
-
const
|
|
590
|
-
if (
|
|
591
|
-
return
|
|
783
|
+
const capabilities = this.currentProvider?.getCapabilities();
|
|
784
|
+
if (capabilities?.defaultHighlightMode) {
|
|
785
|
+
return capabilities.defaultHighlightMode;
|
|
592
786
|
}
|
|
593
|
-
|
|
594
|
-
return supportsWordBoundary ? "word" : "sentence";
|
|
787
|
+
return capabilities?.supportsWordBoundary ? "word" : "sentence";
|
|
595
788
|
}
|
|
596
789
|
hasExplicitBreakSemantics(text) {
|
|
597
790
|
if (!text.includes("<"))
|
|
598
791
|
return false;
|
|
599
792
|
return /<\s*break\b|<\s*speak\b|<\s*prosody\b|<\s*p\b|<\s*s\b/i.test(text);
|
|
600
793
|
}
|
|
601
|
-
isElementHidden(element) {
|
|
602
|
-
return isElementHiddenForTTS(element);
|
|
603
|
-
}
|
|
604
794
|
getBoundaryStrength(element) {
|
|
605
|
-
|
|
606
|
-
if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
|
|
795
|
+
if (explicitBreakMsOf(element) !== null)
|
|
607
796
|
return "major";
|
|
608
797
|
const tagName = element.tagName.toUpperCase();
|
|
609
798
|
if (tagName.match(/^H[1-6]$/))
|
|
@@ -618,26 +807,16 @@ export class TTSService {
|
|
|
618
807
|
tagName === "LI") {
|
|
619
808
|
return "section";
|
|
620
809
|
}
|
|
621
|
-
if (typeof window !== "undefined") {
|
|
622
|
-
const display = window.getComputedStyle(element).display;
|
|
623
|
-
if (display === "block" ||
|
|
624
|
-
display === "list-item" ||
|
|
625
|
-
display.startsWith("table") ||
|
|
626
|
-
display === "flex" ||
|
|
627
|
-
display === "grid") {
|
|
628
|
-
return "minor";
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
810
|
return "minor";
|
|
632
811
|
}
|
|
633
812
|
getBoundaryAnchor(textNode, root) {
|
|
634
|
-
let current = textNode
|
|
813
|
+
let current = flatTreeParentElement(textNode);
|
|
635
814
|
let best = null;
|
|
636
815
|
while (current && current !== root) {
|
|
637
|
-
if (
|
|
816
|
+
if (isElementHiddenForTTS(current))
|
|
638
817
|
return null;
|
|
639
|
-
const explicitBreakMs =
|
|
640
|
-
if (
|
|
818
|
+
const explicitBreakMs = explicitBreakMsOf(current);
|
|
819
|
+
if (explicitBreakMs !== null) {
|
|
641
820
|
return {
|
|
642
821
|
anchor: current,
|
|
643
822
|
units: explicitBreakMs / this.getStructuralPauseProfile().baseMs,
|
|
@@ -652,19 +831,9 @@ export class TTSService {
|
|
|
652
831
|
best = current;
|
|
653
832
|
break;
|
|
654
833
|
}
|
|
655
|
-
if (!best)
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if (display === "block" ||
|
|
659
|
-
display === "list-item" ||
|
|
660
|
-
display.startsWith("table") ||
|
|
661
|
-
display === "flex" ||
|
|
662
|
-
display === "grid") {
|
|
663
|
-
best = current;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
current = current.parentElement;
|
|
834
|
+
if (!best && isBlockDisplay(current))
|
|
835
|
+
best = current;
|
|
836
|
+
current = flatTreeParentElement(current);
|
|
668
837
|
}
|
|
669
838
|
if (!best)
|
|
670
839
|
return null;
|
|
@@ -679,8 +848,8 @@ export class TTSService {
|
|
|
679
848
|
* boundaries are collected over the whole element, and a selection speaks only
|
|
680
849
|
* part of it.
|
|
681
850
|
*/
|
|
682
|
-
createSpeechPlan(contentElement, normalizedText, textOffset
|
|
683
|
-
const boundaries = this.collectSpeechPlanBoundaries(contentElement,
|
|
851
|
+
createSpeechPlan(contentElement, normalizedText, textOffset, language) {
|
|
852
|
+
const boundaries = this.collectSpeechPlanBoundaries(contentElement, language);
|
|
684
853
|
if (textOffset === 0) {
|
|
685
854
|
return this.createSpeechPlanSegments(normalizedText, boundaries);
|
|
686
855
|
}
|
|
@@ -690,18 +859,17 @@ export class TTSService {
|
|
|
690
859
|
}
|
|
691
860
|
return this.createSpeechPlanSegments(normalizedText, shifted);
|
|
692
861
|
}
|
|
693
|
-
collectSpeechPlanBoundaries(contentElement,
|
|
862
|
+
collectSpeechPlanBoundaries(contentElement, language) {
|
|
694
863
|
const boundaries = new Map();
|
|
695
|
-
|
|
864
|
+
// The same locale the position map used, so the boundary offsets index the
|
|
865
|
+
// text the map built.
|
|
866
|
+
const { map } = collectVisibleTextAndMap(contentElement, this.getTextProcessingOptions(language));
|
|
696
867
|
const nodeStartOffsets = this.createNodeStartOffsets(map);
|
|
697
|
-
const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT);
|
|
698
|
-
let currentNode = walker.nextNode();
|
|
699
868
|
let previousBoundaryAnchor = null;
|
|
700
|
-
|
|
701
|
-
const
|
|
702
|
-
const parent = textNode.parentElement;
|
|
869
|
+
for (const textNode of flatTreeTextNodes(contentElement)) {
|
|
870
|
+
const parent = flatTreeParentElement(textNode);
|
|
703
871
|
if (parent &&
|
|
704
|
-
!
|
|
872
|
+
!isElementHiddenForTTS(parent) &&
|
|
705
873
|
!isNodeSuppressedForTTS(textNode)) {
|
|
706
874
|
const boundary = this.getBoundaryAnchor(textNode, contentElement);
|
|
707
875
|
const boundaryPoint = nodeStartOffsets.get(textNode);
|
|
@@ -713,7 +881,6 @@ export class TTSService {
|
|
|
713
881
|
previousBoundaryAnchor = boundary.anchor;
|
|
714
882
|
}
|
|
715
883
|
}
|
|
716
|
-
currentNode = walker.nextNode();
|
|
717
884
|
}
|
|
718
885
|
return boundaries;
|
|
719
886
|
}
|
|
@@ -733,11 +900,7 @@ export class TTSService {
|
|
|
733
900
|
const segments = [];
|
|
734
901
|
let start = 0;
|
|
735
902
|
for (const point of points) {
|
|
736
|
-
const
|
|
737
|
-
const leading = raw.match(/^\s*/)?.[0].length || 0;
|
|
738
|
-
const trailing = raw.match(/\s*$/)?.[0].length || 0;
|
|
739
|
-
const segmentStart = start + leading;
|
|
740
|
-
const segmentEnd = point - trailing;
|
|
903
|
+
const { start: segmentStart, end: segmentEnd } = trimSpan(normalizedText, start, point);
|
|
741
904
|
if (segmentEnd > segmentStart) {
|
|
742
905
|
const text = normalizedText.substring(segmentStart, segmentEnd);
|
|
743
906
|
segments.push({
|
|
@@ -761,9 +924,7 @@ export class TTSService {
|
|
|
761
924
|
return segments.filter((segment) => segment.text.trim().length > 0);
|
|
762
925
|
}
|
|
763
926
|
segmentSentences(text) {
|
|
764
|
-
|
|
765
|
-
?.locale || undefined;
|
|
766
|
-
return segmentTextToSentences(text, { locale });
|
|
927
|
+
return segmentTextToSentences(text, { locale: this.readLocale });
|
|
767
928
|
}
|
|
768
929
|
createSeekSegmentsFromText(text) {
|
|
769
930
|
return this.segmentSentences(text)
|
|
@@ -797,11 +958,7 @@ export class TTSService {
|
|
|
797
958
|
}
|
|
798
959
|
let cursor = segmentStart;
|
|
799
960
|
for (const point of [...splitPoints, segmentEnd]) {
|
|
800
|
-
const
|
|
801
|
-
const leadingWhitespace = raw.match(/^\s*/)?.[0].length || 0;
|
|
802
|
-
const trailingWhitespace = raw.match(/\s*$/)?.[0].length || 0;
|
|
803
|
-
const startOffset = cursor + leadingWhitespace;
|
|
804
|
-
const endOffset = point - trailingWhitespace;
|
|
961
|
+
const { start: startOffset, end: endOffset } = trimSpan(sourceText, cursor, point);
|
|
805
962
|
if (endOffset > startOffset) {
|
|
806
963
|
result.push({
|
|
807
964
|
text: sourceText.substring(startOffset, endOffset),
|
|
@@ -825,6 +982,9 @@ export class TTSService {
|
|
|
825
982
|
return this.splitSegmentsAtBoundaries(grammaticalSegments, structuralBoundaries, args.contentToSpeak);
|
|
826
983
|
}
|
|
827
984
|
createSeekSegmentsFromSpeechChunks(chunks) {
|
|
985
|
+
// One seek segment per chunk, at offsets into a virtual text that joins the
|
|
986
|
+
// chunks with one separator character. A chunk whose speech is SSML is
|
|
987
|
+
// measured by its plain fallback or visible text.
|
|
828
988
|
let offset = 0;
|
|
829
989
|
return chunks.map((chunk) => {
|
|
830
990
|
const speechText = chunk.plainFallback?.speechText ||
|
|
@@ -838,23 +998,12 @@ export class TTSService {
|
|
|
838
998
|
startOffset: offset,
|
|
839
999
|
pauseMsAfter: 0,
|
|
840
1000
|
};
|
|
841
|
-
offset +=
|
|
1001
|
+
offset += text.length + 1;
|
|
842
1002
|
return segment;
|
|
843
1003
|
});
|
|
844
1004
|
}
|
|
845
1005
|
getCurrentSeekSegmentIndex() {
|
|
846
|
-
|
|
847
|
-
return 0;
|
|
848
|
-
let index = 0;
|
|
849
|
-
for (let i = 0; i < this.seekSegments.length; i++) {
|
|
850
|
-
if (this.seekSegments[i].startOffset <= this.currentBoundaryOffset) {
|
|
851
|
-
index = i;
|
|
852
|
-
}
|
|
853
|
-
else {
|
|
854
|
-
break;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
return index;
|
|
1006
|
+
return Math.max(0, this.getSegmentIndexForOffset(this.seekSegments, this.currentBoundaryOffset));
|
|
858
1007
|
}
|
|
859
1008
|
async speakWithPlan(segments, runId, options) {
|
|
860
1009
|
if (!this.provider || segments.length === 0)
|
|
@@ -863,6 +1012,10 @@ export class TTSService {
|
|
|
863
1012
|
const providerWithPlan = this.provider;
|
|
864
1013
|
if (!shouldTrackSentenceProgress &&
|
|
865
1014
|
typeof providerWithPlan.speakSegments === "function") {
|
|
1015
|
+
await this.waitWhilePaused(runId);
|
|
1016
|
+
if (runId !== this.speakRunId || this.provider !== providerWithPlan) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
866
1019
|
this.currentBoundaryOffset = 0;
|
|
867
1020
|
const originalOnWordBoundary = this.provider.onWordBoundary;
|
|
868
1021
|
this.provider.onWordBoundary = (word, position, length) => {
|
|
@@ -872,7 +1025,7 @@ export class TTSService {
|
|
|
872
1025
|
}
|
|
873
1026
|
};
|
|
874
1027
|
try {
|
|
875
|
-
await providerWithPlan.speakSegments(segments);
|
|
1028
|
+
await providerWithPlan.speakSegments(this.segmentsWithinLimit(segments));
|
|
876
1029
|
}
|
|
877
1030
|
finally {
|
|
878
1031
|
if (runId === this.speakRunId && this.provider === providerWithPlan) {
|
|
@@ -886,18 +1039,28 @@ export class TTSService {
|
|
|
886
1039
|
wordBoundaryOffset: this.activeWordBoundaryOffset,
|
|
887
1040
|
});
|
|
888
1041
|
for (const segment of segments) {
|
|
1042
|
+
await this.waitWhilePaused(runId);
|
|
889
1043
|
if (runId !== this.speakRunId)
|
|
890
1044
|
return;
|
|
891
1045
|
this.currentBoundaryOffset = segment.startOffset;
|
|
1046
|
+
const highlightSegment = () => {
|
|
1047
|
+
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
1048
|
+
};
|
|
892
1049
|
if (shouldTrackSentenceProgress) {
|
|
893
|
-
this.runWhenPlaybackStarts(runId,
|
|
894
|
-
|
|
895
|
-
|
|
1050
|
+
this.runWhenPlaybackStarts(runId, highlightSegment);
|
|
1051
|
+
}
|
|
1052
|
+
this.wordlessPartHighlight = highlightSegment;
|
|
1053
|
+
try {
|
|
1054
|
+
await this.speakWithinLimit(segment.text, runId);
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
if (this.wordlessPartHighlight === highlightSegment) {
|
|
1058
|
+
this.wordlessPartHighlight = null;
|
|
1059
|
+
}
|
|
896
1060
|
}
|
|
897
|
-
await this.provider.speak(segment.text);
|
|
898
1061
|
const pauseMs = segment.pauseMsAfter ?? 0;
|
|
899
|
-
if (pauseMs > 0) {
|
|
900
|
-
await
|
|
1062
|
+
if (pauseMs > 0 && runId === this.speakRunId) {
|
|
1063
|
+
await this.waitStructuralPause(pauseMs);
|
|
901
1064
|
}
|
|
902
1065
|
}
|
|
903
1066
|
}
|
|
@@ -917,10 +1080,11 @@ export class TTSService {
|
|
|
917
1080
|
const end = this.normalizedToDOM.get(mapStart + length - 1);
|
|
918
1081
|
if (!start || !end)
|
|
919
1082
|
return;
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1083
|
+
// One range per tree: a sentence running into a shadow root paints in both.
|
|
1084
|
+
const ranges = createRangesFromVisibleMap(this.normalizedToDOM, mapStart, mapStart + length);
|
|
1085
|
+
if (ranges.length === 0)
|
|
1086
|
+
return;
|
|
1087
|
+
this.paintTTSSentenceRanges(ranges);
|
|
924
1088
|
this.activeSentenceStartOffset = startOffset;
|
|
925
1089
|
}
|
|
926
1090
|
getSegmentIndexForOffset(segments, offset) {
|
|
@@ -952,66 +1116,180 @@ export class TTSService {
|
|
|
952
1116
|
this.highlightSentenceSegment(segment.startOffset, segment.text);
|
|
953
1117
|
}
|
|
954
1118
|
/**
|
|
955
|
-
*
|
|
1119
|
+
* Read `target` aloud: a range reads the text it selects, an element its
|
|
1120
|
+
* content. A node with a spoken card that the target holds whole reads its
|
|
1121
|
+
* card, `catalogId` names the card of the content root, and math reads as
|
|
1122
|
+
* math speech.
|
|
956
1123
|
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
1124
|
+
* Content marked not-to-be-spoken is never read, a named card included. When
|
|
1125
|
+
* the target holds nothing speakable, nothing is spoken and playback already
|
|
1126
|
+
* running continues.
|
|
960
1127
|
*/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1128
|
+
async speak(target, options = {}) {
|
|
1129
|
+
this.assertNotDisposed();
|
|
1130
|
+
const pendingReadiness = this.pendingReadiness();
|
|
1131
|
+
// A speak waiting on its provider is already the current run, so a pause
|
|
1132
|
+
// or stop issued meanwhile applies to it.
|
|
1133
|
+
const owner = options.ownerId ?? null;
|
|
1134
|
+
const waitingRunId = pendingReadiness ? this.beginRun(owner) : null;
|
|
1135
|
+
if (pendingReadiness) {
|
|
1136
|
+
try {
|
|
1137
|
+
await pendingReadiness;
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
if (waitingRunId === this.speakRunId)
|
|
1141
|
+
this.setState(PlaybackState.IDLE);
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
this.assertNotDisposed();
|
|
1145
|
+
if (waitingRunId !== this.speakRunId)
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (!this.provider) {
|
|
1149
|
+
if (waitingRunId !== null)
|
|
1150
|
+
this.setState(PlaybackState.IDLE);
|
|
1151
|
+
throw toTTSStartFailure(new Error("TTS service not initialized"));
|
|
1152
|
+
}
|
|
1153
|
+
const language = this.resolveReadLanguage(isRange(target) ? target.startContainer : target, options.language);
|
|
1154
|
+
const content = isRange(target)
|
|
1155
|
+
? this.resolveRangeTarget(target, options, language)
|
|
1156
|
+
: this.resolveElementTarget(target, options, language);
|
|
1157
|
+
if (!content) {
|
|
1158
|
+
if (waitingRunId !== null)
|
|
1159
|
+
this.setState(PlaybackState.IDLE);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const runId = waitingRunId ?? this.beginRun(owner);
|
|
1163
|
+
if (options.rate !== undefined) {
|
|
1164
|
+
await this.applyRunRate(options.rate);
|
|
1165
|
+
if (runId !== this.speakRunId)
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
await this.speakContent(content.text, content.options, runId);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Sets the rate a run starts at, after any rate write in flight. The run has
|
|
1172
|
+
* no playback yet, so nothing restarts.
|
|
1173
|
+
*/
|
|
1174
|
+
async applyRunRate(rate) {
|
|
1175
|
+
await this.playbackRateWriteQueue;
|
|
1176
|
+
const nextRate = this.normalizePlaybackRate(rate);
|
|
1177
|
+
const currentRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1178
|
+
if (nextRate === currentRate)
|
|
1179
|
+
return;
|
|
1180
|
+
await this.updateSettings({ rate: nextRate });
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Suppression is read from the live ancestors of what is read, because the
|
|
1184
|
+
* element carrying it may sit above the content root.
|
|
1185
|
+
*/
|
|
1186
|
+
isSuppressedTarget(node) {
|
|
1187
|
+
if (!isNodeSuppressedForTTS(node))
|
|
1188
|
+
return false;
|
|
1189
|
+
console.warn("[tts] the content to read lies inside content marked not-to-be-spoken; nothing was spoken.");
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
resolveElementTarget(root, options, language) {
|
|
1193
|
+
if (this.isSuppressedTarget(root))
|
|
1194
|
+
return null;
|
|
1195
|
+
const contentOptions = {
|
|
1196
|
+
catalogId: options.catalogId,
|
|
1197
|
+
catalogContext: options.catalogContext,
|
|
1198
|
+
language: language.locale,
|
|
1199
|
+
contentLanguage: language.contentLanguage,
|
|
1200
|
+
contentElement: root,
|
|
1201
|
+
};
|
|
1202
|
+
// Math-aware, so an element holding only an equation still reads; a spoken
|
|
1203
|
+
// card reads for content with no text, an image among them.
|
|
1204
|
+
const text = collectMathAwareTextAndMap(root, this.getTextProcessingOptions(language.locale)).visibleText.trim();
|
|
1205
|
+
if (!text && !this.holdsSpokenCard(root, contentOptions)) {
|
|
1206
|
+
console.warn("[tts] the content to read holds no speakable text; nothing was spoken.");
|
|
974
1207
|
return null;
|
|
975
1208
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1209
|
+
return { text, options: contentOptions };
|
|
1210
|
+
}
|
|
1211
|
+
/** Whether the root's card, or a card of a node inside it, would be read. */
|
|
1212
|
+
holdsSpokenCard(root, options) {
|
|
1213
|
+
if (!this.catalogResolver)
|
|
1214
|
+
return false;
|
|
1215
|
+
if (options.catalogId) {
|
|
1216
|
+
const card = this.catalogResolver.getAlternative(options.catalogId, {
|
|
1217
|
+
type: "spoken",
|
|
1218
|
+
language: options.language,
|
|
1219
|
+
useFallback: true,
|
|
1220
|
+
context: options.catalogContext,
|
|
1221
|
+
form: "content",
|
|
984
1222
|
});
|
|
1223
|
+
if (card?.content !== undefined)
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
return this.collectCatalogSpeechChunks(root, options).some((chunk) => chunk.sourceElement);
|
|
1227
|
+
}
|
|
1228
|
+
resolveRangeTarget(range, options, language) {
|
|
1229
|
+
if (this.isSuppressedTarget(range.commonAncestorContainer))
|
|
1230
|
+
return null;
|
|
1231
|
+
// Highlighting stays scoped to the content root when one is named, else to
|
|
1232
|
+
// the selection's nearest element.
|
|
1233
|
+
const ancestor = range.commonAncestorContainer;
|
|
1234
|
+
const root = options.contentRoot ||
|
|
1235
|
+
(ancestor.nodeType === Node.ELEMENT_NODE
|
|
1236
|
+
? ancestor
|
|
1237
|
+
: composedParentElement(ancestor));
|
|
1238
|
+
if (!root)
|
|
1239
|
+
return null;
|
|
1240
|
+
const selected = collectRangeTextForSpeech(range, root);
|
|
1241
|
+
const selectedText = selected.text.trim();
|
|
1242
|
+
if (!selectedText) {
|
|
1243
|
+
if (selected.filtered) {
|
|
1244
|
+
console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
|
|
1245
|
+
}
|
|
985
1246
|
return null;
|
|
986
1247
|
}
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1248
|
+
// The spoken text and its offset come from the root's visible-text map, which
|
|
1249
|
+
// the highlight position map and the structural speech plan index too. Joined
|
|
1250
|
+
// from raw text nodes instead, a selection loses the space the map inserts
|
|
1251
|
+
// between blocks, and every offset after that boundary drifts.
|
|
1252
|
+
const mapped = this.selectMappedRangeText(range, root, language.locale);
|
|
1253
|
+
let text = selectedText;
|
|
1254
|
+
let offset = mapped?.offset ?? 0;
|
|
1255
|
+
if (mapped) {
|
|
1256
|
+
text = mapped.text;
|
|
996
1257
|
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Filtered the same way as the speech itself: the offset indexes into the
|
|
1260
|
+
// highlight text, which comes from the exclusion-aware collectors.
|
|
1261
|
+
const beforeRange = document.createRange();
|
|
1262
|
+
beforeRange.selectNodeContents(root);
|
|
1263
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
1264
|
+
const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
|
|
1265
|
+
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
1266
|
+
offset =
|
|
1267
|
+
normalizedTextBeforeRange.length +
|
|
1268
|
+
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
1269
|
+
}
|
|
1270
|
+
logger.debug("selection offset calculation:", {
|
|
1271
|
+
selectedText: text,
|
|
1272
|
+
mapped: !!mapped,
|
|
1273
|
+
offset,
|
|
1274
|
+
rootTag: root.tagName,
|
|
1275
|
+
});
|
|
997
1276
|
return {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1277
|
+
text,
|
|
1278
|
+
options: {
|
|
1279
|
+
// The root's card reads only when the selection holds all of the root.
|
|
1280
|
+
catalogId: options.catalogId && rangeHoldsSpeakableElement(range, root, root)
|
|
1281
|
+
? options.catalogId
|
|
1282
|
+
: undefined,
|
|
1283
|
+
catalogContext: options.catalogContext,
|
|
1284
|
+
language: language.locale,
|
|
1285
|
+
contentLanguage: language.contentLanguage,
|
|
1286
|
+
contentElement: root,
|
|
1287
|
+
contentRange: range,
|
|
1288
|
+
wordBoundaryOffset: offset,
|
|
1289
|
+
},
|
|
1001
1290
|
};
|
|
1002
1291
|
}
|
|
1003
|
-
|
|
1004
|
-
* Speak text with optional catalog support
|
|
1005
|
-
*
|
|
1006
|
-
* @param text Text to speak (will be normalized: trimmed and whitespace collapsed)
|
|
1007
|
-
* @param options Optional catalog ID, language, and content element for highlighting
|
|
1008
|
-
*/
|
|
1009
|
-
async speak(text, options) {
|
|
1010
|
-
if (!this.provider) {
|
|
1011
|
-
throw new Error("TTS service not initialized");
|
|
1012
|
-
}
|
|
1013
|
-
const runId = ++this.speakRunId;
|
|
1014
|
-
this.clearPlaybackStartBarrier();
|
|
1292
|
+
async speakContent(text, options, runId) {
|
|
1015
1293
|
let playbackStartBarrier = null;
|
|
1016
1294
|
try {
|
|
1017
1295
|
await this.applyLanguageSettings(options);
|
|
@@ -1024,26 +1302,35 @@ export class TTSService {
|
|
|
1024
1302
|
this.logResolvedSpeechContent({
|
|
1025
1303
|
contentToSpeak,
|
|
1026
1304
|
speechSource,
|
|
1027
|
-
catalogId: options
|
|
1305
|
+
catalogId: options.catalogId,
|
|
1028
1306
|
});
|
|
1029
1307
|
this.initializeSpeakTracking(highlightText, options);
|
|
1030
|
-
const highlightMode =
|
|
1031
|
-
|
|
1308
|
+
const highlightMode = speechMatchesVisibleText
|
|
1309
|
+
? this.resolveHighlightMode()
|
|
1310
|
+
: "sentence";
|
|
1032
1311
|
this.activeHighlightMode = highlightMode;
|
|
1033
|
-
|
|
1312
|
+
const wordBoundaryOffset = options.wordBoundaryOffset || 0;
|
|
1313
|
+
this.activeWordBoundaryOffset = wordBoundaryOffset;
|
|
1034
1314
|
const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
|
|
1035
1315
|
const shouldUsePlan = !!this.currentContentElement &&
|
|
1036
1316
|
!usedCatalogSpoken &&
|
|
1037
1317
|
!hasExplicitBreaks &&
|
|
1038
1318
|
speechMatchesVisibleText;
|
|
1039
1319
|
this.playbackChunks = speechChunks?.length ? speechChunks : [];
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1320
|
+
if (this.playbackChunks.length) {
|
|
1321
|
+
this.seekSegments = this.createSeekSegmentsFromSpeechChunks(this.playbackChunks);
|
|
1322
|
+
}
|
|
1323
|
+
else if (hasExplicitBreaks || !speechMatchesVisibleText) {
|
|
1324
|
+
// Authored SSML breaks, and speech that differs from the visible text,
|
|
1325
|
+
// leave no visible-text offsets to seek to.
|
|
1326
|
+
this.seekSegments = [];
|
|
1327
|
+
}
|
|
1328
|
+
else if (shouldUsePlan && this.currentContentElement) {
|
|
1329
|
+
this.seekSegments = this.createSpeechPlan(this.currentContentElement, normalizedText, wordBoundaryOffset, options.language);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
this.seekSegments = this.createSeekSegmentsFromText(highlightText);
|
|
1333
|
+
}
|
|
1047
1334
|
this.sentenceHighlightSegments = hasExplicitBreaks
|
|
1048
1335
|
? []
|
|
1049
1336
|
: this.createSentenceHighlightSegments({
|
|
@@ -1051,7 +1338,6 @@ export class TTSService {
|
|
|
1051
1338
|
shouldUsePlan,
|
|
1052
1339
|
playbackSegments: this.seekSegments,
|
|
1053
1340
|
});
|
|
1054
|
-
this.setState(PlaybackState.LOADING);
|
|
1055
1341
|
playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
|
|
1056
1342
|
this.prepareHighlightsForSpeak({
|
|
1057
1343
|
contentToSpeak: highlightText,
|
|
@@ -1063,15 +1349,14 @@ export class TTSService {
|
|
|
1063
1349
|
if (speechMatchesVisibleText) {
|
|
1064
1350
|
this.configureWordBoundaryHighlighting({
|
|
1065
1351
|
highlightMode,
|
|
1066
|
-
wordBoundaryOffset
|
|
1352
|
+
wordBoundaryOffset,
|
|
1067
1353
|
});
|
|
1068
1354
|
}
|
|
1069
1355
|
else {
|
|
1070
1356
|
this.clearWordBoundaryHighlighting();
|
|
1071
1357
|
}
|
|
1072
|
-
if (!playbackStartBarrier)
|
|
1073
|
-
this.
|
|
1074
|
-
}
|
|
1358
|
+
if (!playbackStartBarrier)
|
|
1359
|
+
this.markPlaying();
|
|
1075
1360
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
1076
1361
|
await this.executeSpeakPlayback({
|
|
1077
1362
|
shouldUsePlan,
|
|
@@ -1086,146 +1371,68 @@ export class TTSService {
|
|
|
1086
1371
|
this.clearHighlightsAndTracking();
|
|
1087
1372
|
}
|
|
1088
1373
|
catch (error) {
|
|
1089
|
-
|
|
1090
|
-
console.error("TTS error:", finalError);
|
|
1091
|
-
this.lastError =
|
|
1092
|
-
finalError instanceof Error ? finalError.message : String(finalError);
|
|
1374
|
+
// A superseded run's failure is the abort that superseded it.
|
|
1093
1375
|
if (runId !== this.speakRunId)
|
|
1094
1376
|
return;
|
|
1377
|
+
console.error("TTS error:", error);
|
|
1378
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1095
1379
|
this.setState(PlaybackState.ERROR);
|
|
1096
1380
|
this.clearHighlightsAndTracking();
|
|
1097
|
-
throw
|
|
1381
|
+
throw error;
|
|
1098
1382
|
}
|
|
1099
1383
|
finally {
|
|
1100
1384
|
this.clearPlaybackStartBarrier(playbackStartBarrier);
|
|
1101
1385
|
}
|
|
1102
1386
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
// rejected initialization. Carry only settings whose meaning is portable
|
|
1110
|
-
// when crossing the provider boundary; otherwise a Polly/Google/SC voice
|
|
1111
|
-
// such as "Joanna" can incorrectly become an explicit Browser voice.
|
|
1112
|
-
const browserConfig = {};
|
|
1113
|
-
if (this.ttsConfig.rate !== undefined) {
|
|
1114
|
-
browserConfig.rate = this.ttsConfig.rate;
|
|
1115
|
-
}
|
|
1116
|
-
if (this.ttsConfig.pitch !== undefined) {
|
|
1117
|
-
browserConfig.pitch = this.ttsConfig.pitch;
|
|
1118
|
-
}
|
|
1119
|
-
if (this.ttsConfig.mathTokenHighlighting !== undefined) {
|
|
1120
|
-
browserConfig.mathTokenHighlighting =
|
|
1121
|
-
this.ttsConfig.mathTokenHighlighting;
|
|
1122
|
-
}
|
|
1123
|
-
const operation = context?.operation || "tts-initialize";
|
|
1124
|
-
const offendingText = context?.contentToSpeak ?? "";
|
|
1125
|
-
const offendingPreview = offendingText
|
|
1126
|
-
.replace(/\s+/g, " ")
|
|
1127
|
-
.trim()
|
|
1128
|
-
.slice(0, 120);
|
|
1129
|
-
const offendingDiagnostic = offendingText.length > 0
|
|
1130
|
-
? {
|
|
1131
|
-
textLength: offendingText.length,
|
|
1132
|
-
textPreview: offendingPreview.length < offendingText.length
|
|
1133
|
-
? `${offendingPreview}…`
|
|
1134
|
-
: offendingPreview,
|
|
1135
|
-
}
|
|
1136
|
-
: {};
|
|
1137
|
-
try {
|
|
1138
|
-
this.provider?.stop();
|
|
1139
|
-
this.currentProvider.destroy();
|
|
1140
|
-
this.currentProvider = fallbackProvider;
|
|
1141
|
-
this.provider = await fallbackProvider.initialize(browserConfig);
|
|
1142
|
-
this.ttsConfig = browserConfig;
|
|
1143
|
-
await this.emitTelemetry("pie-tool-runtime-fallback", {
|
|
1144
|
-
toolId: "textToSpeech",
|
|
1145
|
-
operation,
|
|
1146
|
-
fromProvider: previousProviderId,
|
|
1147
|
-
toProvider: fallbackProvider.providerId,
|
|
1148
|
-
reason: reason instanceof Error ? reason.message : String(reason),
|
|
1149
|
-
...offendingDiagnostic,
|
|
1150
|
-
});
|
|
1151
|
-
console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
|
|
1152
|
-
fromProvider: previousProviderId,
|
|
1153
|
-
reason,
|
|
1154
|
-
...offendingDiagnostic,
|
|
1155
|
-
});
|
|
1156
|
-
return true;
|
|
1157
|
-
}
|
|
1158
|
-
catch (fallbackError) {
|
|
1159
|
-
await this.emitTelemetry("pie-tool-runtime-fallback-error", {
|
|
1160
|
-
toolId: "textToSpeech",
|
|
1161
|
-
operation,
|
|
1162
|
-
fromProvider: previousProviderId,
|
|
1163
|
-
toProvider: "browser",
|
|
1164
|
-
errorType: "TTSRuntimeFallbackError",
|
|
1165
|
-
message: fallbackError instanceof Error
|
|
1166
|
-
? fallbackError.message
|
|
1167
|
-
: String(fallbackError),
|
|
1168
|
-
});
|
|
1169
|
-
console.error("[TTSService] Failed to switch to browser fallback provider", fallbackError);
|
|
1170
|
-
return false;
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
isBrowserSpeechFallbackAvailable() {
|
|
1174
|
-
if (typeof window === "undefined")
|
|
1175
|
-
return false;
|
|
1176
|
-
if (!("speechSynthesis" in window))
|
|
1177
|
-
return false;
|
|
1178
|
-
return (typeof globalThis
|
|
1179
|
-
.SpeechSynthesisUtterance === "function");
|
|
1180
|
-
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Hand the read's language to the provider. A read in a named content language
|
|
1389
|
+
* sets the provider's locales and `contentLanguage` to it, so a browser voice
|
|
1390
|
+
* follows it; a read naming none restores the host's locales and clears
|
|
1391
|
+
* `contentLanguage`, so the browser voice follows the browser's language.
|
|
1392
|
+
*/
|
|
1181
1393
|
async applyLanguageSettings(options) {
|
|
1182
|
-
if (!
|
|
1394
|
+
if (!this.provider)
|
|
1395
|
+
return;
|
|
1396
|
+
this.readLocale = options.language;
|
|
1397
|
+
const named = options.contentLanguage;
|
|
1398
|
+
const providerOptions = this.providerOptions();
|
|
1399
|
+
const next = {
|
|
1400
|
+
locale: named ?? this.hostLocales.locale,
|
|
1401
|
+
textNormalization: named ?? this.hostLocales.textNormalization,
|
|
1402
|
+
segmenter: named ?? this.hostLocales.segmenter,
|
|
1403
|
+
};
|
|
1404
|
+
if (providerOptions.contentLanguage === named &&
|
|
1405
|
+
optionalString(providerOptions.locale) === next.locale &&
|
|
1406
|
+
optionalString(providerOptions.textNormalization?.locale) ===
|
|
1407
|
+
next.textNormalization &&
|
|
1408
|
+
optionalString(providerOptions.segmenter?.locale) === next.segmenter) {
|
|
1183
1409
|
return;
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
{});
|
|
1187
|
-
const segmenter = (providerOptions.segmenter || {});
|
|
1410
|
+
}
|
|
1411
|
+
// Every key is set, undefined included: providers merge options shallowly.
|
|
1188
1412
|
const mergedProviderOptions = {
|
|
1189
1413
|
...providerOptions,
|
|
1190
|
-
locale:
|
|
1414
|
+
locale: next.locale,
|
|
1191
1415
|
textNormalization: {
|
|
1192
|
-
...textNormalization,
|
|
1193
|
-
locale:
|
|
1194
|
-
},
|
|
1195
|
-
segmenter: {
|
|
1196
|
-
...segmenter,
|
|
1197
|
-
locale: options.language,
|
|
1416
|
+
...providerOptions.textNormalization,
|
|
1417
|
+
locale: next.textNormalization,
|
|
1198
1418
|
},
|
|
1419
|
+
segmenter: { ...providerOptions.segmenter, locale: next.segmenter },
|
|
1420
|
+
contentLanguage: named,
|
|
1199
1421
|
};
|
|
1200
1422
|
this.ttsConfig = {
|
|
1201
1423
|
...this.ttsConfig,
|
|
1202
1424
|
providerOptions: mergedProviderOptions,
|
|
1203
1425
|
};
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
|
|
1208
|
-
}
|
|
1426
|
+
await this.provider.updateSettings({
|
|
1427
|
+
providerOptions: mergedProviderOptions,
|
|
1428
|
+
});
|
|
1209
1429
|
}
|
|
1210
1430
|
async resolveSpeechContent(text, options) {
|
|
1211
1431
|
const normalizedInputText = normalizeTextForSpeech(text);
|
|
1212
|
-
if (options
|
|
1213
|
-
return {
|
|
1214
|
-
contentToSpeak: normalizedInputText,
|
|
1215
|
-
speechText: normalizedInputText,
|
|
1216
|
-
visibleText: normalizedInputText,
|
|
1217
|
-
highlightText: normalizedInputText,
|
|
1218
|
-
usedCatalogSpoken: false,
|
|
1219
|
-
speechSource: "dom-or-input",
|
|
1220
|
-
normalizedText: normalizedInputText,
|
|
1221
|
-
containsMathMarkup: false,
|
|
1222
|
-
speechMatchesVisibleText: true,
|
|
1223
|
-
};
|
|
1224
|
-
}
|
|
1225
|
-
if (options?.catalogId && this.catalogResolver) {
|
|
1432
|
+
if (options.catalogId && this.catalogResolver) {
|
|
1226
1433
|
const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
|
|
1227
1434
|
type: "spoken",
|
|
1228
|
-
language: options.language
|
|
1435
|
+
language: options.language,
|
|
1229
1436
|
useFallback: true,
|
|
1230
1437
|
context: options.catalogContext,
|
|
1231
1438
|
form: "content",
|
|
@@ -1234,11 +1441,9 @@ export class TTSService {
|
|
|
1234
1441
|
// instance. Fall through to generated TTS rather than speaking "".
|
|
1235
1442
|
const spokenText = catalogContent?.content;
|
|
1236
1443
|
if (catalogContent && spokenText !== undefined) {
|
|
1237
|
-
const visibleText = options
|
|
1238
|
-
? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
|
|
1239
|
-
: normalizedInputText;
|
|
1444
|
+
const visibleText = collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
1240
1445
|
const normalizedCatalogText = normalizeTextForSpeech(spokenText);
|
|
1241
|
-
|
|
1446
|
+
logger.debug(`Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
|
|
1242
1447
|
return {
|
|
1243
1448
|
contentToSpeak: spokenText,
|
|
1244
1449
|
speechText: spokenText,
|
|
@@ -1251,24 +1456,12 @@ export class TTSService {
|
|
|
1251
1456
|
speechMatchesVisibleText: normalizedCatalogText === visibleText,
|
|
1252
1457
|
};
|
|
1253
1458
|
}
|
|
1254
|
-
|
|
1459
|
+
logger.debug(`No catalog found for "${options.catalogId}", falling back to generated TTS`);
|
|
1255
1460
|
}
|
|
1256
|
-
const composed = options
|
|
1257
|
-
? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
|
|
1258
|
-
: null;
|
|
1461
|
+
const composed = this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options);
|
|
1259
1462
|
if (composed)
|
|
1260
1463
|
return composed;
|
|
1261
|
-
const generated = options
|
|
1262
|
-
? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
|
|
1263
|
-
: {
|
|
1264
|
-
contentToSpeak: normalizedInputText,
|
|
1265
|
-
speechText: normalizedInputText,
|
|
1266
|
-
visibleText: normalizedInputText,
|
|
1267
|
-
highlightText: normalizedInputText,
|
|
1268
|
-
normalizedText: normalizedInputText,
|
|
1269
|
-
containsMathMarkup: false,
|
|
1270
|
-
speechMatchesVisibleText: true,
|
|
1271
|
-
};
|
|
1464
|
+
const generated = await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language, options.contentRange);
|
|
1272
1465
|
return {
|
|
1273
1466
|
...generated,
|
|
1274
1467
|
usedCatalogSpoken: false,
|
|
@@ -1282,7 +1475,11 @@ export class TTSService {
|
|
|
1282
1475
|
if (!chunks.some((chunk) => chunk.sourceElement))
|
|
1283
1476
|
return null;
|
|
1284
1477
|
const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
|
|
1285
|
-
|
|
1478
|
+
// A selection's visible text is the text it holds, which the caller passes:
|
|
1479
|
+
// the highlight offsets index it from `wordBoundaryOffset`.
|
|
1480
|
+
const visibleText = options.contentRange
|
|
1481
|
+
? normalizedInputText
|
|
1482
|
+
: collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
1286
1483
|
return {
|
|
1287
1484
|
contentToSpeak: speechText,
|
|
1288
1485
|
speechText,
|
|
@@ -1297,6 +1494,9 @@ export class TTSService {
|
|
|
1297
1494
|
};
|
|
1298
1495
|
}
|
|
1299
1496
|
collectCatalogSpeechChunks(root, options) {
|
|
1497
|
+
const catalogResolver = this.catalogResolver;
|
|
1498
|
+
if (!catalogResolver)
|
|
1499
|
+
return [];
|
|
1300
1500
|
const chunks = [];
|
|
1301
1501
|
let textBuffer = "";
|
|
1302
1502
|
const flushTextBuffer = () => {
|
|
@@ -1311,27 +1511,36 @@ export class TTSService {
|
|
|
1311
1511
|
speechMatchesVisibleText: true,
|
|
1312
1512
|
});
|
|
1313
1513
|
};
|
|
1514
|
+
const range = options.contentRange;
|
|
1314
1515
|
const resolveCatalog = (element) => {
|
|
1315
1516
|
const catalogIdRef = element.getAttribute("data-catalog-idref");
|
|
1316
1517
|
if (!catalogIdRef)
|
|
1317
1518
|
return null;
|
|
1519
|
+
// A card reads its whole node, so it stands in for a selection's part of
|
|
1520
|
+
// the node only when the selection holds all of it. A part reads as the
|
|
1521
|
+
// visible text selected.
|
|
1522
|
+
if (range && !rangeHoldsSpeakableElement(range, element, root)) {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1318
1525
|
const lookup = {
|
|
1319
1526
|
type: "spoken",
|
|
1320
|
-
language:
|
|
1527
|
+
// The node's own language first: a `lang` inside the read content
|
|
1528
|
+
// names the language of the part it marks.
|
|
1529
|
+
language: findLangAttribute(element, root) || options.language,
|
|
1321
1530
|
useFallback: true,
|
|
1322
1531
|
context: options.catalogContext,
|
|
1323
1532
|
};
|
|
1324
1533
|
// Two lookups rather than one, because `form` is a preference: asking for
|
|
1325
1534
|
// the payload form happily returns a script card when no recording
|
|
1326
1535
|
// exists, so the answer has to be checked rather than assumed.
|
|
1327
|
-
const audioCard =
|
|
1536
|
+
const audioCard = catalogResolver.getAlternative(catalogIdRef, {
|
|
1328
1537
|
...lookup,
|
|
1329
1538
|
form: "payload",
|
|
1330
1539
|
});
|
|
1331
1540
|
const audio = audioCard?.payload
|
|
1332
1541
|
? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
|
|
1333
1542
|
: undefined;
|
|
1334
|
-
const scriptCard =
|
|
1543
|
+
const scriptCard = catalogResolver.getAlternative(catalogIdRef, {
|
|
1335
1544
|
...lookup,
|
|
1336
1545
|
form: "content",
|
|
1337
1546
|
});
|
|
@@ -1354,7 +1563,7 @@ export class TTSService {
|
|
|
1354
1563
|
: null;
|
|
1355
1564
|
};
|
|
1356
1565
|
const getMathElementsForAlignment = (element) => {
|
|
1357
|
-
const mathElements =
|
|
1566
|
+
const mathElements = flatQuerySelectorAll(element, "math");
|
|
1358
1567
|
if (element.localName?.toLowerCase() === "math") {
|
|
1359
1568
|
return [element, ...mathElements];
|
|
1360
1569
|
}
|
|
@@ -1367,7 +1576,12 @@ export class TTSService {
|
|
|
1367
1576
|
if (isNodeExcludedFromSpeech(node, root))
|
|
1368
1577
|
return;
|
|
1369
1578
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
1370
|
-
|
|
1579
|
+
if (!range) {
|
|
1580
|
+
textBuffer += ` ${node.textContent || ""}`;
|
|
1581
|
+
}
|
|
1582
|
+
else if (rangeIntersectsComposedNode(range, node)) {
|
|
1583
|
+
textBuffer += ` ${textInRange(node, range)}`;
|
|
1584
|
+
}
|
|
1371
1585
|
return;
|
|
1372
1586
|
}
|
|
1373
1587
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
@@ -1378,49 +1592,47 @@ export class TTSService {
|
|
|
1378
1592
|
flushTextBuffer();
|
|
1379
1593
|
const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
|
|
1380
1594
|
const visibleText = collectedVisible.visibleText ||
|
|
1381
|
-
normalizeTextForSpeech(element
|
|
1595
|
+
normalizeTextForSpeech(flatTextContent(element));
|
|
1382
1596
|
const regionElement = resolveReadableRegion(element, root);
|
|
1383
|
-
// The script chunk
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1597
|
+
// The script chunk doubles as the recording's fallback: word-level
|
|
1598
|
+
// alignment is meaningful only on the synthesized path, which is the one
|
|
1599
|
+
// that runs if the audio cannot play.
|
|
1600
|
+
let scriptChunk = null;
|
|
1601
|
+
if (catalog.script !== undefined) {
|
|
1602
|
+
const script = catalog.script;
|
|
1603
|
+
const alignment = createCatalogSpanAlignment({
|
|
1604
|
+
speechText: script,
|
|
1605
|
+
visibleText,
|
|
1606
|
+
});
|
|
1607
|
+
const mathElement = getSingleMathElementForAlignment(element, visibleText);
|
|
1608
|
+
const mathAlignment = mathElement
|
|
1609
|
+
? createMathAwareAlignment({
|
|
1610
|
+
mathElement,
|
|
1391
1611
|
speechText: script,
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1612
|
+
})
|
|
1613
|
+
: undefined;
|
|
1614
|
+
const mathAlignments = mathAlignment
|
|
1615
|
+
? undefined
|
|
1616
|
+
: getMathElementsForAlignment(element).map((candidate) => ({
|
|
1617
|
+
element: candidate,
|
|
1618
|
+
alignment: createMathAwareAlignment({
|
|
1619
|
+
mathElement: candidate,
|
|
1398
1620
|
speechText: script,
|
|
1399
|
-
})
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
regionElement,
|
|
1415
|
-
speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
|
|
1416
|
-
playbackMode: alignment.playbackMode,
|
|
1417
|
-
alignment,
|
|
1418
|
-
mathAlignment,
|
|
1419
|
-
mathAlignments,
|
|
1420
|
-
visibleMap: collectedVisible.map,
|
|
1421
|
-
};
|
|
1422
|
-
})()
|
|
1423
|
-
: null;
|
|
1621
|
+
}),
|
|
1622
|
+
}));
|
|
1623
|
+
scriptChunk = {
|
|
1624
|
+
speechText: script,
|
|
1625
|
+
visibleText,
|
|
1626
|
+
sourceElement: element,
|
|
1627
|
+
regionElement,
|
|
1628
|
+
speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
|
|
1629
|
+
playbackMode: alignment.playbackMode,
|
|
1630
|
+
alignment,
|
|
1631
|
+
mathAlignment,
|
|
1632
|
+
mathAlignments,
|
|
1633
|
+
visibleMap: collectedVisible.map,
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1424
1636
|
if (catalog.audio) {
|
|
1425
1637
|
chunks.push(scriptChunk
|
|
1426
1638
|
? {
|
|
@@ -1446,7 +1658,7 @@ export class TTSService {
|
|
|
1446
1658
|
chunks.push(scriptChunk);
|
|
1447
1659
|
return;
|
|
1448
1660
|
}
|
|
1449
|
-
for (const child of
|
|
1661
|
+
for (const child of flatTreeChildNodes(element)) {
|
|
1450
1662
|
visit(child);
|
|
1451
1663
|
}
|
|
1452
1664
|
};
|
|
@@ -1468,12 +1680,13 @@ export class TTSService {
|
|
|
1468
1680
|
const provider = this.currentProvider;
|
|
1469
1681
|
if (!provider)
|
|
1470
1682
|
return "plain";
|
|
1471
|
-
// Belt-and-suspenders: the browser provider can never voice SSML.
|
|
1472
|
-
if (provider.providerId?.toLowerCase() === "browser")
|
|
1473
|
-
return "plain";
|
|
1474
1683
|
return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
|
|
1475
1684
|
}
|
|
1476
|
-
|
|
1685
|
+
/**
|
|
1686
|
+
* With `range`, the selection only: an equation it holds whole reads as math
|
|
1687
|
+
* speech, and a selection without one reads the selected text, `normalizedInputText`.
|
|
1688
|
+
*/
|
|
1689
|
+
async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language, range) {
|
|
1477
1690
|
// Delegate to the generated-speech module: a pure plan assembly + SRE
|
|
1478
1691
|
// memoization core, plus a DOM adapter that binds live-DOM anchors and
|
|
1479
1692
|
// emits playback chunks. The aggregate `contentToSpeak` stays plain text
|
|
@@ -1481,14 +1694,19 @@ export class TTSService {
|
|
|
1481
1694
|
const playbackFormat = this.resolveGeneratedPlaybackFormat();
|
|
1482
1695
|
const { plan, containsMathMarkup, visibleText: collectedVisibleText, } = await buildGeneratedSpeechFromRoot({
|
|
1483
1696
|
contentRoot: contentElement,
|
|
1697
|
+
range,
|
|
1484
1698
|
language,
|
|
1485
1699
|
mathSpeech: this.getMathSpeechOptions(),
|
|
1486
1700
|
textProcessingOptions: this.getTextProcessingOptions(language),
|
|
1487
1701
|
produceSsml: playbackFormat === "ssml",
|
|
1488
1702
|
resolveMathSpeech: this.generatedMathSpeechResolver,
|
|
1489
1703
|
});
|
|
1490
|
-
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1491
1704
|
if (!containsMathMarkup) {
|
|
1705
|
+
// The input text is the selection's text in the root's visible-text map,
|
|
1706
|
+
// which its highlight offsets index.
|
|
1707
|
+
const visibleText = range
|
|
1708
|
+
? normalizedInputText
|
|
1709
|
+
: collectedVisibleText || normalizedInputText;
|
|
1492
1710
|
return {
|
|
1493
1711
|
contentToSpeak: visibleText,
|
|
1494
1712
|
speechText: visibleText,
|
|
@@ -1499,6 +1717,7 @@ export class TTSService {
|
|
|
1499
1717
|
speechMatchesVisibleText: true,
|
|
1500
1718
|
};
|
|
1501
1719
|
}
|
|
1720
|
+
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1502
1721
|
const speechChunks = planToCompositionChunkInputs(plan, {
|
|
1503
1722
|
format: playbackFormat,
|
|
1504
1723
|
});
|
|
@@ -1519,7 +1738,7 @@ export class TTSService {
|
|
|
1519
1738
|
.replace(/\s+/g, " ")
|
|
1520
1739
|
.trim()
|
|
1521
1740
|
.slice(0, 200);
|
|
1522
|
-
|
|
1741
|
+
logger.debug("Speak resolved content", {
|
|
1523
1742
|
source: args.speechSource,
|
|
1524
1743
|
catalogId: args.catalogId || null,
|
|
1525
1744
|
length: args.contentToSpeak.length,
|
|
@@ -1528,7 +1747,7 @@ export class TTSService {
|
|
|
1528
1747
|
}
|
|
1529
1748
|
initializeSpeakTracking(contentToSpeak, options) {
|
|
1530
1749
|
this.currentText = contentToSpeak;
|
|
1531
|
-
this.currentContentElement = options
|
|
1750
|
+
this.currentContentElement = options.contentElement;
|
|
1532
1751
|
this.lastError = null;
|
|
1533
1752
|
this.currentBoundaryOffset = 0;
|
|
1534
1753
|
this.playbackChunks = [];
|
|
@@ -1537,7 +1756,7 @@ export class TTSService {
|
|
|
1537
1756
|
prepareHighlightsForSpeak(args) {
|
|
1538
1757
|
if (!this.currentContentElement || !this.highlightCoordinator)
|
|
1539
1758
|
return;
|
|
1540
|
-
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options
|
|
1759
|
+
this.buildPositionMap(this.currentContentElement, args.contentToSpeak, args.options.language);
|
|
1541
1760
|
if (args.highlightMode === "sentence" && args.shouldUsePlan) {
|
|
1542
1761
|
return;
|
|
1543
1762
|
}
|
|
@@ -1545,7 +1764,7 @@ export class TTSService {
|
|
|
1545
1764
|
this.runWhenPlaybackStarts(args.runId, () => {
|
|
1546
1765
|
if (initialSegment) {
|
|
1547
1766
|
this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
|
|
1548
|
-
|
|
1767
|
+
logger.debug("Applied initial sentence highlighting");
|
|
1549
1768
|
}
|
|
1550
1769
|
else {
|
|
1551
1770
|
try {
|
|
@@ -1560,34 +1779,29 @@ export class TTSService {
|
|
|
1560
1779
|
});
|
|
1561
1780
|
}
|
|
1562
1781
|
configureWordBoundaryHighlighting(args) {
|
|
1563
|
-
if (!this.provider
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1782
|
+
if (!this.provider)
|
|
1783
|
+
return;
|
|
1784
|
+
// A sentence-mode read paints no words, so it takes no word boundaries.
|
|
1785
|
+
if (args.highlightMode === "sentence") {
|
|
1786
|
+
this.provider.onWordBoundary = undefined;
|
|
1567
1787
|
return;
|
|
1568
1788
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1789
|
+
if (!this.highlightCoordinator || !this.currentContentElement)
|
|
1790
|
+
return;
|
|
1791
|
+
// Browser boundaries and the server provider's time-scaled boundaries index
|
|
1792
|
+
// the same spoken text.
|
|
1571
1793
|
this.provider.onWordBoundary = (word, charIndex, length) => {
|
|
1572
1794
|
const wordLength = length || word.length;
|
|
1573
1795
|
const spokenIndex = charIndex + this.currentBoundaryOffset;
|
|
1574
1796
|
const globalIndex = spokenIndex + args.wordBoundaryOffset;
|
|
1575
1797
|
this.highlightSentenceForOffset(spokenIndex);
|
|
1576
|
-
const
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const nativeRange = this.createTextRange(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
1581
|
-
if (nativeRange) {
|
|
1582
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(nativeRange).range);
|
|
1583
|
-
}
|
|
1584
|
-
else {
|
|
1585
|
-
this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
else {
|
|
1589
|
-
console.warn(`[TTSService] Could not find highlight range for position ${globalIndex}, length ${wordLength}`);
|
|
1798
|
+
const ranges = createRangesFromVisibleMap(this.normalizedToDOM, globalIndex, globalIndex + wordLength);
|
|
1799
|
+
if (ranges.length === 0) {
|
|
1800
|
+
logger.debug(`no text at position ${globalIndex}, length ${wordLength}, to highlight`);
|
|
1801
|
+
return;
|
|
1590
1802
|
}
|
|
1803
|
+
logger.debug(`Highlighting "${ranges.join("")}" (word: "${word}") at position ${globalIndex}`);
|
|
1804
|
+
this.paintTTSWord(ranges);
|
|
1591
1805
|
};
|
|
1592
1806
|
}
|
|
1593
1807
|
clearWordBoundaryHighlighting() {
|
|
@@ -1602,26 +1816,22 @@ export class TTSService {
|
|
|
1602
1816
|
try {
|
|
1603
1817
|
this.lastRenderedRegionTarget = null;
|
|
1604
1818
|
if (chunk.regionRange) {
|
|
1605
|
-
|
|
1606
|
-
this.
|
|
1819
|
+
const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
|
|
1820
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1821
|
+
this.paintTTSSentenceRanges(ranges.length > 1 ? ranges : [chunk.regionRange]);
|
|
1607
1822
|
return;
|
|
1608
1823
|
}
|
|
1609
1824
|
if (!element)
|
|
1610
1825
|
return;
|
|
1611
1826
|
const range = document.createRange();
|
|
1612
1827
|
range.selectNodeContents(element);
|
|
1613
|
-
this.highlightCoordinator.
|
|
1828
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1614
1829
|
this.paintTTSSentenceRanges([range]);
|
|
1615
1830
|
}
|
|
1616
1831
|
catch {
|
|
1617
1832
|
// Continue playback even when a detached node cannot be highlighted.
|
|
1618
1833
|
}
|
|
1619
1834
|
}
|
|
1620
|
-
highlightCatalogActiveRange(range) {
|
|
1621
|
-
if (!this.highlightCoordinator)
|
|
1622
|
-
return;
|
|
1623
|
-
this.paintTTSWordRange(this.resolveWordHighlightRange(range).range);
|
|
1624
|
-
}
|
|
1625
1835
|
highlightRenderableRegionTarget(target) {
|
|
1626
1836
|
if (!this.highlightCoordinator ||
|
|
1627
1837
|
!target ||
|
|
@@ -1638,7 +1848,7 @@ export class TTSService {
|
|
|
1638
1848
|
range.selectNodeContents(target.element);
|
|
1639
1849
|
}
|
|
1640
1850
|
else if (target.type === "range") {
|
|
1641
|
-
this.paintTTSSentenceRanges([target.range]);
|
|
1851
|
+
this.paintTTSSentenceRanges(target.ranges ?? [target.range]);
|
|
1642
1852
|
this.lastRenderedRegionTarget = target;
|
|
1643
1853
|
return;
|
|
1644
1854
|
}
|
|
@@ -1663,28 +1873,24 @@ export class TTSService {
|
|
|
1663
1873
|
// Clear the word layer so the previously spoken word does not stay
|
|
1664
1874
|
// highlighted. (The "hold last token" behavior returns a non-null
|
|
1665
1875
|
// target, so it does not reach here.)
|
|
1666
|
-
this.highlightCoordinator.
|
|
1876
|
+
this.highlightCoordinator.clearTTSWord();
|
|
1667
1877
|
return;
|
|
1668
1878
|
}
|
|
1669
1879
|
if (target.type === "text-range") {
|
|
1670
1880
|
const nativeRange = this.createTextRange(target.node, target.startOffset, target.endOffset);
|
|
1671
|
-
if (nativeRange)
|
|
1672
|
-
this.
|
|
1673
|
-
}
|
|
1674
|
-
else {
|
|
1675
|
-
this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
|
|
1676
|
-
}
|
|
1881
|
+
if (nativeRange)
|
|
1882
|
+
this.paintTTSWord([nativeRange]);
|
|
1677
1883
|
return;
|
|
1678
1884
|
}
|
|
1679
1885
|
if (target.type === "range") {
|
|
1680
|
-
this.
|
|
1886
|
+
this.paintTTSWord(target.ranges ?? [target.range]);
|
|
1681
1887
|
return;
|
|
1682
1888
|
}
|
|
1683
1889
|
const elementNativeRange = document.createRange();
|
|
1684
1890
|
elementNativeRange.selectNodeContents(target.element);
|
|
1685
1891
|
const resolvedElementRange = this.resolveWordHighlightRange(elementNativeRange);
|
|
1686
1892
|
if (resolvedElementRange.remapped) {
|
|
1687
|
-
this.
|
|
1893
|
+
this.highlightCoordinator.highlightTTSWord([resolvedElementRange.range]);
|
|
1688
1894
|
return;
|
|
1689
1895
|
}
|
|
1690
1896
|
// Element targets are atomic: a resolved math token, a whole-expression
|
|
@@ -1699,10 +1905,12 @@ export class TTSService {
|
|
|
1699
1905
|
const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
|
|
1700
1906
|
if (target.quality === "semantic-token" &&
|
|
1701
1907
|
onlyChild?.nodeType === Node.TEXT_NODE) {
|
|
1702
|
-
|
|
1908
|
+
const tokenRange = document.createRange();
|
|
1909
|
+
tokenRange.selectNodeContents(onlyChild);
|
|
1910
|
+
this.highlightCoordinator.highlightTTSWord([tokenRange]);
|
|
1703
1911
|
return;
|
|
1704
1912
|
}
|
|
1705
|
-
this.highlightCoordinator.highlightTTSWordElement
|
|
1913
|
+
this.highlightCoordinator.highlightTTSWordElement(target.element);
|
|
1706
1914
|
}
|
|
1707
1915
|
renderHighlightDecision(decision) {
|
|
1708
1916
|
this.highlightRenderableRegionTarget(decision.regionTarget);
|
|
@@ -1713,21 +1921,24 @@ export class TTSService {
|
|
|
1713
1921
|
await this.speakCatalogChunkOnce(chunk, runId);
|
|
1714
1922
|
}
|
|
1715
1923
|
catch (error) {
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
// fallback, so this cannot recurse.
|
|
1924
|
+
// A chunk with a plainFallback retries once with it when speaking fails:
|
|
1925
|
+
// a rejected SSML math chunk falls back to its plain-text variant, a
|
|
1926
|
+
// recording that cannot play to its script. The fallback carries no
|
|
1927
|
+
// fallback of its own, so this cannot recurse.
|
|
1720
1928
|
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
//
|
|
1929
|
+
logger.debug("SSML chunk speak failed; retrying plain text", {
|
|
1930
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1931
|
+
});
|
|
1932
|
+
// A run that began with a recording holds the start barrier for the
|
|
1933
|
+
// recording's start signal. A provider with no start signal of its own
|
|
1934
|
+
// would never release it, so drop the recording's queued highlight and
|
|
1935
|
+
// mark the run playing now.
|
|
1725
1936
|
if (this.playbackStartDeferredRunId === runId &&
|
|
1726
1937
|
this.provider &&
|
|
1727
1938
|
!("onPlaybackStart" in this.provider)) {
|
|
1728
1939
|
this.pendingPlaybackStartHighlights = [];
|
|
1729
1940
|
this.clearPlaybackStartBarrier();
|
|
1730
|
-
this.
|
|
1941
|
+
this.markPlaying();
|
|
1731
1942
|
}
|
|
1732
1943
|
await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
|
|
1733
1944
|
return;
|
|
@@ -1787,6 +1998,18 @@ export class TTSService {
|
|
|
1787
1998
|
cleanup();
|
|
1788
1999
|
reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
|
|
1789
2000
|
};
|
|
2001
|
+
// A pause before the clip starts rejects its play with an AbortError;
|
|
2002
|
+
// resume plays it, so that rejection is no failure of the clip.
|
|
2003
|
+
const play = () => {
|
|
2004
|
+
Promise.resolve(element.play())
|
|
2005
|
+
.then(markStarted)
|
|
2006
|
+
.catch((error) => {
|
|
2007
|
+
const aborted = error?.name === "AbortError";
|
|
2008
|
+
if (aborted && this.state === PlaybackState.PAUSED)
|
|
2009
|
+
return;
|
|
2010
|
+
onError();
|
|
2011
|
+
});
|
|
2012
|
+
};
|
|
1790
2013
|
// Cancellation has to settle this promise, not just stop the element:
|
|
1791
2014
|
// `stop()` bumps the run id, and a pending play that never resolves would
|
|
1792
2015
|
// wedge the chunk loop on a run nobody is listening to any more.
|
|
@@ -1800,6 +2023,7 @@ export class TTSService {
|
|
|
1800
2023
|
element.pause();
|
|
1801
2024
|
resolve();
|
|
1802
2025
|
},
|
|
2026
|
+
play,
|
|
1803
2027
|
};
|
|
1804
2028
|
element.addEventListener("ended", onEnded);
|
|
1805
2029
|
element.addEventListener("error", onError);
|
|
@@ -1807,7 +2031,7 @@ export class TTSService {
|
|
|
1807
2031
|
// Reaching the slice's end is this clip finishing, so the chunk sequence
|
|
1808
2032
|
// advances rather than the element merely pausing.
|
|
1809
2033
|
disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
|
|
1810
|
-
|
|
2034
|
+
play();
|
|
1811
2035
|
}).finally(() => {
|
|
1812
2036
|
if (this.activeRecordedAudio?.element === element) {
|
|
1813
2037
|
this.activeRecordedAudio = null;
|
|
@@ -1852,6 +2076,9 @@ export class TTSService {
|
|
|
1852
2076
|
mathTokenHighlighting,
|
|
1853
2077
|
})
|
|
1854
2078
|
: null;
|
|
2079
|
+
const providerOffsetSpace = !pipelineChunk || pipelineChunk.offsetSpace === "unsupported"
|
|
2080
|
+
? "unknown"
|
|
2081
|
+
: pipelineChunk.offsetSpace;
|
|
1855
2082
|
this.runWhenPlaybackStarts(runId, () => {
|
|
1856
2083
|
if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
|
|
1857
2084
|
this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
|
|
@@ -1860,7 +2087,9 @@ export class TTSService {
|
|
|
1860
2087
|
this.highlightCatalogRegion(chunk);
|
|
1861
2088
|
}
|
|
1862
2089
|
});
|
|
1863
|
-
|
|
2090
|
+
// A sentence-mode read takes no word boundaries, aligned chunk or not.
|
|
2091
|
+
const canUseChunkBoundaries = this.resolveHighlightMode() === "word" &&
|
|
2092
|
+
chunk.sourceElement &&
|
|
1864
2093
|
((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
|
|
1865
2094
|
(chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
|
|
1866
2095
|
(chunk.alignment &&
|
|
@@ -1872,7 +2101,7 @@ export class TTSService {
|
|
|
1872
2101
|
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1873
2102
|
provider.onWordBoundary = undefined;
|
|
1874
2103
|
try {
|
|
1875
|
-
await
|
|
2104
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1876
2105
|
}
|
|
1877
2106
|
finally {
|
|
1878
2107
|
if (this.provider === provider &&
|
|
@@ -1886,25 +2115,19 @@ export class TTSService {
|
|
|
1886
2115
|
const provider = this.provider;
|
|
1887
2116
|
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1888
2117
|
const installedHandler = (word, position, length) => {
|
|
1889
|
-
if (runId !== this.speakRunId)
|
|
1890
|
-
return;
|
|
1891
|
-
if (highlightPlan && pipelineChunk) {
|
|
1892
|
-
const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
|
|
1893
|
-
? "unknown"
|
|
1894
|
-
: pipelineChunk.offsetSpace;
|
|
1895
|
-
this.renderHighlightDecision(highlightPlan.resolveBoundary({
|
|
1896
|
-
chunkId: pipelineChunk.id,
|
|
1897
|
-
word,
|
|
1898
|
-
position,
|
|
1899
|
-
length,
|
|
1900
|
-
providerOffsetSpace,
|
|
1901
|
-
}));
|
|
2118
|
+
if (runId !== this.speakRunId || !highlightPlan || !pipelineChunk)
|
|
1902
2119
|
return;
|
|
1903
|
-
|
|
2120
|
+
this.renderHighlightDecision(highlightPlan.resolveBoundary({
|
|
2121
|
+
chunkId: pipelineChunk.id,
|
|
2122
|
+
word,
|
|
2123
|
+
position,
|
|
2124
|
+
length,
|
|
2125
|
+
providerOffsetSpace,
|
|
2126
|
+
}));
|
|
1904
2127
|
};
|
|
1905
2128
|
provider.onWordBoundary = installedHandler;
|
|
1906
2129
|
try {
|
|
1907
|
-
await
|
|
2130
|
+
await this.speakWithinLimit(chunk.speechText, runId);
|
|
1908
2131
|
}
|
|
1909
2132
|
finally {
|
|
1910
2133
|
if (this.provider === provider &&
|
|
@@ -1917,6 +2140,7 @@ export class TTSService {
|
|
|
1917
2140
|
async executeSpeakPlayback(args) {
|
|
1918
2141
|
if (args.speechChunks?.length && this.provider) {
|
|
1919
2142
|
for (let index = 0; index < args.speechChunks.length; index++) {
|
|
2143
|
+
await this.waitWhilePaused(args.runId);
|
|
1920
2144
|
if (args.runId !== this.speakRunId)
|
|
1921
2145
|
return;
|
|
1922
2146
|
const segment = this.seekSegments[index];
|
|
@@ -1928,23 +2152,19 @@ export class TTSService {
|
|
|
1928
2152
|
}
|
|
1929
2153
|
return;
|
|
1930
2154
|
}
|
|
1931
|
-
if (args.shouldUsePlan &&
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
}
|
|
1938
|
-
else if (this.provider) {
|
|
1939
|
-
await this.provider.speak(args.contentToSpeak);
|
|
1940
|
-
}
|
|
2155
|
+
if (args.shouldUsePlan &&
|
|
2156
|
+
this.currentContentElement &&
|
|
2157
|
+
this.seekSegments.length > 0) {
|
|
2158
|
+
await this.speakWithPlan(this.seekSegments, args.runId, {
|
|
2159
|
+
highlightMode: args.highlightMode,
|
|
2160
|
+
});
|
|
1941
2161
|
return;
|
|
1942
2162
|
}
|
|
1943
|
-
|
|
1944
|
-
await this.provider.speak(args.contentToSpeak);
|
|
1945
|
-
}
|
|
2163
|
+
await this.speakWithinLimit(args.contentToSpeak, args.runId);
|
|
1946
2164
|
}
|
|
1947
2165
|
clearHighlightsAndTracking() {
|
|
2166
|
+
// A finished run leaves no handler for the next read to inherit.
|
|
2167
|
+
this.clearWordBoundaryHighlighting();
|
|
1948
2168
|
this.pendingPlaybackStartHighlights = [];
|
|
1949
2169
|
this.activePlaybackRate = null;
|
|
1950
2170
|
if (this.highlightCoordinator) {
|
|
@@ -1959,38 +2179,22 @@ export class TTSService {
|
|
|
1959
2179
|
this.playbackChunks = [];
|
|
1960
2180
|
this.sentenceHighlightSegments = [];
|
|
1961
2181
|
this.activeSentenceStartOffset = null;
|
|
2182
|
+
this.wordlessPartHighlight = null;
|
|
1962
2183
|
}
|
|
1963
|
-
/**
|
|
1964
|
-
* Speak a text range with accurate word highlighting
|
|
1965
|
-
*
|
|
1966
|
-
* This calculates the offset of the range within its parent element
|
|
1967
|
-
* to ensure word highlighting aligns correctly with the selected text.
|
|
1968
|
-
*
|
|
1969
|
-
* @param range DOM Range to speak
|
|
1970
|
-
*/
|
|
1971
2184
|
/**
|
|
1972
2185
|
* The selected part of `root`'s normalized visible text and where it starts, or
|
|
1973
2186
|
* null when the map is unavailable or holds no selected character.
|
|
1974
2187
|
*/
|
|
1975
|
-
selectMappedRangeText(range, root) {
|
|
2188
|
+
selectMappedRangeText(range, root, language) {
|
|
1976
2189
|
if (typeof range.comparePoint !== "function")
|
|
1977
2190
|
return null;
|
|
1978
|
-
const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions());
|
|
2191
|
+
const { text: rootText, map } = collectVisibleTextAndMap(root, this.getTextProcessingOptions(language));
|
|
1979
2192
|
let start = -1;
|
|
1980
2193
|
let end = -1;
|
|
1981
2194
|
for (const [index, { node, offset }] of map) {
|
|
1982
2195
|
if (index >= rootText.length)
|
|
1983
2196
|
continue;
|
|
1984
|
-
|
|
1985
|
-
try {
|
|
1986
|
-
inside =
|
|
1987
|
-
range.comparePoint(node, offset) === 0 &&
|
|
1988
|
-
range.comparePoint(node, Math.min(offset + 1, node.length)) === 0;
|
|
1989
|
-
}
|
|
1990
|
-
catch {
|
|
1991
|
-
inside = false;
|
|
1992
|
-
}
|
|
1993
|
-
if (!inside)
|
|
2197
|
+
if (!rangeHoldsTextPosition(range, node, offset))
|
|
1994
2198
|
continue;
|
|
1995
2199
|
if (start === -1 || index < start)
|
|
1996
2200
|
start = index;
|
|
@@ -2004,103 +2208,39 @@ export class TTSService {
|
|
|
2004
2208
|
const text = raw.trim();
|
|
2005
2209
|
return text ? { text, offset: start + leading } : null;
|
|
2006
2210
|
}
|
|
2007
|
-
async speakRange(range, options) {
|
|
2008
|
-
if (!this.provider) {
|
|
2009
|
-
throw new Error("TTS service not initialized");
|
|
2010
|
-
}
|
|
2011
|
-
// Enforced from the live ancestors rather than from `root`, because the
|
|
2012
|
-
// element carrying the suppression may sit above whatever root the caller
|
|
2013
|
-
// passed or the range happens to resolve to.
|
|
2014
|
-
if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
|
|
2015
|
-
console.warn("[tts] selection lies inside content marked not-to-be-spoken; nothing was spoken. Selecting a node and pressing read-aloud must not become a way around suppression.");
|
|
2016
|
-
return;
|
|
2017
|
-
}
|
|
2018
|
-
// Use explicit content root when provided; otherwise keep highlighting scoped
|
|
2019
|
-
// to the selected range's nearest element ancestor.
|
|
2020
|
-
const fromOptions = options?.contentRoot || null;
|
|
2021
|
-
let root = fromOptions;
|
|
2022
|
-
if (!root) {
|
|
2023
|
-
const ancestor = range.commonAncestorContainer;
|
|
2024
|
-
root =
|
|
2025
|
-
ancestor.nodeType === Node.ELEMENT_NODE
|
|
2026
|
-
? ancestor
|
|
2027
|
-
: ancestor.parentElement;
|
|
2028
|
-
}
|
|
2029
|
-
if (!root)
|
|
2030
|
-
return;
|
|
2031
|
-
const selected = collectRangeTextForSpeech(range, root);
|
|
2032
|
-
const selectedText = selected.text.trim();
|
|
2033
|
-
if (!selectedText) {
|
|
2034
|
-
if (selected.filtered) {
|
|
2035
|
-
console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
|
|
2036
|
-
}
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
// The spoken text and its offset come from the root's visible-text map, which
|
|
2040
|
-
// the highlight position map and the structural speech plan index too. Joined
|
|
2041
|
-
// from raw text nodes instead, a selection loses the space the map inserts
|
|
2042
|
-
// between blocks, and every offset after that boundary drifts.
|
|
2043
|
-
const mapped = this.selectMappedRangeText(range, root);
|
|
2044
|
-
let text = selectedText;
|
|
2045
|
-
let offset = mapped?.offset ?? 0;
|
|
2046
|
-
if (mapped) {
|
|
2047
|
-
text = mapped.text;
|
|
2048
|
-
}
|
|
2049
|
-
else {
|
|
2050
|
-
// Filtered the same way as the speech itself: the offset indexes into the
|
|
2051
|
-
// highlight text, which comes from the exclusion-aware collectors.
|
|
2052
|
-
const beforeRange = document.createRange();
|
|
2053
|
-
beforeRange.selectNodeContents(root);
|
|
2054
|
-
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
2055
|
-
const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
|
|
2056
|
-
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
2057
|
-
offset =
|
|
2058
|
-
normalizedTextBeforeRange.length +
|
|
2059
|
-
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
2060
|
-
}
|
|
2061
|
-
this.debugLog("[TTSService] speakRange offset calculation:", {
|
|
2062
|
-
selectedText: text,
|
|
2063
|
-
mapped: !!mapped,
|
|
2064
|
-
offset,
|
|
2065
|
-
rootTag: root.tagName,
|
|
2066
|
-
});
|
|
2067
|
-
// Speak the text with the root element as context
|
|
2068
|
-
await this.speak(text, {
|
|
2069
|
-
contentElement: root,
|
|
2070
|
-
ignoreCatalogs: true,
|
|
2071
|
-
wordBoundaryOffset: offset,
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
2211
|
/**
|
|
2075
|
-
* Pause playback
|
|
2212
|
+
* Pause playback. A pause while the read is loading holds it: its audio does
|
|
2213
|
+
* not start until {@link resume}.
|
|
2076
2214
|
*/
|
|
2077
2215
|
pause() {
|
|
2078
|
-
if (
|
|
2216
|
+
if (this.state !== PlaybackState.PLAYING &&
|
|
2217
|
+
this.state !== PlaybackState.LOADING) {
|
|
2079
2218
|
return;
|
|
2080
|
-
if (this.state === PlaybackState.PLAYING) {
|
|
2081
|
-
this.activeRecordedAudio?.element.pause();
|
|
2082
|
-
this.provider.pause();
|
|
2083
|
-
this.setState(PlaybackState.PAUSED);
|
|
2084
2219
|
}
|
|
2220
|
+
this.resumeState = this.state;
|
|
2221
|
+
this.activeRecordedAudio?.element.pause();
|
|
2222
|
+
this.provider?.pause();
|
|
2223
|
+
// Between two parts, nothing is playing to pause: the run holds before
|
|
2224
|
+
// its next part instead.
|
|
2225
|
+
this.setState(PlaybackState.PAUSED);
|
|
2085
2226
|
}
|
|
2086
2227
|
/**
|
|
2087
|
-
* Resume playback
|
|
2228
|
+
* Resume playback, back to loading when the pause came before the audio
|
|
2229
|
+
* started.
|
|
2088
2230
|
*/
|
|
2089
2231
|
resume() {
|
|
2090
|
-
if (
|
|
2232
|
+
if (this.state !== PlaybackState.PAUSED)
|
|
2091
2233
|
return;
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
this.setState(PlaybackState.PLAYING);
|
|
2098
|
-
}
|
|
2234
|
+
// State first: audio that starts during resume reports into it.
|
|
2235
|
+
this.setState(this.resumeState);
|
|
2236
|
+
this.activeRecordedAudio?.play();
|
|
2237
|
+
this.provider?.resume();
|
|
2238
|
+
this.releasePauseHold();
|
|
2099
2239
|
}
|
|
2100
2240
|
normalizePlaybackRate(rate) {
|
|
2101
2241
|
if (!Number.isFinite(rate))
|
|
2102
2242
|
return 1;
|
|
2103
|
-
return Math.max(
|
|
2243
|
+
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
|
|
2104
2244
|
}
|
|
2105
2245
|
async restartFromSeekIndex(targetIndex) {
|
|
2106
2246
|
if (!this.provider || this.seekSegments.length === 0)
|
|
@@ -2109,18 +2249,27 @@ export class TTSService {
|
|
|
2109
2249
|
const restartChunks = this.playbackChunks.length
|
|
2110
2250
|
? this.playbackChunks.slice(safeTargetIndex)
|
|
2111
2251
|
: null;
|
|
2252
|
+
// A seek under a pause moves the cursor and stays paused: the restarted
|
|
2253
|
+
// run holds before its first part until resume.
|
|
2254
|
+
const holdPaused = this.state === PlaybackState.PAUSED;
|
|
2112
2255
|
this.speakRunId += 1;
|
|
2113
|
-
this.
|
|
2114
|
-
this.cancelRecordedAudio();
|
|
2256
|
+
this.abandonRun();
|
|
2115
2257
|
this.provider.onWordBoundary = undefined;
|
|
2116
2258
|
this.provider.stop();
|
|
2117
2259
|
const runId = ++this.speakRunId;
|
|
2118
2260
|
const restartSegments = this.seekSegments.slice(safeTargetIndex);
|
|
2119
2261
|
this.highlightCoordinator?.clearTTS();
|
|
2120
2262
|
this.activeSentenceStartOffset = null;
|
|
2121
|
-
this.
|
|
2263
|
+
this.currentBoundaryOffset = this.seekSegments[safeTargetIndex].startOffset;
|
|
2264
|
+
if (!holdPaused)
|
|
2265
|
+
this.setState(PlaybackState.LOADING);
|
|
2122
2266
|
const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
|
|
2123
|
-
if (
|
|
2267
|
+
if (holdPaused) {
|
|
2268
|
+
this.resumeState = playbackStartBarrier
|
|
2269
|
+
? PlaybackState.LOADING
|
|
2270
|
+
: PlaybackState.PLAYING;
|
|
2271
|
+
}
|
|
2272
|
+
else if (!playbackStartBarrier) {
|
|
2124
2273
|
this.setState(PlaybackState.PLAYING);
|
|
2125
2274
|
}
|
|
2126
2275
|
this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
|
|
@@ -2131,6 +2280,7 @@ export class TTSService {
|
|
|
2131
2280
|
});
|
|
2132
2281
|
if (restartChunks) {
|
|
2133
2282
|
for (let index = 0; index < restartChunks.length; index++) {
|
|
2283
|
+
await this.waitWhilePaused(runId);
|
|
2134
2284
|
if (runId !== this.speakRunId)
|
|
2135
2285
|
return;
|
|
2136
2286
|
const absoluteIndex = safeTargetIndex + index;
|
|
@@ -2152,8 +2302,11 @@ export class TTSService {
|
|
|
2152
2302
|
this.clearHighlightsAndTracking();
|
|
2153
2303
|
}
|
|
2154
2304
|
catch (error) {
|
|
2155
|
-
|
|
2305
|
+
// A superseded run's failure is the abort that superseded it.
|
|
2306
|
+
if (runId !== this.speakRunId) {
|
|
2307
|
+
logger.debug("Superseded seek restart failed:", error);
|
|
2156
2308
|
return;
|
|
2309
|
+
}
|
|
2157
2310
|
this.lastError = error instanceof Error ? error.message : String(error);
|
|
2158
2311
|
this.setState(PlaybackState.ERROR);
|
|
2159
2312
|
this.clearHighlightsAndTracking();
|
|
@@ -2251,59 +2404,49 @@ export class TTSService {
|
|
|
2251
2404
|
await this.seekBy(-step);
|
|
2252
2405
|
}
|
|
2253
2406
|
/**
|
|
2254
|
-
* Stop playback
|
|
2407
|
+
* Stop playback and release the run owner. A run that ends on its own keeps
|
|
2408
|
+
* its owner, so the owning control can replay it.
|
|
2255
2409
|
*/
|
|
2256
2410
|
stop() {
|
|
2257
|
-
if (!this.provider)
|
|
2258
|
-
return;
|
|
2259
2411
|
this.speakRunId += 1;
|
|
2260
|
-
this.
|
|
2261
|
-
this.
|
|
2262
|
-
this.
|
|
2263
|
-
this.provider.stop();
|
|
2264
|
-
this.setState(PlaybackState.IDLE);
|
|
2412
|
+
this.abandonRun();
|
|
2413
|
+
this.provider?.stop();
|
|
2414
|
+
this.clearHighlightsAndTracking();
|
|
2265
2415
|
this.currentText = null;
|
|
2266
|
-
//
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
// Clear tracking
|
|
2271
|
-
this.lastRenderedRegionTarget = null;
|
|
2272
|
-
this.currentContentElement = null;
|
|
2273
|
-
this.normalizedToDOM.clear();
|
|
2274
|
-
this.currentBoundaryOffset = 0;
|
|
2275
|
-
this.activeWordBoundaryOffset = 0;
|
|
2276
|
-
this.seekSegments = [];
|
|
2277
|
-
this.playbackChunks = [];
|
|
2278
|
-
this.sentenceHighlightSegments = [];
|
|
2279
|
-
this.activeSentenceStartOffset = null;
|
|
2280
|
-
this.activePlaybackRate = null;
|
|
2281
|
-
}
|
|
2282
|
-
/**
|
|
2283
|
-
* Request UI-level TTS controls to hand off/deactivate.
|
|
2284
|
-
*
|
|
2285
|
-
* This is intentionally separate from playback controls so hosts can orchestrate
|
|
2286
|
-
* control handoff explicitly (for example: stop playback, then dismiss controls).
|
|
2287
|
-
*/
|
|
2288
|
-
requestControlHandoff() {
|
|
2289
|
-
if (typeof window === "undefined")
|
|
2290
|
-
return;
|
|
2291
|
-
const detail = { source: "host" };
|
|
2292
|
-
window.dispatchEvent(new CustomEvent(PIE_TTS_CONTROL_HANDOFF_EVENT, {
|
|
2293
|
-
detail,
|
|
2294
|
-
}));
|
|
2416
|
+
// Announced even from idle, so a listener sees the owner released.
|
|
2417
|
+
const releasedOwner = this.runOwner !== null;
|
|
2418
|
+
this.runOwner = null;
|
|
2419
|
+
this.setState(PlaybackState.IDLE, releasedOwner);
|
|
2295
2420
|
}
|
|
2296
2421
|
/**
|
|
2297
|
-
*
|
|
2422
|
+
* Stop playback and drop the provider, whose owner destroys it. A later speak
|
|
2423
|
+
* waits on the readiness gate, as before the first {@link initialize}.
|
|
2298
2424
|
*/
|
|
2299
|
-
|
|
2300
|
-
|
|
2425
|
+
releaseProvider() {
|
|
2426
|
+
this.stop();
|
|
2427
|
+
this.provider = null;
|
|
2428
|
+
this.currentProvider = null;
|
|
2301
2429
|
}
|
|
2302
2430
|
/**
|
|
2303
|
-
*
|
|
2431
|
+
* Stop, release the provider and drop every listener, hook and pending timer.
|
|
2432
|
+
* A disposed service cannot speak or be initialized again.
|
|
2304
2433
|
*/
|
|
2305
|
-
|
|
2306
|
-
|
|
2434
|
+
dispose() {
|
|
2435
|
+
if (this.disposed)
|
|
2436
|
+
return;
|
|
2437
|
+
this.releaseProvider();
|
|
2438
|
+
this.disposed = true;
|
|
2439
|
+
this.speakRunId += 1;
|
|
2440
|
+
this.abandonRun();
|
|
2441
|
+
this.playbackRateRequestId += 1;
|
|
2442
|
+
this.pendingPlaybackRateRequest = null;
|
|
2443
|
+
this.listeners.clear();
|
|
2444
|
+
this.readinessGate = null;
|
|
2445
|
+
this.mathSpeechSource = null;
|
|
2446
|
+
this.highlightTargetResolverProvider = null;
|
|
2447
|
+
this.highlightCoordinator = null;
|
|
2448
|
+
this.catalogResolver = null;
|
|
2449
|
+
this.telemetryReporter = null;
|
|
2307
2450
|
}
|
|
2308
2451
|
/**
|
|
2309
2452
|
* Get current state
|
|
@@ -2311,60 +2454,36 @@ export class TTSService {
|
|
|
2311
2454
|
getState() {
|
|
2312
2455
|
return this.state;
|
|
2313
2456
|
}
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
*/
|
|
2317
|
-
getCurrentText() {
|
|
2318
|
-
return this.currentText;
|
|
2319
|
-
}
|
|
2320
|
-
/**
|
|
2321
|
-
* Get the last error message
|
|
2322
|
-
* Returns null if no error has occurred
|
|
2323
|
-
*/
|
|
2324
|
-
getLastError() {
|
|
2325
|
-
return this.lastError;
|
|
2326
|
-
}
|
|
2327
|
-
/**
|
|
2328
|
-
* Subscribe to state changes
|
|
2329
|
-
*
|
|
2330
|
-
* @param id Unique listener ID
|
|
2331
|
-
* @param callback Function to call on state change
|
|
2332
|
-
*/
|
|
2333
|
-
onStateChange(id, callback) {
|
|
2334
|
-
if (!this.listeners.has(id)) {
|
|
2335
|
-
this.listeners.set(id, new Set());
|
|
2336
|
-
}
|
|
2337
|
-
this.listeners.get(id).add(callback);
|
|
2457
|
+
getRunOwner() {
|
|
2458
|
+
return this.runOwner;
|
|
2338
2459
|
}
|
|
2339
2460
|
/**
|
|
2340
|
-
*
|
|
2461
|
+
* Subscribe to playback state changes. The returned function unsubscribes;
|
|
2462
|
+
* each subscription is its own, so the same callback can be subscribed twice.
|
|
2341
2463
|
*/
|
|
2342
|
-
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2464
|
+
onStateChange(callback) {
|
|
2465
|
+
const listener = (state) => callback(state);
|
|
2466
|
+
this.listeners.add(listener);
|
|
2467
|
+
return () => {
|
|
2468
|
+
this.listeners.delete(listener);
|
|
2469
|
+
};
|
|
2350
2470
|
}
|
|
2351
2471
|
/**
|
|
2352
|
-
* Set state and notify listeners
|
|
2472
|
+
* Set state and notify listeners. `announceUnchanged` notifies listeners even
|
|
2473
|
+
* when the state does not change; telemetry fires only on a change.
|
|
2353
2474
|
*/
|
|
2354
|
-
setState(newState) {
|
|
2355
|
-
if (this.state === newState)
|
|
2356
|
-
return;
|
|
2475
|
+
setState(newState, announceUnchanged = false) {
|
|
2357
2476
|
const previousState = this.state;
|
|
2477
|
+
if (previousState === newState && !announceUnchanged)
|
|
2478
|
+
return;
|
|
2358
2479
|
this.state = newState;
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
for (const callback of callbacks) {
|
|
2362
|
-
callback(newState);
|
|
2363
|
-
}
|
|
2480
|
+
for (const listener of [...this.listeners]) {
|
|
2481
|
+
listener(newState);
|
|
2364
2482
|
}
|
|
2483
|
+
if (previousState === newState)
|
|
2484
|
+
return;
|
|
2365
2485
|
void this.emitTelemetry("pie-tool-playback-state-changed", {
|
|
2366
2486
|
toolId: "textToSpeech",
|
|
2367
|
-
providerId: "tts",
|
|
2368
2487
|
previousState,
|
|
2369
2488
|
state: newState,
|
|
2370
2489
|
});
|
|
@@ -2374,7 +2493,6 @@ export class TTSService {
|
|
|
2374
2493
|
: "pie-tool-playback-start";
|
|
2375
2494
|
void this.emitTelemetry(eventName, {
|
|
2376
2495
|
toolId: "textToSpeech",
|
|
2377
|
-
providerId: "tts",
|
|
2378
2496
|
previousState,
|
|
2379
2497
|
state: newState,
|
|
2380
2498
|
});
|
|
@@ -2383,7 +2501,6 @@ export class TTSService {
|
|
|
2383
2501
|
if (newState === PlaybackState.PAUSED) {
|
|
2384
2502
|
void this.emitTelemetry("pie-tool-playback-pause", {
|
|
2385
2503
|
toolId: "textToSpeech",
|
|
2386
|
-
providerId: "tts",
|
|
2387
2504
|
previousState,
|
|
2388
2505
|
state: newState,
|
|
2389
2506
|
});
|
|
@@ -2392,7 +2509,6 @@ export class TTSService {
|
|
|
2392
2509
|
if (newState === PlaybackState.ERROR) {
|
|
2393
2510
|
void this.emitTelemetry("pie-tool-playback-error", {
|
|
2394
2511
|
toolId: "textToSpeech",
|
|
2395
|
-
providerId: "tts",
|
|
2396
2512
|
previousState,
|
|
2397
2513
|
state: newState,
|
|
2398
2514
|
message: this.lastError || undefined,
|
|
@@ -2405,7 +2521,6 @@ export class TTSService {
|
|
|
2405
2521
|
previousState === PlaybackState.LOADING)) {
|
|
2406
2522
|
void this.emitTelemetry("pie-tool-playback-stop", {
|
|
2407
2523
|
toolId: "textToSpeech",
|
|
2408
|
-
providerId: "tts",
|
|
2409
2524
|
previousState,
|
|
2410
2525
|
state: newState,
|
|
2411
2526
|
});
|